refactor(groups): migrate groups selectors to react [EE-3842] (#8936)

pull/9115/head
Chaim Lev-Ari 2023-06-22 21:11:10 +07:00 committed by GitHub
parent 2018529add
commit e91b4f5c83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 543 additions and 627 deletions

View File

@ -173,14 +173,7 @@
<div class="col-sm-12 form-section-title"> Target environments </div> <div class="col-sm-12 form-section-title"> Target environments </div>
<!-- node-selection --> <!-- node-selection -->
<associated-endpoints-selector <associated-edge-environments-selector value="$ctrl.model.Endpoints" on-change="($ctrl.onChangeEnvironments)"></associated-edge-environments-selector>
endpoint-ids="$ctrl.model.Endpoints"
tags="$ctrl.tags"
groups="$ctrl.groups"
has-backend-pagination="true"
on-associate="($ctrl.associateEndpoint)"
on-dissociate="($ctrl.dissociateEndpoint)"
></associated-endpoints-selector>
<!-- !node-selection --> <!-- !node-selection -->
<!-- actions --> <!-- actions -->
<div class="col-sm-12 form-section-title"> Actions </div> <div class="col-sm-12 form-section-title"> Actions </div>

View File

@ -1,4 +1,3 @@
import _ from 'lodash-es';
import moment from 'moment'; import moment from 'moment';
import { editor, upload } from '@@/BoxSelector/common-options/build-methods'; import { editor, upload } from '@@/BoxSelector/common-options/build-methods';
@ -45,8 +44,7 @@ export class EdgeJobFormController {
this.action = this.action.bind(this); this.action = this.action.bind(this);
this.editorUpdate = this.editorUpdate.bind(this); this.editorUpdate = this.editorUpdate.bind(this);
this.associateEndpoint = this.associateEndpoint.bind(this); this.onChangeEnvironments = this.onChangeEnvironments.bind(this);
this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
this.onChangeGroups = this.onChangeGroups.bind(this); this.onChangeGroups = this.onChangeGroups.bind(this);
this.onChange = this.onChange.bind(this); this.onChange = this.onChange.bind(this);
this.onCronMethodChange = this.onCronMethodChange.bind(this); this.onCronMethodChange = this.onCronMethodChange.bind(this);
@ -115,14 +113,10 @@ export class EdgeJobFormController {
this.isEditorDirty = true; this.isEditorDirty = true;
} }
associateEndpoint(endpoint) { onChangeEnvironments(value) {
if (!_.includes(this.model.Endpoints, endpoint.Id)) { return this.$scope.$evalAsync(() => {
this.model.Endpoints = [...this.model.Endpoints, endpoint.Id]; this.model.Endpoints = value;
} });
}
dissociateEndpoint(endpoint) {
this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id);
} }
$onInit() { $onInit() {

View File

@ -33,14 +33,7 @@
<!-- environments --> <!-- environments -->
<div class="col-sm-12 form-section-title"> Associated environments </div> <div class="col-sm-12 form-section-title"> Associated environments </div>
<div class="form-group"> <div class="form-group">
<associated-endpoints-selector <associated-edge-environments-selector value="$ctrl.model.Endpoints" on-change="($ctrl.onChangeEnvironments)"></associated-edge-environments-selector>
endpoint-ids="$ctrl.model.Endpoints"
tags="$ctrl.tags"
groups="$ctrl.groups"
has-backend-pagination="true"
on-associate="($ctrl.associateEndpoint)"
on-dissociate="($ctrl.dissociateEndpoint)"
></associated-endpoints-selector>
</div> </div>
</div> </div>
<div class="form-group" ng-if="$ctrl.noEndpoints"> <div class="form-group" ng-if="$ctrl.noEndpoints">
@ -59,23 +52,11 @@
<tag-selector ng-if="$ctrl.model.TagIds" value="$ctrl.model.TagIds" on-change="($ctrl.onChangeTags)"> </tag-selector> <tag-selector ng-if="$ctrl.model.TagIds" value="$ctrl.model.TagIds" on-change="($ctrl.onChangeTags)"> </tag-selector>
<div class="table-in-row"> <edge-group-association-table
<group-association-table title="'Associated environments by tags'"
loaded="$ctrl.loaded" empty-content-message="'No associated available'"
page-type="$ctrl.pageType" query="$ctrl.dynamicQuery"
table-type="associated" ></edge-group-association-table>
retrieve-page="$ctrl.getDynamicEndpoints"
dataset="$ctrl.endpoints.value"
pagination-state="$ctrl.endpoints.state"
empty-dataset-message="No associated endpoint"
tags="$ctrl.tags"
show-tags="true"
groups="$ctrl.groups"
show-groups="true"
has-backend-pagination="true"
title="Associated environments by tags"
></group-association-table>
</div>
</div> </div>
<!-- !DynamicGroup --> <!-- !DynamicGroup -->

View File

@ -1,9 +1,5 @@
import _ from 'lodash-es';
import { confirmDestructive } from '@@/modals/confirm'; import { confirmDestructive } from '@@/modals/confirm';
import { EdgeTypes } from '@/react/portainer/environments/types'; import { EdgeTypes } from '@/react/portainer/environments/types';
import { getEnvironments } from '@/react/portainer/environments/environment.service';
import { getTags } from '@/portainer/tags/tags.service';
import { notifyError } from '@/portainer/services/notifications';
import { buildConfirmButton } from '@@/modals/utils'; import { buildConfirmButton } from '@@/modals/utils';
import { tagOptions } from '@/react/edge/edge-groups/CreateView/tag-options'; import { tagOptions } from '@/react/edge/edge-groups/CreateView/tag-options';
import { groupTypeOptions } from '@/react/edge/edge-groups/CreateView/group-type-options'; import { groupTypeOptions } from '@/react/edge/edge-groups/CreateView/group-type-options';
@ -17,22 +13,13 @@ export class EdgeGroupFormController {
this.groupTypeOptions = groupTypeOptions; this.groupTypeOptions = groupTypeOptions;
this.tagOptions = tagOptions; this.tagOptions = tagOptions;
this.endpoints = { this.dynamicQuery = {
state: { types: EdgeTypes,
limit: '10', tagIds: [],
filter: '', tagsPartialMatch: false,
pageNumber: 1,
totalCount: 0,
},
value: null,
}; };
this.tags = []; this.onChangeEnvironments = this.onChangeEnvironments.bind(this);
this.associateEndpoint = this.associateEndpoint.bind(this);
this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
this.getDynamicEndpointsAsync = this.getDynamicEndpointsAsync.bind(this);
this.getDynamicEndpoints = this.getDynamicEndpoints.bind(this);
this.onChangeTags = this.onChangeTags.bind(this); this.onChangeTags = this.onChangeTags.bind(this);
this.onChangeDynamic = this.onChangeDynamic.bind(this); this.onChangeDynamic = this.onChangeDynamic.bind(this);
this.onChangeModel = this.onChangeModel.bind(this); this.onChangeModel = this.onChangeModel.bind(this);
@ -43,7 +30,11 @@ export class EdgeGroupFormController {
() => this.model, () => this.model,
() => { () => {
if (this.model.Dynamic) { if (this.model.Dynamic) {
this.getDynamicEndpoints(); this.dynamicQuery = {
types: EdgeTypes,
tagIds: this.model.TagIds,
tagsPartialMatch: this.model.PartialMatch,
};
} }
}, },
true true
@ -71,59 +62,25 @@ export class EdgeGroupFormController {
this.onChangeModel({ TagIds: value }); this.onChangeModel({ TagIds: value });
} }
associateEndpoint(endpoint) { onChangeEnvironments(value, meta) {
if (!_.includes(this.model.Endpoints, endpoint.Id)) {
this.model.Endpoints = [...this.model.Endpoints, endpoint.Id];
}
}
dissociateEndpoint(endpoint) {
return this.$async(async () => { return this.$async(async () => {
const confirmed = await confirmDestructive({ if (meta.type === 'remove' && this.pageType === 'edit') {
title: 'Confirm action', const confirmed = await confirmDestructive({
message: 'Removing the environment from this group will remove its corresponding edge stacks', title: 'Confirm action',
confirmButton: buildConfirmButton('Confirm'), message: 'Removing the environment from this group will remove its corresponding edge stacks',
}); confirmButton: buildConfirmButton('Confirm'),
});
if (!confirmed) { if (!confirmed) {
return; return;
}
} }
this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id); this.onChangeModel({ Endpoints: value });
});
}
getDynamicEndpoints() {
return this.$async(this.getDynamicEndpointsAsync);
}
async getDynamicEndpointsAsync() {
const { pageNumber, limit, search } = this.endpoints.state;
const start = (pageNumber - 1) * limit + 1;
const query = { search, types: EdgeTypes, tagIds: this.model.TagIds, tagsPartialMatch: this.model.PartialMatch };
const response = await getEnvironments({ start, limit, query });
const totalCount = parseInt(response.totalCount, 10);
this.endpoints.value = response.value;
this.endpoints.state.totalCount = totalCount;
}
getTags() {
return this.$async(async () => {
try {
this.tags = await getTags();
} catch (err) {
notifyError('Failure', err, 'Unable to retrieve tags');
}
}); });
} }
handleSubmit() { handleSubmit() {
this.formAction(this.model); this.formAction(this.model);
} }
$onInit() {
this.getTags();
}
} }

View File

@ -7,7 +7,6 @@ angular.module('portainer.edge').component('edgeGroupForm', {
controller: EdgeGroupFormController, controller: EdgeGroupFormController,
bindings: { bindings: {
model: '<', model: '<',
groups: '<',
formActionLabel: '@', formActionLabel: '@',
formAction: '<', formAction: '<',
actionInProgress: '<', actionInProgress: '<',

View File

@ -10,6 +10,8 @@ import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/compon
import { EditEdgeStackForm } from '@/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm'; import { EditEdgeStackForm } from '@/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm';
import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter'; import { withUIRouter } from '@/react-tools/withUIRouter';
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
export const componentsModule = angular export const componentsModule = angular
.module('portainer.edge.react.components', []) .module('portainer.edge.react.components', [])
@ -76,4 +78,22 @@ export const componentsModule = angular
'onSubmit', 'onSubmit',
'allowKubeToSelectCompose', 'allowKubeToSelectCompose',
]) ])
)
.component(
'edgeGroupAssociationTable',
r2a(withReactQuery(EdgeGroupAssociationTable), [
'emptyContentLabel',
'onClickRow',
'query',
'title',
'data-cy',
'hideEnvironmentIds',
])
)
.component(
'associatedEdgeEnvironmentsSelector',
r2a(withReactQuery(AssociatedEdgeEnvironmentsSelector), [
'onChange',
'value',
])
).name; ).name;

View File

@ -1,50 +0,0 @@
<div class="col-sm-12 small text-muted">
You can select which environment should be part of this group by moving them to the associated environments table. Simply click on any environment entry to move it from one table
to the other.
</div>
<div class="col-sm-12" style="margin-top: 20px">
<div class="table-row-container">
<!-- available-endpoints -->
<div class="datatable table-in-row">
<group-association-table
loaded="$ctrl.loaded"
page-type="$ctrl.pageType"
table-type="available"
retrieve-page="$ctrl.getAvailableEndpoints"
dataset="$ctrl.endpoints.available"
entry-click="$ctrl.associateEndpoint"
pagination-state="$ctrl.state.available"
empty-dataset-message="No environment available"
tags="$ctrl.tags"
show-tags="true"
groups="$ctrl.groups"
show-groups="true"
has-backend-pagination="true"
title="Available environments"
data-cy="edgeGroupCreate-availableEndpoints"
></group-association-table>
</div>
<!-- !available-endpoints -->
<!-- associated-endpoints -->
<div class="datatable table-in-row">
<group-association-table
loaded="$ctrl.loaded"
page-type="$ctrl.pageType"
table-type="associated"
retrieve-page="$ctrl.getAssociatedEndpoints"
dataset="$ctrl.endpoints.associated"
entry-click="$ctrl.dissociateEndpoint"
pagination-state="$ctrl.state.associated"
empty-dataset-message="No associated environment"
tags="$ctrl.tags"
show-tags="true"
groups="$ctrl.groups"
show-groups="true"
has-backend-pagination="true"
title="Associated environments"
data-cy="edgeGroupCreate-associatedEndpoints"
></group-association-table>
</div>
<!-- !associated-endpoints -->
</div>
</div>

View File

@ -1,16 +0,0 @@
import angular from 'angular';
import AssociatedEndpointsSelectorController from './associatedEndpointsSelectorController';
angular.module('portainer.app').component('associatedEndpointsSelector', {
templateUrl: './associatedEndpointsSelector.html',
controller: AssociatedEndpointsSelectorController,
bindings: {
endpointIds: '<',
tags: '<',
groups: '<',
hasBackendPagination: '<',
onAssociate: '<',
onDissociate: '<',
},
});

View File

@ -1,111 +0,0 @@
import angular from 'angular';
import _ from 'lodash-es';
import { EdgeTypes } from '@/react/portainer/environments/types';
import { getEnvironments } from '@/react/portainer/environments/environment.service';
class AssoicatedEndpointsSelectorController {
/* @ngInject */
constructor($async) {
this.$async = $async;
this.state = {
available: {
limit: '10',
filter: '',
pageNumber: 1,
totalCount: 0,
},
associated: {
limit: '10',
filter: '',
pageNumber: 1,
totalCount: 0,
},
};
this.endpoints = {
associated: [],
available: null,
};
this.getAvailableEndpoints = this.getAvailableEndpoints.bind(this);
this.getAssociatedEndpoints = this.getAssociatedEndpoints.bind(this);
this.associateEndpoint = this.associateEndpoint.bind(this);
this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
this.loadData = this.loadData.bind(this);
}
$onInit() {
this.loadData();
}
$onChanges({ endpointIds }) {
if (endpointIds && endpointIds.currentValue) {
this.loadData();
}
}
loadData() {
this.getAvailableEndpoints();
this.getAssociatedEndpoints();
}
/* #region internal queries to retrieve endpoints per "side" of the selector */
getAvailableEndpoints() {
return this.$async(async () => {
const { start, filter, limit } = this.getPaginationData('available');
const query = { search: filter, types: EdgeTypes };
const response = await getEnvironments({ start, limit, query });
const endpoints = _.filter(response.value, (endpoint) => !_.includes(this.endpointIds, endpoint.Id));
this.setTableData('available', endpoints, response.totalCount);
this.noEndpoints = this.state.available.totalCount === 0;
});
}
getAssociatedEndpoints() {
return this.$async(async () => {
let response = { value: [], totalCount: 0 };
if (this.endpointIds.length > 0) {
// fetch only if already has associated endpoints
const { start, filter, limit } = this.getPaginationData('associated');
const query = { search: filter, types: EdgeTypes, endpointIds: this.endpointIds };
response = await getEnvironments({ start, limit, query });
}
this.setTableData('associated', response.value, response.totalCount);
});
}
/* #endregion */
/* #region On endpoint click (either available or associated) */
associateEndpoint(endpoint) {
this.onAssociate(endpoint);
}
dissociateEndpoint(endpoint) {
this.onDissociate(endpoint);
}
/* #endregion */
/* #region Utils funcs */
getPaginationData(tableType) {
const { pageNumber, limit, filter } = this.state[tableType];
const start = (pageNumber - 1) * limit + 1;
return { start, filter, limit };
}
setTableData(tableType, endpoints, totalCount) {
this.endpoints[tableType] = endpoints;
this.state[tableType].totalCount = parseInt(totalCount, 10);
}
/* #endregion */
}
angular.module('portainer.app').controller('AssoicatedEndpointsSelectorController', AssoicatedEndpointsSelectorController);
export default AssoicatedEndpointsSelectorController;

View File

@ -6,14 +6,12 @@ angular.module('portainer.app').component('groupForm', {
controller: GroupFormController, controller: GroupFormController,
bindings: { bindings: {
loaded: '<', loaded: '<',
pageType: '@',
model: '=', model: '=',
availableEndpoints: '=',
associatedEndpoints: '=', associatedEndpoints: '=',
addLabelAction: '<',
removeLabelAction: '<',
formAction: '<', formAction: '<',
formActionLabel: '@', formActionLabel: '@',
actionInProgress: '<', actionInProgress: '<',
onChangeEnvironments: '<',
}, },
}); });

View File

@ -1,4 +1,4 @@
<form class="form-horizontal" name="endpointGroupForm"> <form class="form-horizontal" name="endpointGroupForm" ng-if="$ctrl.model">
<!-- name-input --> <!-- name-input -->
<div class="form-group"> <div class="form-group">
<label for="group_name" class="col-sm-3 col-lg-2 control-label required text-left">Name</label> <label for="group_name" class="col-sm-3 col-lg-2 control-label required text-left">Name</label>
@ -29,68 +29,19 @@
<tag-selector ng-if="$ctrl.model.TagIds" value="$ctrl.model.TagIds" on-change="($ctrl.onChangeTags)" allow-create="$ctrl.state.allowCreateTag"> </tag-selector> <tag-selector ng-if="$ctrl.model.TagIds" value="$ctrl.model.TagIds" on-change="($ctrl.onChangeTags)" allow-create="$ctrl.state.allowCreateTag"> </tag-selector>
<!-- environments --> <div class="form-group" ng-if="$ctrl.model.Id !== 1">
<div ng-if="$ctrl.model.Id !== 1"> <associated-endpoints-selector value="$ctrl.associatedEndpoints" on-change="($ctrl.onChangeEnvironments)"></associated-endpoints-selector>
<div class="col-sm-12 form-section-title"> Associated environments </div>
<div class="form-group">
<div class="col-sm-12 small text-muted">
You can select which environment should be part of this group by moving them to the associated environments table. Simply click on any environment entry to move it from one
table to the other.
</div>
<div class="col-sm-12" style="margin-top: 20px">
<!-- available-endpoints -->
<div class="table-row-container">
<div class="datatable table-in-row">
<group-association-table
loaded="$ctrl.loaded"
page-type="$ctrl.pageType"
table-type="available"
retrieve-page="$ctrl.getPaginatedEndpointsByGroup"
dataset="$ctrl.availableEndpoints"
entry-click="$ctrl.associateEndpoint"
pagination-state="$ctrl.state.available"
empty-dataset-message="No environment available"
title="Available environments"
cy-value="available-endpoints"
></group-association-table>
</div>
<!-- !available-endpoints -->
<!-- associated-endpoints -->
<div class="table-in-row">
<group-association-table
loaded="$ctrl.loaded"
page-type="$ctrl.pageType"
table-type="associated"
retrieve-page="$ctrl.getPaginatedEndpointsByGroup"
dataset="$ctrl.associatedEndpoints"
entry-click="$ctrl.dissociateEndpoint"
pagination-state="$ctrl.state.associated"
empty-dataset-message="No associated environment"
has-backend-pagination="this.pageType !== 'create'"
title="Associated environments"
cy-value="associated-endpoints"
></group-association-table>
</div>
</div>
<!-- !associated-endpoints -->
</div>
</div>
</div> </div>
<div ng-if="$ctrl.model.Id === 1">
<div class="table-in-row"> <div class="-mx-[15px]">
<group-association-table <group-association-table
loaded="$ctrl.loaded" ng-if="$ctrl.model.Id === 1"
page-type="$ctrl.pageType" title="'Unassociated environments'"
table-type="associated" empty-content-message="'No environment available'"
retrieve-page="$ctrl.getPaginatedEndpointsByGroup" query="$ctrl.unassociatedQuery"
dataset="$ctrl.associatedEndpoints" ></group-association-table>
pagination-state="$ctrl.state.associated"
empty-dataset-message="No environment available"
title="Unassociated environments"
></group-association-table>
</div>
</div> </div>
<!-- !endpoints -->
<!-- actions --> <!-- actions -->
<div class="col-sm-12 form-section-title"> Actions </div> <div class="col-sm-12 form-section-title"> Actions </div>
<div class="form-group"> <div class="form-group">

View File

@ -1,7 +1,4 @@
import _ from 'lodash-es';
import angular from 'angular'; import angular from 'angular';
import { endpointsByGroup } from '@/react/portainer/environments/environment.service';
import { notifyError } from '@/portainer/services/notifications';
class GroupFormController { class GroupFormController {
/* @ngInject */ /* @ngInject */
@ -12,9 +9,14 @@ class GroupFormController {
this.Notifications = Notifications; this.Notifications = Notifications;
this.Authentication = Authentication; this.Authentication = Authentication;
this.associateEndpoint = this.associateEndpoint.bind(this); this.state = {
this.dissociateEndpoint = this.dissociateEndpoint.bind(this); allowCreateTag: this.Authentication.isAdmin(),
this.getPaginatedEndpointsByGroup = this.getPaginatedEndpointsByGroup.bind(this); };
this.unassociatedQuery = {
groupIds: [1],
};
this.onChangeTags = this.onChangeTags.bind(this); this.onChangeTags = this.onChangeTags.bind(this);
} }
@ -23,81 +25,6 @@ class GroupFormController {
this.model.TagIds = value; this.model.TagIds = value;
}); });
} }
$onInit() {
this.state = {
available: {
limit: '10',
filter: '',
pageNumber: 1,
totalCount: 0,
},
associated: {
limit: '10',
filter: '',
pageNumber: 1,
totalCount: 0,
},
allowCreateTag: this.Authentication.isAdmin(),
};
}
associateEndpoint(endpoint) {
if (this.pageType === 'create' && !_.includes(this.associatedEndpoints, endpoint)) {
this.associatedEndpoints.push(endpoint);
} else if (this.pageType === 'edit') {
this.GroupService.addEndpoint(this.model.Id, endpoint)
.then(() => {
this.Notifications.success('Success', 'Environment successfully added to group');
this.reloadTablesContent();
})
.catch((err) => this.Notifications.error('Error', err, 'Unable to add environment to group'));
}
}
dissociateEndpoint(endpoint) {
if (this.pageType === 'create') {
_.remove(this.associatedEndpoints, (item) => item.Id === endpoint.Id);
} else if (this.pageType === 'edit') {
this.GroupService.removeEndpoint(this.model.Id, endpoint.Id)
.then(() => {
this.Notifications.success('Success', 'Environment successfully removed from group');
this.reloadTablesContent();
})
.catch((err) => this.Notifications.error('Error', err, 'Unable to remove environment from group'));
}
}
reloadTablesContent() {
this.getPaginatedEndpointsByGroup(this.pageType, 'available');
this.getPaginatedEndpointsByGroup(this.pageType, 'associated');
this.GroupService.group(this.model.Id).then((data) => {
this.model = data;
});
}
getPaginatedEndpointsByGroup(pageType, tableType) {
this.$async(async () => {
try {
if (tableType === 'available') {
const context = this.state.available;
const start = (context.pageNumber - 1) * context.limit + 1;
const data = await endpointsByGroup(1, start, context.limit, { search: context.filter });
this.availableEndpoints = data.value;
this.state.available.totalCount = data.totalCount;
} else if (tableType === 'associated' && pageType === 'edit') {
const groupId = this.model.Id ? this.model.Id : 1;
const context = this.state.associated;
const start = (context.pageNumber - 1) * context.limit + 1;
const data = await endpointsByGroup(groupId, start, context.limit, { search: context.filter });
this.associatedEndpoints = data.value;
this.state.associated.totalCount = data.totalCount;
}
// ignore (associated + create) group as there is no backend pagination for this table
} catch (err) {
notifyError('Failure', err, 'Failed getting endpoints for group');
}
});
}
} }
angular.module('portainer.app').controller('GroupFormController', GroupFormController); angular.module('portainer.app').controller('GroupFormController', GroupFormController);

View File

@ -1,70 +0,0 @@
import _ from 'lodash-es';
import { idsToTagNames } from 'Portainer/helpers/tagHelper';
angular.module('portainer.app').component('groupAssociationTable', {
templateUrl: './groupAssociationTable.html',
controller: function () {
this.state = {
orderBy: 'Name',
reverseOrder: false,
paginatedItemLimit: '10',
textFilter: '',
loading: true,
pageNumber: 1,
};
this.changeOrderBy = function (orderField) {
this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false;
this.state.orderBy = orderField;
};
this.onTextFilterChange = function () {
this.paginationChangedAction();
};
this.onPageChanged = function (newPageNumber) {
this.paginationState.pageNumber = newPageNumber;
this.paginationChangedAction();
};
this.onPaginationLimitChanged = function () {
this.paginationChangedAction();
};
this.paginationChangedAction = function () {
this.retrievePage(this.pageType, this.tableType);
};
this.$onChanges = function (changes) {
if (changes.loaded && changes.loaded.currentValue) {
this.paginationChangedAction();
}
};
this.tagIdsToTagNames = function tagIdsToTagNames(tagIds) {
return idsToTagNames(this.tags, tagIds).join(', ') || '-';
};
this.groupIdToGroupName = function groupIdToGroupName(groupId) {
const group = _.find(this.groups, { Id: groupId });
return group ? group.Name : '';
};
},
bindings: {
paginationState: '=',
loaded: '<',
pageType: '<',
tableType: '@',
retrievePage: '<',
dataset: '<',
entryClick: '<',
emptyDatasetMessage: '@',
tags: '<',
showTags: '<',
groups: '<',
showGroups: '<',
hasBackendPagination: '<',
cyValue: '@',
title: '@',
},
});

View File

@ -1,85 +0,0 @@
<div class="datatable">
<div class="toolBar">
<div class="toolBarTitle">{{ $ctrl.title }}</div>
<div class="searchBar vertical-center">
<pr-icon icon="'search'" class-name="'searchIcon'"></pr-icon>
<input
type="text"
class="searchInput"
ng-model="$ctrl.paginationState.filter"
ng-change="$ctrl.onTextFilterChange()"
ng-model-options="{ debounce: 300 }"
placeholder="Search..."
/>
</div>
</div>
<table class="table-hover table" data-cy="{{ $ctrl.cyValue }}">
<thead>
<tr>
<th> Name </th>
<th ng-if="$ctrl.showGroups"> Group </th>
<th ng-if="$ctrl.showTags"> Tags </th>
</tr>
</thead>
<tbody>
<tr
ng-if="!$ctrl.hasBackendPagination"
ng-click="$ctrl.entryClick(item)"
class="interactive"
dir-paginate="item in $ctrl.dataset | filter:$ctrl.paginationState.filter | itemsPerPage: $ctrl.paginationState.limit"
pagination-id="$ctrl.tableType"
>
<td>
{{ item.Name | truncate : 64 }}
</td>
<td ng-if="$ctrl.showGroups">
{{ $ctrl.groupIdToGroupName(item.GroupId) | truncate : 64 }}
</td>
<td ng-if="$ctrl.showTags">
{{ $ctrl.tagIdsToTagNames(item.TagIds) | arraytostr | truncate : 64 }}
</td>
</tr>
<tr
ng-if="$ctrl.hasBackendPagination"
ng-click="$ctrl.entryClick(item)"
class="interactive"
dir-paginate="item in $ctrl.dataset | itemsPerPage: $ctrl.paginationState.limit"
pagination-id="$ctrl.tableType"
total-items="$ctrl.paginationState.totalCount"
>
<td>
{{ item.Name | truncate : 64 }}
</td>
<td ng-if="$ctrl.showGroups">
{{ $ctrl.groupIdToGroupName(item.GroupId) | truncate : 64 }}
</td>
<td ng-if="$ctrl.showTags">
{{ $ctrl.tagIdsToTagNames(item.TagIds) | truncate : 64 }}
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-muted text-center">Loading...</td>
</tr>
<tr ng-if="$ctrl.dataset.length === 0">
<td colspan="3" class="text-muted text-center">{{ $ctrl.emptyDatasetMessage }}</td>
</tr>
</tbody>
</table>
<div class="footer" ng-if="$ctrl.dataset">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span class="space-right"> Items per page </span>
<select ng-model="$ctrl.paginationState.limit" ng-change="$ctrl.onPaginationLimitChanged()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls pagination-id="$ctrl.tableType" max-size="5" on-page-change="$ctrl.onPageChanged(newPageNumber, oldPageNumber)"></dir-pagination-controls>
</form>
</div>
</div>
</div>

View File

@ -2,6 +2,7 @@ export function EndpointGroupDefaultModel() {
this.Name = ''; this.Name = '';
this.Description = ''; this.Description = '';
this.TagIds = []; this.TagIds = [];
this.AssociatedEndpoints = [];
} }
export function EndpointGroupModel(data) { export function EndpointGroupModel(data) {

View File

@ -6,6 +6,8 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter'; import { withUIRouter } from '@/react-tools/withUIRouter';
import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsBeTeaser'; import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsBeTeaser';
import { withFormValidation } from '@/react-tools/withFormValidation'; import { withFormValidation } from '@/react-tools/withFormValidation';
import { GroupAssociationTable } from '@/react/portainer/environments/environment-groups/components/GroupAssociationTable';
import { AssociatedEnvironmentsSelector } from '@/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector';
import { import {
EnvironmentVariablesFieldset, EnvironmentVariablesFieldset,
@ -204,7 +206,21 @@ export const ngModule = angular
'height', 'height',
]) ])
) )
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, [])); .component(
'groupAssociationTable',
r2a(withReactQuery(GroupAssociationTable), [
'emptyContentLabel',
'onClickRow',
'query',
'title',
'data-cy',
])
)
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []))
.component(
'associatedEndpointsSelector',
r2a(withReactQuery(AssociatedEnvironmentsSelector), ['onChange', 'value'])
);
export const componentsModule = ngModule.name; export const componentsModule = ngModule.name;

View File

@ -40,8 +40,8 @@ angular.module('portainer.app').factory('GroupService', [
return EndpointGroups.updateAccess({ id: groupId }, { UserAccessPolicies: userAccessPolicies, TeamAccessPolicies: teamAccessPolicies }).$promise; return EndpointGroups.updateAccess({ id: groupId }, { UserAccessPolicies: userAccessPolicies, TeamAccessPolicies: teamAccessPolicies }).$promise;
}; };
service.addEndpoint = function (groupId, endpoint) { service.addEndpoint = function (groupId, endpointId) {
return EndpointGroups.addEndpoint({ id: groupId, action: 'endpoints/' + endpoint.Id }, endpoint).$promise; return EndpointGroups.addEndpoint({ id: groupId, action: 'endpoints/' + endpointId }).$promise;
}; };
service.removeEndpoint = function (groupId, endpointId) { service.removeEndpoint = function (groupId, endpointId) {

View File

@ -17,10 +17,12 @@ export const tagKeys = {
export function useTags<T = Tag[]>({ export function useTags<T = Tag[]>({
select, select,
}: { select?: (tags: Tag[]) => T } = {}) { enabled = true,
}: { select?: (tags: Tag[]) => T; enabled?: boolean } = {}) {
return useQuery(tagKeys.all, () => getTags(), { return useQuery(tagKeys.all, () => getTags(), {
staleTime: 50, staleTime: 50,
select, select,
enabled,
...withError('Failed to retrieve tags'), ...withError('Failed to retrieve tags'),
}); });
} }

View File

@ -5,17 +5,13 @@ angular.module('portainer.app').controller('CreateGroupController', function Cre
actionInProgress: false, actionInProgress: false,
}; };
$scope.onChangeEnvironments = onChangeEnvironments;
$scope.create = function () { $scope.create = function () {
var model = $scope.model; var model = $scope.model;
var associatedEndpoints = [];
for (var i = 0; i < $scope.associatedEndpoints.length; i++) {
var endpoint = $scope.associatedEndpoints[i];
associatedEndpoints.push(endpoint.Id);
}
$scope.state.actionInProgress = true; $scope.state.actionInProgress = true;
GroupService.createGroup(model, associatedEndpoints) GroupService.createGroup(model, $scope.associatedEndpoints)
.then(function success() { .then(function success() {
Notifications.success('Success', 'Group successfully created'); Notifications.success('Success', 'Group successfully created');
$state.go('portainer.groups', {}, { reload: true }); $state.go('portainer.groups', {}, { reload: true });
@ -34,5 +30,11 @@ angular.module('portainer.app').controller('CreateGroupController', function Cre
$scope.loaded = true; $scope.loaded = true;
} }
function onChangeEnvironments(value) {
return $scope.$evalAsync(() => {
$scope.associatedEndpoints = value;
});
}
initView(); initView();
}); });

View File

@ -6,15 +6,12 @@
<rd-widget-body> <rd-widget-body>
<group-form <group-form
loaded="loaded" loaded="loaded"
page-type="create"
model="model" model="model"
available-endpoints="availableEndpoints"
associated-endpoints="associatedEndpoints" associated-endpoints="associatedEndpoints"
add-label-action="addLabel"
remove-label-action="removeLabel"
form-action="create" form-action="create"
form-action-label="Create the group" form-action-label="Create the group"
action-in-progress="state.actionInProgress" action-in-progress="state.actionInProgress"
on-change-environments="(onChangeEnvironments)"
></group-form> ></group-form>
</rd-widget-body> </rd-widget-body>
</rd-widget> </rd-widget>

View File

@ -6,15 +6,12 @@
<rd-widget-body> <rd-widget-body>
<group-form <group-form
loaded="loaded" loaded="loaded"
page-type="edit"
model="group" model="group"
available-endpoints="availableEndpoints"
associated-endpoints="associatedEndpoints" associated-endpoints="associatedEndpoints"
add-label-action="addLabel"
remove-label-action="removeLabel"
form-action="update" form-action="update"
form-action-label="Update the group" form-action-label="Update the group"
action-in-progress="state.actionInProgress" action-in-progress="state.actionInProgress"
on-change-environments="(onChangeEnvironments)"
></group-form> ></group-form>
</rd-widget-body> </rd-widget-body>
</rd-widget> </rd-widget>

View File

@ -1,7 +1,12 @@
angular.module('portainer.app').controller('GroupController', function GroupController($q, $scope, $state, $transition$, GroupService, Notifications) { import { getEnvironments } from '@/react/portainer/environments/environment.service';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
angular.module('portainer.app').controller('GroupController', function GroupController($async, $q, $scope, $state, $transition$, GroupService, Notifications) {
$scope.state = { $scope.state = {
actionInProgress: false, actionInProgress: false,
}; };
$scope.onChangeEnvironments = onChangeEnvironments;
$scope.associatedEndpoints = [];
$scope.update = function () { $scope.update = function () {
var model = $scope.group; var model = $scope.group;
@ -20,14 +25,53 @@ angular.module('portainer.app').controller('GroupController', function GroupCont
}); });
}; };
function onChangeEnvironments(value, meta) {
return $async(async () => {
let success = false;
if (meta.type === 'add') {
success = await onAssociate(meta.value);
} else if (meta.type === 'remove') {
success = await onDisassociate(meta.value);
}
if (success) {
$scope.associatedEndpoints = value;
}
});
}
async function onAssociate(endpointId) {
try {
await GroupService.addEndpoint($scope.group.Id, endpointId);
notifySuccess('Success', `Environment successfully added to group`);
return true;
} catch (err) {
notifyError('Failure', err, `Unable to add environment to group`);
}
}
async function onDisassociate(endpointId) {
try {
await GroupService.removeEndpoint($scope.group.Id, endpointId);
notifySuccess('Success', `Environment successfully removed to group`);
return true;
} catch (err) {
notifyError('Failure', err, `Unable to remove environment to group`);
}
}
function initView() { function initView() {
var groupId = $transition$.params().id; var groupId = $transition$.params().id;
$q.all({ $q.all({
group: GroupService.group(groupId), group: GroupService.group(groupId),
endpoints: getEnvironments({ query: { groupIds: [groupId] } }),
}) })
.then(function success(data) { .then(function success(data) {
$scope.group = data.group; $scope.group = data.group;
$scope.associatedEndpoints = data.endpoints.value.map((endpoint) => endpoint.Id);
$scope.loaded = true; $scope.loaded = true;
}) })
.catch(function error(err) { .catch(function error(err) {

View File

@ -17,6 +17,8 @@ import { ReactNode, useMemo } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import _ from 'lodash'; import _ from 'lodash';
import { AutomationTestingProps } from '@/types';
import { IconProps } from '@@/Icon'; import { IconProps } from '@@/Icon';
import { DatatableHeader } from './DatatableHeader'; import { DatatableHeader } from './DatatableHeader';
@ -32,7 +34,7 @@ import { TableRow } from './TableRow';
export interface Props< export interface Props<
D extends Record<string, unknown>, D extends Record<string, unknown>,
TSettings extends BasicTableSettings = BasicTableSettings TSettings extends BasicTableSettings = BasicTableSettings
> { > extends AutomationTestingProps {
dataset: D[]; dataset: D[];
columns: TableOptions<D>['columns']; columns: TableOptions<D>['columns'];
renderTableSettings?(instance: TableInstance<D>): ReactNode; renderTableSettings?(instance: TableInstance<D>): ReactNode;
@ -82,6 +84,7 @@ export function Datatable<D extends Record<string, unknown>>({
highlightedItemId, highlightedItemId,
noWidget, noWidget,
getRowCanExpand, getRowCanExpand,
'data-cy': dataCy,
}: Props<D>) { }: Props<D>) {
const isServerSidePagination = typeof pageCount !== 'undefined'; const isServerSidePagination = typeof pageCount !== 'undefined';
const enableRowSelection = getIsSelectionEnabled( const enableRowSelection = getIsSelectionEnabled(
@ -156,6 +159,7 @@ export function Datatable<D extends Record<string, unknown>>({
emptyContentLabel={emptyContentLabel} emptyContentLabel={emptyContentLabel}
isLoading={isLoading} isLoading={isLoading}
onSortChange={handleSortChange} onSortChange={handleSortChange}
data-cy={dataCy}
/> />
<DatatableFooter <DatatableFooter

View File

@ -1,8 +1,11 @@
import { Row, Table as TableInstance } from '@tanstack/react-table'; import { Row, Table as TableInstance } from '@tanstack/react-table';
import { AutomationTestingProps } from '@/types';
import { Table } from './Table'; import { Table } from './Table';
interface Props<D extends Record<string, unknown>> { interface Props<D extends Record<string, unknown>>
extends AutomationTestingProps {
tableInstance: TableInstance<D>; tableInstance: TableInstance<D>;
renderRow(row: Row<D>): React.ReactNode; renderRow(row: Row<D>): React.ReactNode;
onSortChange?(colId: string, desc: boolean): void; onSortChange?(colId: string, desc: boolean): void;
@ -16,12 +19,13 @@ export function DatatableContent<D extends Record<string, unknown>>({
onSortChange, onSortChange,
isLoading, isLoading,
emptyContentLabel, emptyContentLabel,
'data-cy': dataCy,
}: Props<D>) { }: Props<D>) {
const headerGroups = tableInstance.getHeaderGroups(); const headerGroups = tableInstance.getHeaderGroups();
const pageRowModel = tableInstance.getPaginationRowModel(); const pageRowModel = tableInstance.getPaginationRowModel();
return ( return (
<Table> <Table data-cy={dataCy}>
<thead> <thead>
{headerGroups.map((headerGroup) => ( {headerGroups.map((headerGroup) => (
<Table.HeaderRow<D> <Table.HeaderRow<D>

View File

@ -1,6 +1,8 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { AutomationTestingProps } from '@/types';
import { TableContainer } from './TableContainer'; import { TableContainer } from './TableContainer';
import { TableActions } from './TableActions'; import { TableActions } from './TableActions';
import { TableFooter } from './TableFooter'; import { TableFooter } from './TableFooter';
@ -12,14 +14,19 @@ import { TableHeaderCell } from './TableHeaderCell';
import { TableHeaderRow } from './TableHeaderRow'; import { TableHeaderRow } from './TableHeaderRow';
import { TableRow } from './TableRow'; import { TableRow } from './TableRow';
interface Props { interface Props extends AutomationTestingProps {
className?: string; className?: string;
} }
function MainComponent({ children, className }: PropsWithChildren<Props>) { function MainComponent({
children,
className,
'data-cy': dataCy,
}: PropsWithChildren<Props>) {
return ( return (
<div className="table-responsive"> <div className="table-responsive">
<table <table
data-cy={dataCy}
className={clsx( className={clsx(
'table-hover table-filters nowrap-cells table', 'table-hover table-filters nowrap-cells table',
className className

View File

@ -14,6 +14,9 @@ export function createSelectColumn<T>(): ColumnDef<T> {
indeterminate={table.getIsSomeRowsSelected()} indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()} onChange={table.getToggleAllRowsSelectedHandler()}
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())} disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
onClick={(e) => {
e.stopPropagation();
}}
/> />
), ),
cell: ({ row, table }) => ( cell: ({ row, table }) => (
@ -24,6 +27,8 @@ export function createSelectColumn<T>(): ColumnDef<T> {
onChange={row.getToggleSelectedHandler()} onChange={row.getToggleSelectedHandler()}
disabled={!row.getCanSelect()} disabled={!row.getCanSelect()}
onClick={(e) => { onClick={(e) => {
e.stopPropagation();
if (e.shiftKey) { if (e.shiftKey) {
const { rows, rowsById } = table.getRowModel(); const { rows, rowsById } = table.getRowModel();
const rowsToToggle = getRowRange(rows, row.id, lastSelectedId); const rowsToToggle = getRowRange(rows, row.id, lastSelectedId);

View File

@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { useStore } from 'zustand'; import { useStore } from 'zustand';
import { useSearchBarState } from './SearchBar'; import { useSearchBarState } from './SearchBar';
@ -27,3 +27,23 @@ export function useTableState<
[settings, search, setSearch] [settings, search, setSearch]
); );
} }
export function useTableStateWithoutStorage(
defaultSortKey: string
): BasicTableSettings & {
setSearch: (search: string) => void;
search: string;
} {
const [search, setSearch] = useState('');
const [pageSize, setPageSize] = useState(10);
const [sortBy, setSortBy] = useState({ id: defaultSortKey, desc: false });
return {
search,
setSearch,
pageSize,
setPageSize,
setSortBy: (id: string, desc: boolean) => setSortBy({ id, desc }),
sortBy,
};
}

View File

@ -0,0 +1,63 @@
import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
import { EdgeGroupAssociationTable } from './EdgeGroupAssociationTable';
export function AssociatedEdgeEnvironmentsSelector({
onChange,
value,
}: {
onChange: (
value: EnvironmentId[],
meta: { type: 'add' | 'remove'; value: EnvironmentId }
) => void;
value: EnvironmentId[];
}) {
return (
<>
<div className="col-sm-12 small text-muted">
You can select which environment should be part of this group by moving
them to the associated environments table. Simply click on any
environment entry to move it from one table to the other.
</div>
<div className="col-sm-12 mt-4">
<div className="flex">
<div className="w-1/2">
<EdgeGroupAssociationTable
title="Available environments"
emptyContentLabel="No environment available"
query={{
types: EdgeTypes,
}}
onClickRow={(env) => {
if (!value.includes(env.Id)) {
onChange([...value, env.Id], { type: 'add', value: env.Id });
}
}}
data-cy="edgeGroupCreate-availableEndpoints"
hideEnvironmentIds={value}
/>
</div>
<div className="w-1/2">
<EdgeGroupAssociationTable
title="Associated environments"
emptyContentLabel="No associated environment'"
query={{
types: EdgeTypes,
endpointIds: value,
}}
onClickRow={(env) => {
if (value.includes(env.Id)) {
onChange(
value.filter((id) => id !== env.Id),
{ type: 'remove', value: env.Id }
);
}
}}
/>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,117 @@
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useMemo, useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import {
Environment,
EnvironmentId,
} from '@/react/portainer/environments/types';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { useTags } from '@/portainer/tags/queries';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
import { AutomationTestingProps } from '@/types';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables';
type DecoratedEnvironment = Environment & {
Tags: string[];
Group: string;
};
const columHelper = createColumnHelper<DecoratedEnvironment>();
const columns = [
columHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
columHelper.accessor('Group', {
header: 'Group',
id: 'Group',
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
columHelper.accessor((row) => row.Tags.join(','), {
header: 'Tags',
id: 'tags',
enableSorting: false,
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
];
export function EdgeGroupAssociationTable({
title,
query,
emptyContentLabel,
onClickRow,
'data-cy': dataCy,
hideEnvironmentIds = [],
}: {
title: string;
query: EnvironmentsQueryParams;
emptyContentLabel: string;
onClickRow: (env: Environment) => void;
hideEnvironmentIds?: EnvironmentId[];
} & AutomationTestingProps) {
const tableState = useTableStateWithoutStorage('Name');
const [page, setPage] = useState(1);
const environmentsQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page,
search: tableState.search,
sort: tableState.sortBy.id as 'Group' | 'Name',
order: tableState.sortBy.desc ? 'desc' : 'asc',
...query,
});
const groupsQuery = useGroups({
enabled: environmentsQuery.environments.length > 0,
});
const tagsQuery = useTags({
enabled: environmentsQuery.environments.length > 0,
});
const environments: Array<DecoratedEnvironment> = useMemo(
() =>
environmentsQuery.environments
.filter((e) => !hideEnvironmentIds.includes(e.Id))
.map((env) => ({
...env,
Group:
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
Tags: env.TagIds.map(
(tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
),
})),
[
environmentsQuery.environments,
groupsQuery.data,
hideEnvironmentIds,
tagsQuery.data,
]
);
const totalCount = environmentsQuery.totalCount - hideEnvironmentIds.length;
return (
<Datatable<DecoratedEnvironment>
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
onPageChange={setPage}
pageCount={Math.ceil(totalCount / tableState.pageSize)}
renderRow={(row) => (
<TableRow<DecoratedEnvironment>
cells={row.getVisibleCells()}
onClick={() => onClickRow(row.original)}
/>
)}
emptyContentLabel={emptyContentLabel}
data-cy={dataCy}
disableSelect
totalCount={totalCount}
/>
);
}

View File

@ -14,6 +14,7 @@ import {
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { import {
refetchIfAnyOffline, refetchIfAnyOffline,
SortType,
useEnvironmentList, useEnvironmentList,
} from '@/react/portainer/environments/queries/useEnvironmentList'; } from '@/react/portainer/environments/queries/useEnvironmentList';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries'; import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
@ -68,7 +69,9 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
'group', 'group',
[] []
); );
const [sortByFilter, setSortByFilter] = useSearchBarState('sortBy'); const [sortByFilter, setSortByFilter] = useHomePageFilter<
SortType | undefined
>('sortBy', 'Name');
const [sortByDescending, setSortByDescending] = useHomePageFilter( const [sortByDescending, setSortByDescending] = useHomePageFilter(
'sortOrder', 'sortOrder',
false false
@ -342,7 +345,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
setConnectionTypes([]); setConnectionTypes([]);
} }
function sortOnchange(value: string) { function sortOnchange(value?: 'Name' | 'Group' | 'Status') {
setSortByFilter(value); setSortByFilter(value);
setSortByButton(!!value); setSortByButton(!!value);
} }

View File

@ -6,6 +6,10 @@ import { useAgentVersionsList } from '../../environments/queries/useAgentVersion
import { EnvironmentStatus, PlatformType } from '../../environments/types'; import { EnvironmentStatus, PlatformType } from '../../environments/types';
import { isBE } from '../../feature-flags/feature-flags.service'; import { isBE } from '../../feature-flags/feature-flags.service';
import { useGroups } from '../../environments/environment-groups/queries'; import { useGroups } from '../../environments/environment-groups/queries';
import {
SortOptions,
SortType,
} from '../../environments/queries/useEnvironmentList';
import { HomepageFilter } from './HomepageFilter'; import { HomepageFilter } from './HomepageFilter';
import { SortbySelector } from './SortbySelector'; import { SortbySelector } from './SortbySelector';
@ -17,7 +21,7 @@ const status = [
{ value: EnvironmentStatus.Down, label: 'Down' }, { value: EnvironmentStatus.Down, label: 'Down' },
]; ];
const sortByOptions = ['Name', 'Group', 'Status'].map((v) => ({ const sortByOptions = SortOptions.map((v) => ({
value: v, value: v,
label: v, label: v,
})); }));
@ -60,8 +64,8 @@ export function EnvironmentListFilters({
setAgentVersions: (value: string[]) => void; setAgentVersions: (value: string[]) => void;
agentVersions: string[]; agentVersions: string[];
sortByState: string; sortByState?: SortType;
sortOnChange: (value: string) => void; sortOnChange: (value: SortType) => void;
sortOnDescending: () => void; sortOnDescending: () => void;
sortByDescending: boolean; sortByDescending: boolean;

View File

@ -3,16 +3,18 @@ import clsx from 'clsx';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect'; import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { TableHeaderSortIcons } from '@@/datatables/TableHeaderSortIcons'; import { TableHeaderSortIcons } from '@@/datatables/TableHeaderSortIcons';
import { SortType } from '../../environments/queries/useEnvironmentList';
import styles from './SortbySelector.module.css'; import styles from './SortbySelector.module.css';
interface Props { interface Props {
filterOptions: Option<string>[]; filterOptions: Option<SortType>[];
onChange: (value: string) => void; onChange: (value: SortType) => void;
onDescending: () => void; onDescending: () => void;
placeHolder: string; placeHolder: string;
sortByDescending: boolean; sortByDescending: boolean;
sortByButton: boolean; sortByButton: boolean;
value: string; value?: SortType;
} }
export function SortbySelector({ export function SortbySelector({
@ -30,7 +32,7 @@ export function SortbySelector({
<PortainerSelect <PortainerSelect
placeholder={placeHolder} placeholder={placeHolder}
options={filterOptions} options={filterOptions}
onChange={(option) => onChange(option || '')} onChange={(option: SortType) => onChange(option || '')}
isClearable isClearable
value={value} value={value}
/> />

View File

@ -11,7 +11,7 @@ import { Link } from '@@/Link';
import { useTableState } from '@@/datatables/useTableState'; import { useTableState } from '@@/datatables/useTableState';
import { isBE } from '../../feature-flags/feature-flags.service'; import { isBE } from '../../feature-flags/feature-flags.service';
import { refetchIfAnyOffline } from '../queries/useEnvironmentList'; import { isSortType, refetchIfAnyOffline } from '../queries/useEnvironmentList';
import { columns } from './columns'; import { columns } from './columns';
import { EnvironmentListItem } from './types'; import { EnvironmentListItem } from './types';
@ -36,7 +36,7 @@ export function EnvironmentsDatatable({
excludeSnapshots: true, excludeSnapshots: true,
page: page + 1, page: page + 1,
pageLimit: tableState.pageSize, pageLimit: tableState.pageSize,
sort: tableState.sortBy.id, sort: isSortType(tableState.sortBy.id) ? tableState.sortBy.id : undefined,
order: tableState.sortBy.desc ? 'desc' : 'asc', order: tableState.sortBy.desc ? 'desc' : 'asc',
}, },
{ enabled: groupsQuery.isSuccess, refetchInterval: refetchIfAnyOffline } { enabled: groupsQuery.isSuccess, refetchInterval: refetchIfAnyOffline }

View File

@ -0,0 +1,61 @@
import { EnvironmentId } from '../../types';
import { GroupAssociationTable } from './GroupAssociationTable';
export function AssociatedEnvironmentsSelector({
onChange,
value,
}: {
onChange: (
value: EnvironmentId[],
meta: { type: 'add' | 'remove'; value: EnvironmentId }
) => void;
value: EnvironmentId[];
}) {
return (
<>
<div className="col-sm-12 small text-muted">
You can select which environment should be part of this group by moving
them to the associated environments table. Simply click on any
environment entry to move it from one table to the other.
</div>
<div className="col-sm-12 mt-4">
<div className="flex">
<div className="w-1/2">
<GroupAssociationTable
title="Available environments"
emptyContentLabel="No environment available"
query={{
groupIds: [1],
}}
onClickRow={(env) => {
if (!value.includes(env.Id)) {
onChange([...value, env.Id], { type: 'add', value: env.Id });
}
}}
data-cy="edgeGroupCreate-availableEndpoints"
/>
</div>
<div className="w-1/2">
<GroupAssociationTable
title="Associated environments"
emptyContentLabel="No associated environment'"
query={{
endpointIds: value,
}}
onClickRow={(env) => {
if (value.includes(env.Id)) {
onChange(
value.filter((id) => id !== env.Id),
{ type: 'remove', value: env.Id }
);
}
}}
/>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,68 @@
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { Environment } from '@/react/portainer/environments/types';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
import { AutomationTestingProps } from '@/types';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables';
const columHelper = createColumnHelper<Environment>();
const columns = [
columHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
];
export function GroupAssociationTable({
title,
query,
emptyContentLabel,
onClickRow,
'data-cy': dataCy,
}: {
title: string;
query: EnvironmentsQueryParams;
emptyContentLabel: string;
onClickRow?: (env: Environment) => void;
} & AutomationTestingProps) {
const tableState = useTableStateWithoutStorage('Name');
const [page, setPage] = useState(1);
const environmentsQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page,
search: tableState.search,
sort: tableState.sortBy.id as 'Name',
order: tableState.sortBy.desc ? 'desc' : 'asc',
...query,
});
const { environments } = environmentsQuery;
return (
<Datatable<Environment>
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
onPageChange={setPage}
pageCount={Math.ceil(environmentsQuery.totalCount / tableState.pageSize)}
renderRow={(row) => (
<TableRow<Environment>
cells={row.getVisibleCells()}
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
/>
)}
emptyContentLabel={emptyContentLabel}
data-cy={dataCy}
disableSelect
totalCount={environmentsQuery.totalCount}
/>
);
}

View File

@ -8,9 +8,11 @@ import { queryKeys } from './queries/query-keys';
export function useGroups<T = EnvironmentGroup[]>({ export function useGroups<T = EnvironmentGroup[]>({
select, select,
}: { select?: (group: EnvironmentGroup[]) => T } = {}) { enabled = true,
}: { select?: (group: EnvironmentGroup[]) => T; enabled?: boolean } = {}) {
return useQuery(queryKeys.base(), getGroups, { return useQuery(queryKeys.base(), getGroups, {
select, select,
enabled,
}); });
} }

View File

@ -60,7 +60,10 @@ export async function getEnvironments(
query = {}, query = {},
}: GetEnvironmentsOptions = { query: {} } }: GetEnvironmentsOptions = { query: {} }
) { ) {
if (query.tagIds && query.tagIds.length === 0) { if (
(query.tagIds && query.tagIds.length === 0) ||
(query.endpointIds && query.endpointIds.length === 0)
) {
return { return {
totalCount: 0, totalCount: 0,
value: <Environment[]>[], value: <Environment[]>[],

View File

@ -12,10 +12,16 @@ import { queryKeys } from './query-keys';
export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
export const SortOptions = ['Name', 'Group', 'Status'] as const;
export type SortType = (typeof SortOptions)[number];
export function isSortType(value: string): value is SortType {
return SortOptions.includes(value as SortType);
}
export type Query = EnvironmentsQueryParams & { export type Query = EnvironmentsQueryParams & {
page?: number; page?: number;
pageLimit?: number; pageLimit?: number;
sort?: string; sort?: SortType;
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';
}; };