From e91b4f5c8372eeaa8dfc9a0e50886559bf115687 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 22 Jun 2023 21:11:10 +0700 Subject: [PATCH] refactor(groups): migrate groups selectors to react [EE-3842] (#8936) --- .../components/edge-job-form/edgeJobForm.html | 9 +- .../edge-job-form/edgeJobFormController.js | 16 +-- app/edge/components/group-form/groupForm.html | 31 +---- .../group-form/groupFormController.js | 85 ++++--------- app/edge/components/group-form/index.js | 1 - app/edge/react/components/index.ts | 20 +++ .../associatedEndpointsSelector.html | 50 -------- .../associatedEndpointsSelector.js | 16 --- .../associatedEndpointsSelectorController.js | 111 ----------------- .../components/forms/group-form/group-form.js | 6 +- .../forms/group-form/groupForm.html | 73 ++--------- .../forms/group-form/groupFormController.js | 89 ++----------- .../group-association-table.js | 70 ----------- .../groupAssociationTable.html | 85 ------------- app/portainer/models/group.js | 1 + app/portainer/react/components/index.ts | 18 ++- app/portainer/services/api/groupService.js | 4 +- app/portainer/tags/queries.ts | 4 +- .../groups/create/createGroupController.js | 16 +-- .../views/groups/create/creategroup.html | 5 +- app/portainer/views/groups/edit/group.html | 5 +- .../views/groups/edit/groupController.js | 46 ++++++- app/react/components/datatables/Datatable.tsx | 6 +- .../datatables/DatatableContent.tsx | 8 +- app/react/components/datatables/Table.tsx | 11 +- .../components/datatables/select-column.tsx | 5 + .../components/datatables/useTableState.ts | 22 +++- .../AssociatedEdgeEnvironmentsSelector.tsx | 63 ++++++++++ .../components/EdgeGroupAssociationTable.tsx | 117 ++++++++++++++++++ .../EnvironmentList/EnvironmentList.tsx | 7 +- .../EnvironmentListFilters.tsx | 10 +- .../EnvironmentList/SortbySelector.tsx | 10 +- .../ListView/EnvironmentsDatatable.tsx | 4 +- .../AssociatedEnvironmentsSelector.tsx | 61 +++++++++ .../components/GroupAssociationTable.tsx | 68 ++++++++++ .../environment-groups/queries.ts | 4 +- .../environments/environment.service/index.ts | 5 +- .../queries/useEnvironmentList.ts | 8 +- 38 files changed, 543 insertions(+), 627 deletions(-) delete mode 100644 app/portainer/components/associated-endpoints-selector/associatedEndpointsSelector.html delete mode 100644 app/portainer/components/associated-endpoints-selector/associatedEndpointsSelector.js delete mode 100644 app/portainer/components/associated-endpoints-selector/associatedEndpointsSelectorController.js delete mode 100644 app/portainer/components/group-association-table/group-association-table.js delete mode 100644 app/portainer/components/group-association-table/groupAssociationTable.html create mode 100644 app/react/edge/components/AssociatedEdgeEnvironmentsSelector.tsx create mode 100644 app/react/edge/components/EdgeGroupAssociationTable.tsx create mode 100644 app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector.tsx create mode 100644 app/react/portainer/environments/environment-groups/components/GroupAssociationTable.tsx diff --git a/app/edge/components/edge-job-form/edgeJobForm.html b/app/edge/components/edge-job-form/edgeJobForm.html index 01f5a2be2..6d686db67 100644 --- a/app/edge/components/edge-job-form/edgeJobForm.html +++ b/app/edge/components/edge-job-form/edgeJobForm.html @@ -173,14 +173,7 @@
Target environments
- +
Actions
diff --git a/app/edge/components/edge-job-form/edgeJobFormController.js b/app/edge/components/edge-job-form/edgeJobFormController.js index 4c0b22aff..0bc8acfaf 100644 --- a/app/edge/components/edge-job-form/edgeJobFormController.js +++ b/app/edge/components/edge-job-form/edgeJobFormController.js @@ -1,4 +1,3 @@ -import _ from 'lodash-es'; import moment from 'moment'; import { editor, upload } from '@@/BoxSelector/common-options/build-methods'; @@ -45,8 +44,7 @@ export class EdgeJobFormController { this.action = this.action.bind(this); this.editorUpdate = this.editorUpdate.bind(this); - this.associateEndpoint = this.associateEndpoint.bind(this); - this.dissociateEndpoint = this.dissociateEndpoint.bind(this); + this.onChangeEnvironments = this.onChangeEnvironments.bind(this); this.onChangeGroups = this.onChangeGroups.bind(this); this.onChange = this.onChange.bind(this); this.onCronMethodChange = this.onCronMethodChange.bind(this); @@ -115,14 +113,10 @@ export class EdgeJobFormController { this.isEditorDirty = true; } - associateEndpoint(endpoint) { - if (!_.includes(this.model.Endpoints, endpoint.Id)) { - this.model.Endpoints = [...this.model.Endpoints, endpoint.Id]; - } - } - - dissociateEndpoint(endpoint) { - this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id); + onChangeEnvironments(value) { + return this.$scope.$evalAsync(() => { + this.model.Endpoints = value; + }); } $onInit() { diff --git a/app/edge/components/group-form/groupForm.html b/app/edge/components/group-form/groupForm.html index 6d37e02cc..315ce5c11 100644 --- a/app/edge/components/group-form/groupForm.html +++ b/app/edge/components/group-form/groupForm.html @@ -33,14 +33,7 @@
Associated environments
- +
@@ -59,23 +52,11 @@ -
- -
+
diff --git a/app/edge/components/group-form/groupFormController.js b/app/edge/components/group-form/groupFormController.js index 7086570a7..f668c5877 100644 --- a/app/edge/components/group-form/groupFormController.js +++ b/app/edge/components/group-form/groupFormController.js @@ -1,9 +1,5 @@ -import _ from 'lodash-es'; import { confirmDestructive } from '@@/modals/confirm'; 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 { tagOptions } from '@/react/edge/edge-groups/CreateView/tag-options'; import { groupTypeOptions } from '@/react/edge/edge-groups/CreateView/group-type-options'; @@ -17,22 +13,13 @@ export class EdgeGroupFormController { this.groupTypeOptions = groupTypeOptions; this.tagOptions = tagOptions; - this.endpoints = { - state: { - limit: '10', - filter: '', - pageNumber: 1, - totalCount: 0, - }, - value: null, + this.dynamicQuery = { + types: EdgeTypes, + tagIds: [], + tagsPartialMatch: false, }; - this.tags = []; - - 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.onChangeEnvironments = this.onChangeEnvironments.bind(this); this.onChangeTags = this.onChangeTags.bind(this); this.onChangeDynamic = this.onChangeDynamic.bind(this); this.onChangeModel = this.onChangeModel.bind(this); @@ -43,7 +30,11 @@ export class EdgeGroupFormController { () => this.model, () => { if (this.model.Dynamic) { - this.getDynamicEndpoints(); + this.dynamicQuery = { + types: EdgeTypes, + tagIds: this.model.TagIds, + tagsPartialMatch: this.model.PartialMatch, + }; } }, true @@ -71,59 +62,25 @@ export class EdgeGroupFormController { this.onChangeModel({ TagIds: value }); } - associateEndpoint(endpoint) { - if (!_.includes(this.model.Endpoints, endpoint.Id)) { - this.model.Endpoints = [...this.model.Endpoints, endpoint.Id]; - } - } - - dissociateEndpoint(endpoint) { + onChangeEnvironments(value, meta) { return this.$async(async () => { - const confirmed = await confirmDestructive({ - title: 'Confirm action', - message: 'Removing the environment from this group will remove its corresponding edge stacks', - confirmButton: buildConfirmButton('Confirm'), - }); + if (meta.type === 'remove' && this.pageType === 'edit') { + const confirmed = await confirmDestructive({ + title: 'Confirm action', + message: 'Removing the environment from this group will remove its corresponding edge stacks', + confirmButton: buildConfirmButton('Confirm'), + }); - if (!confirmed) { - return; + if (!confirmed) { + return; + } } - this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id); - }); - } - - 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'); - } + this.onChangeModel({ Endpoints: value }); }); } handleSubmit() { this.formAction(this.model); } - - $onInit() { - this.getTags(); - } } diff --git a/app/edge/components/group-form/index.js b/app/edge/components/group-form/index.js index 2da9bfd7d..23ed1bbd0 100644 --- a/app/edge/components/group-form/index.js +++ b/app/edge/components/group-form/index.js @@ -7,7 +7,6 @@ angular.module('portainer.edge').component('edgeGroupForm', { controller: EdgeGroupFormController, bindings: { model: '<', - groups: '<', formActionLabel: '@', formAction: '<', actionInProgress: '<', diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts index 673a0daca..457d5d178 100644 --- a/app/edge/react/components/index.ts +++ b/app/edge/react/components/index.ts @@ -10,6 +10,8 @@ import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/compon import { EditEdgeStackForm } from '@/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm'; import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withUIRouter } from '@/react-tools/withUIRouter'; +import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable'; +import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; export const componentsModule = angular .module('portainer.edge.react.components', []) @@ -76,4 +78,22 @@ export const componentsModule = angular 'onSubmit', 'allowKubeToSelectCompose', ]) + ) + .component( + 'edgeGroupAssociationTable', + r2a(withReactQuery(EdgeGroupAssociationTable), [ + 'emptyContentLabel', + 'onClickRow', + 'query', + 'title', + 'data-cy', + 'hideEnvironmentIds', + ]) + ) + .component( + 'associatedEdgeEnvironmentsSelector', + r2a(withReactQuery(AssociatedEdgeEnvironmentsSelector), [ + 'onChange', + 'value', + ]) ).name; diff --git a/app/portainer/components/associated-endpoints-selector/associatedEndpointsSelector.html b/app/portainer/components/associated-endpoints-selector/associatedEndpointsSelector.html deleted file mode 100644 index 95292d71f..000000000 --- a/app/portainer/components/associated-endpoints-selector/associatedEndpointsSelector.html +++ /dev/null @@ -1,50 +0,0 @@ -
- 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. -
-
-
- -
- -
- - -
- -
- -
-
diff --git a/app/portainer/components/associated-endpoints-selector/associatedEndpointsSelector.js b/app/portainer/components/associated-endpoints-selector/associatedEndpointsSelector.js deleted file mode 100644 index 2e0cce768..000000000 --- a/app/portainer/components/associated-endpoints-selector/associatedEndpointsSelector.js +++ /dev/null @@ -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: '<', - }, -}); diff --git a/app/portainer/components/associated-endpoints-selector/associatedEndpointsSelectorController.js b/app/portainer/components/associated-endpoints-selector/associatedEndpointsSelectorController.js deleted file mode 100644 index d44f2b0af..000000000 --- a/app/portainer/components/associated-endpoints-selector/associatedEndpointsSelectorController.js +++ /dev/null @@ -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; diff --git a/app/portainer/components/forms/group-form/group-form.js b/app/portainer/components/forms/group-form/group-form.js index e5f815655..bd0faf7f3 100644 --- a/app/portainer/components/forms/group-form/group-form.js +++ b/app/portainer/components/forms/group-form/group-form.js @@ -6,14 +6,12 @@ angular.module('portainer.app').component('groupForm', { controller: GroupFormController, bindings: { loaded: '<', - pageType: '@', model: '=', - availableEndpoints: '=', associatedEndpoints: '=', - addLabelAction: '<', - removeLabelAction: '<', formAction: '<', formActionLabel: '@', actionInProgress: '<', + + onChangeEnvironments: '<', }, }); diff --git a/app/portainer/components/forms/group-form/groupForm.html b/app/portainer/components/forms/group-form/groupForm.html index f9351e1c6..76f935329 100644 --- a/app/portainer/components/forms/group-form/groupForm.html +++ b/app/portainer/components/forms/group-form/groupForm.html @@ -1,4 +1,4 @@ -
+
@@ -29,68 +29,19 @@ - -
-
Associated environments
-
-
- 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. -
-
- -
-
- -
- - -
- -
-
- -
-
+
+
-
-
- -
+ +
+
- +
Actions
diff --git a/app/portainer/components/forms/group-form/groupFormController.js b/app/portainer/components/forms/group-form/groupFormController.js index 22dce5741..b7675ac6c 100644 --- a/app/portainer/components/forms/group-form/groupFormController.js +++ b/app/portainer/components/forms/group-form/groupFormController.js @@ -1,7 +1,4 @@ -import _ from 'lodash-es'; import angular from 'angular'; -import { endpointsByGroup } from '@/react/portainer/environments/environment.service'; -import { notifyError } from '@/portainer/services/notifications'; class GroupFormController { /* @ngInject */ @@ -12,9 +9,14 @@ class GroupFormController { this.Notifications = Notifications; this.Authentication = Authentication; - this.associateEndpoint = this.associateEndpoint.bind(this); - this.dissociateEndpoint = this.dissociateEndpoint.bind(this); - this.getPaginatedEndpointsByGroup = this.getPaginatedEndpointsByGroup.bind(this); + this.state = { + allowCreateTag: this.Authentication.isAdmin(), + }; + + this.unassociatedQuery = { + groupIds: [1], + }; + this.onChangeTags = this.onChangeTags.bind(this); } @@ -23,81 +25,6 @@ class GroupFormController { 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); diff --git a/app/portainer/components/group-association-table/group-association-table.js b/app/portainer/components/group-association-table/group-association-table.js deleted file mode 100644 index 80bbf6a2d..000000000 --- a/app/portainer/components/group-association-table/group-association-table.js +++ /dev/null @@ -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: '@', - }, -}); diff --git a/app/portainer/components/group-association-table/groupAssociationTable.html b/app/portainer/components/group-association-table/groupAssociationTable.html deleted file mode 100644 index 6386fac31..000000000 --- a/app/portainer/components/group-association-table/groupAssociationTable.html +++ /dev/null @@ -1,85 +0,0 @@ -
-
-
{{ $ctrl.title }}
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
Name Group Tags
- {{ item.Name | truncate : 64 }} - - {{ $ctrl.groupIdToGroupName(item.GroupId) | truncate : 64 }} - - {{ $ctrl.tagIdsToTagNames(item.TagIds) | arraytostr | truncate : 64 }} -
- {{ item.Name | truncate : 64 }} - - {{ $ctrl.groupIdToGroupName(item.GroupId) | truncate : 64 }} - - {{ $ctrl.tagIdsToTagNames(item.TagIds) | truncate : 64 }} -
Loading...
{{ $ctrl.emptyDatasetMessage }}
- -
diff --git a/app/portainer/models/group.js b/app/portainer/models/group.js index ca806f582..91eaf46de 100644 --- a/app/portainer/models/group.js +++ b/app/portainer/models/group.js @@ -2,6 +2,7 @@ export function EndpointGroupDefaultModel() { this.Name = ''; this.Description = ''; this.TagIds = []; + this.AssociatedEndpoints = []; } export function EndpointGroupModel(data) { diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 738232908..1ed5e9e86 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -6,6 +6,8 @@ import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsBeTeaser'; 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 { EnvironmentVariablesFieldset, @@ -204,7 +206,21 @@ export const ngModule = angular '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; diff --git a/app/portainer/services/api/groupService.js b/app/portainer/services/api/groupService.js index bb613babf..acc823fc7 100644 --- a/app/portainer/services/api/groupService.js +++ b/app/portainer/services/api/groupService.js @@ -40,8 +40,8 @@ angular.module('portainer.app').factory('GroupService', [ return EndpointGroups.updateAccess({ id: groupId }, { UserAccessPolicies: userAccessPolicies, TeamAccessPolicies: teamAccessPolicies }).$promise; }; - service.addEndpoint = function (groupId, endpoint) { - return EndpointGroups.addEndpoint({ id: groupId, action: 'endpoints/' + endpoint.Id }, endpoint).$promise; + service.addEndpoint = function (groupId, endpointId) { + return EndpointGroups.addEndpoint({ id: groupId, action: 'endpoints/' + endpointId }).$promise; }; service.removeEndpoint = function (groupId, endpointId) { diff --git a/app/portainer/tags/queries.ts b/app/portainer/tags/queries.ts index 131da6b51..b71411e91 100644 --- a/app/portainer/tags/queries.ts +++ b/app/portainer/tags/queries.ts @@ -17,10 +17,12 @@ export const tagKeys = { export function useTags({ select, -}: { select?: (tags: Tag[]) => T } = {}) { + enabled = true, +}: { select?: (tags: Tag[]) => T; enabled?: boolean } = {}) { return useQuery(tagKeys.all, () => getTags(), { staleTime: 50, select, + enabled, ...withError('Failed to retrieve tags'), }); } diff --git a/app/portainer/views/groups/create/createGroupController.js b/app/portainer/views/groups/create/createGroupController.js index 3299a6c52..98fd3c466 100644 --- a/app/portainer/views/groups/create/createGroupController.js +++ b/app/portainer/views/groups/create/createGroupController.js @@ -5,17 +5,13 @@ angular.module('portainer.app').controller('CreateGroupController', function Cre actionInProgress: false, }; + $scope.onChangeEnvironments = onChangeEnvironments; + $scope.create = function () { 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; - GroupService.createGroup(model, associatedEndpoints) + GroupService.createGroup(model, $scope.associatedEndpoints) .then(function success() { Notifications.success('Success', 'Group successfully created'); $state.go('portainer.groups', {}, { reload: true }); @@ -34,5 +30,11 @@ angular.module('portainer.app').controller('CreateGroupController', function Cre $scope.loaded = true; } + function onChangeEnvironments(value) { + return $scope.$evalAsync(() => { + $scope.associatedEndpoints = value; + }); + } + initView(); }); diff --git a/app/portainer/views/groups/create/creategroup.html b/app/portainer/views/groups/create/creategroup.html index 76deb7d91..35770440f 100644 --- a/app/portainer/views/groups/create/creategroup.html +++ b/app/portainer/views/groups/create/creategroup.html @@ -6,15 +6,12 @@ diff --git a/app/portainer/views/groups/edit/group.html b/app/portainer/views/groups/edit/group.html index 8b5a888b8..f88b92d7b 100644 --- a/app/portainer/views/groups/edit/group.html +++ b/app/portainer/views/groups/edit/group.html @@ -6,15 +6,12 @@ diff --git a/app/portainer/views/groups/edit/groupController.js b/app/portainer/views/groups/edit/groupController.js index c67b4501f..d683e7aae 100644 --- a/app/portainer/views/groups/edit/groupController.js +++ b/app/portainer/views/groups/edit/groupController.js @@ -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 = { actionInProgress: false, }; + $scope.onChangeEnvironments = onChangeEnvironments; + $scope.associatedEndpoints = []; $scope.update = function () { 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() { var groupId = $transition$.params().id; $q.all({ group: GroupService.group(groupId), + endpoints: getEnvironments({ query: { groupIds: [groupId] } }), }) .then(function success(data) { $scope.group = data.group; + $scope.associatedEndpoints = data.endpoints.value.map((endpoint) => endpoint.Id); $scope.loaded = true; }) .catch(function error(err) { diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx index 80fa33207..92cba539b 100644 --- a/app/react/components/datatables/Datatable.tsx +++ b/app/react/components/datatables/Datatable.tsx @@ -17,6 +17,8 @@ import { ReactNode, useMemo } from 'react'; import clsx from 'clsx'; import _ from 'lodash'; +import { AutomationTestingProps } from '@/types'; + import { IconProps } from '@@/Icon'; import { DatatableHeader } from './DatatableHeader'; @@ -32,7 +34,7 @@ import { TableRow } from './TableRow'; export interface Props< D extends Record, TSettings extends BasicTableSettings = BasicTableSettings -> { +> extends AutomationTestingProps { dataset: D[]; columns: TableOptions['columns']; renderTableSettings?(instance: TableInstance): ReactNode; @@ -82,6 +84,7 @@ export function Datatable>({ highlightedItemId, noWidget, getRowCanExpand, + 'data-cy': dataCy, }: Props) { const isServerSidePagination = typeof pageCount !== 'undefined'; const enableRowSelection = getIsSelectionEnabled( @@ -156,6 +159,7 @@ export function Datatable>({ emptyContentLabel={emptyContentLabel} isLoading={isLoading} onSortChange={handleSortChange} + data-cy={dataCy} /> > { +interface Props> + extends AutomationTestingProps { tableInstance: TableInstance; renderRow(row: Row): React.ReactNode; onSortChange?(colId: string, desc: boolean): void; @@ -16,12 +19,13 @@ export function DatatableContent>({ onSortChange, isLoading, emptyContentLabel, + 'data-cy': dataCy, }: Props) { const headerGroups = tableInstance.getHeaderGroups(); const pageRowModel = tableInstance.getPaginationRowModel(); return ( - +
{headerGroups.map((headerGroup) => ( diff --git a/app/react/components/datatables/Table.tsx b/app/react/components/datatables/Table.tsx index 4429f62b3..1cde85c41 100644 --- a/app/react/components/datatables/Table.tsx +++ b/app/react/components/datatables/Table.tsx @@ -1,6 +1,8 @@ import clsx from 'clsx'; import { PropsWithChildren } from 'react'; +import { AutomationTestingProps } from '@/types'; + import { TableContainer } from './TableContainer'; import { TableActions } from './TableActions'; import { TableFooter } from './TableFooter'; @@ -12,14 +14,19 @@ import { TableHeaderCell } from './TableHeaderCell'; import { TableHeaderRow } from './TableHeaderRow'; import { TableRow } from './TableRow'; -interface Props { +interface Props extends AutomationTestingProps { className?: string; } -function MainComponent({ children, className }: PropsWithChildren) { +function MainComponent({ + children, + className, + 'data-cy': dataCy, +}: PropsWithChildren) { return (
(): ColumnDef { indeterminate={table.getIsSomeRowsSelected()} onChange={table.getToggleAllRowsSelectedHandler()} disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())} + onClick={(e) => { + e.stopPropagation(); + }} /> ), cell: ({ row, table }) => ( @@ -24,6 +27,8 @@ export function createSelectColumn(): ColumnDef { onChange={row.getToggleSelectedHandler()} disabled={!row.getCanSelect()} onClick={(e) => { + e.stopPropagation(); + if (e.shiftKey) { const { rows, rowsById } = table.getRowModel(); const rowsToToggle = getRowRange(rows, row.id, lastSelectedId); diff --git a/app/react/components/datatables/useTableState.ts b/app/react/components/datatables/useTableState.ts index ffa903280..4a530f0fc 100644 --- a/app/react/components/datatables/useTableState.ts +++ b/app/react/components/datatables/useTableState.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useStore } from 'zustand'; import { useSearchBarState } from './SearchBar'; @@ -27,3 +27,23 @@ export function useTableState< [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, + }; +} diff --git a/app/react/edge/components/AssociatedEdgeEnvironmentsSelector.tsx b/app/react/edge/components/AssociatedEdgeEnvironmentsSelector.tsx new file mode 100644 index 000000000..7de8938b6 --- /dev/null +++ b/app/react/edge/components/AssociatedEdgeEnvironmentsSelector.tsx @@ -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 ( + <> +
+ 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. +
+ +
+
+
+ { + if (!value.includes(env.Id)) { + onChange([...value, env.Id], { type: 'add', value: env.Id }); + } + }} + data-cy="edgeGroupCreate-availableEndpoints" + hideEnvironmentIds={value} + /> +
+
+ { + if (value.includes(env.Id)) { + onChange( + value.filter((id) => id !== env.Id), + { type: 'remove', value: env.Id } + ); + } + }} + /> +
+
+
+ + ); +} diff --git a/app/react/edge/components/EdgeGroupAssociationTable.tsx b/app/react/edge/components/EdgeGroupAssociationTable.tsx new file mode 100644 index 000000000..6b3dc5b49 --- /dev/null +++ b/app/react/edge/components/EdgeGroupAssociationTable.tsx @@ -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(); + +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 = 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 ( + + title={title} + columns={columns} + settingsManager={tableState} + dataset={environments} + onPageChange={setPage} + pageCount={Math.ceil(totalCount / tableState.pageSize)} + renderRow={(row) => ( + + cells={row.getVisibleCells()} + onClick={() => onClickRow(row.original)} + /> + )} + emptyContentLabel={emptyContentLabel} + data-cy={dataCy} + disableSelect + totalCount={totalCount} + /> + ); +} diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx index f7b33e17a..31d1df9ee 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx @@ -14,6 +14,7 @@ import { import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; import { refetchIfAnyOffline, + SortType, useEnvironmentList, } from '@/react/portainer/environments/queries/useEnvironmentList'; import { useGroups } from '@/react/portainer/environments/environment-groups/queries'; @@ -68,7 +69,9 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) { 'group', [] ); - const [sortByFilter, setSortByFilter] = useSearchBarState('sortBy'); + const [sortByFilter, setSortByFilter] = useHomePageFilter< + SortType | undefined + >('sortBy', 'Name'); const [sortByDescending, setSortByDescending] = useHomePageFilter( 'sortOrder', false @@ -342,7 +345,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) { setConnectionTypes([]); } - function sortOnchange(value: string) { + function sortOnchange(value?: 'Name' | 'Group' | 'Status') { setSortByFilter(value); setSortByButton(!!value); } diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx index 8123c6f55..01af915c8 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx @@ -6,6 +6,10 @@ import { useAgentVersionsList } from '../../environments/queries/useAgentVersion import { EnvironmentStatus, PlatformType } from '../../environments/types'; import { isBE } from '../../feature-flags/feature-flags.service'; import { useGroups } from '../../environments/environment-groups/queries'; +import { + SortOptions, + SortType, +} from '../../environments/queries/useEnvironmentList'; import { HomepageFilter } from './HomepageFilter'; import { SortbySelector } from './SortbySelector'; @@ -17,7 +21,7 @@ const status = [ { value: EnvironmentStatus.Down, label: 'Down' }, ]; -const sortByOptions = ['Name', 'Group', 'Status'].map((v) => ({ +const sortByOptions = SortOptions.map((v) => ({ value: v, label: v, })); @@ -60,8 +64,8 @@ export function EnvironmentListFilters({ setAgentVersions: (value: string[]) => void; agentVersions: string[]; - sortByState: string; - sortOnChange: (value: string) => void; + sortByState?: SortType; + sortOnChange: (value: SortType) => void; sortOnDescending: () => void; sortByDescending: boolean; diff --git a/app/react/portainer/HomeView/EnvironmentList/SortbySelector.tsx b/app/react/portainer/HomeView/EnvironmentList/SortbySelector.tsx index 368baeb44..ea8130925 100644 --- a/app/react/portainer/HomeView/EnvironmentList/SortbySelector.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/SortbySelector.tsx @@ -3,16 +3,18 @@ import clsx from 'clsx'; import { Option, PortainerSelect } from '@@/form-components/PortainerSelect'; import { TableHeaderSortIcons } from '@@/datatables/TableHeaderSortIcons'; +import { SortType } from '../../environments/queries/useEnvironmentList'; + import styles from './SortbySelector.module.css'; interface Props { - filterOptions: Option[]; - onChange: (value: string) => void; + filterOptions: Option[]; + onChange: (value: SortType) => void; onDescending: () => void; placeHolder: string; sortByDescending: boolean; sortByButton: boolean; - value: string; + value?: SortType; } export function SortbySelector({ @@ -30,7 +32,7 @@ export function SortbySelector({ onChange(option || '')} + onChange={(option: SortType) => onChange(option || '')} isClearable value={value} /> diff --git a/app/react/portainer/environments/ListView/EnvironmentsDatatable.tsx b/app/react/portainer/environments/ListView/EnvironmentsDatatable.tsx index 8fa46699d..dc5d3bdb7 100644 --- a/app/react/portainer/environments/ListView/EnvironmentsDatatable.tsx +++ b/app/react/portainer/environments/ListView/EnvironmentsDatatable.tsx @@ -11,7 +11,7 @@ import { Link } from '@@/Link'; import { useTableState } from '@@/datatables/useTableState'; import { isBE } from '../../feature-flags/feature-flags.service'; -import { refetchIfAnyOffline } from '../queries/useEnvironmentList'; +import { isSortType, refetchIfAnyOffline } from '../queries/useEnvironmentList'; import { columns } from './columns'; import { EnvironmentListItem } from './types'; @@ -36,7 +36,7 @@ export function EnvironmentsDatatable({ excludeSnapshots: true, page: page + 1, pageLimit: tableState.pageSize, - sort: tableState.sortBy.id, + sort: isSortType(tableState.sortBy.id) ? tableState.sortBy.id : undefined, order: tableState.sortBy.desc ? 'desc' : 'asc', }, { enabled: groupsQuery.isSuccess, refetchInterval: refetchIfAnyOffline } diff --git a/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector.tsx b/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector.tsx new file mode 100644 index 000000000..df5c737ac --- /dev/null +++ b/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector.tsx @@ -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 ( + <> +
+ 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. +
+ +
+
+
+ { + if (!value.includes(env.Id)) { + onChange([...value, env.Id], { type: 'add', value: env.Id }); + } + }} + data-cy="edgeGroupCreate-availableEndpoints" + /> +
+
+ { + if (value.includes(env.Id)) { + onChange( + value.filter((id) => id !== env.Id), + { type: 'remove', value: env.Id } + ); + } + }} + /> +
+
+
+ + ); +} diff --git a/app/react/portainer/environments/environment-groups/components/GroupAssociationTable.tsx b/app/react/portainer/environments/environment-groups/components/GroupAssociationTable.tsx new file mode 100644 index 000000000..0ec33046a --- /dev/null +++ b/app/react/portainer/environments/environment-groups/components/GroupAssociationTable.tsx @@ -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(); + +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 ( + + title={title} + columns={columns} + settingsManager={tableState} + dataset={environments} + onPageChange={setPage} + pageCount={Math.ceil(environmentsQuery.totalCount / tableState.pageSize)} + renderRow={(row) => ( + + cells={row.getVisibleCells()} + onClick={onClickRow ? () => onClickRow(row.original) : undefined} + /> + )} + emptyContentLabel={emptyContentLabel} + data-cy={dataCy} + disableSelect + totalCount={environmentsQuery.totalCount} + /> + ); +} diff --git a/app/react/portainer/environments/environment-groups/queries.ts b/app/react/portainer/environments/environment-groups/queries.ts index 8de2fae3d..3e5c8399a 100644 --- a/app/react/portainer/environments/environment-groups/queries.ts +++ b/app/react/portainer/environments/environment-groups/queries.ts @@ -8,9 +8,11 @@ import { queryKeys } from './queries/query-keys'; export function useGroups({ select, -}: { select?: (group: EnvironmentGroup[]) => T } = {}) { + enabled = true, +}: { select?: (group: EnvironmentGroup[]) => T; enabled?: boolean } = {}) { return useQuery(queryKeys.base(), getGroups, { select, + enabled, }); } diff --git a/app/react/portainer/environments/environment.service/index.ts b/app/react/portainer/environments/environment.service/index.ts index 7a3bd448b..bc20b22c2 100644 --- a/app/react/portainer/environments/environment.service/index.ts +++ b/app/react/portainer/environments/environment.service/index.ts @@ -60,7 +60,10 @@ export async function getEnvironments( query = {}, }: GetEnvironmentsOptions = { query: {} } ) { - if (query.tagIds && query.tagIds.length === 0) { + if ( + (query.tagIds && query.tagIds.length === 0) || + (query.endpointIds && query.endpointIds.length === 0) + ) { return { totalCount: 0, value: [], diff --git a/app/react/portainer/environments/queries/useEnvironmentList.ts b/app/react/portainer/environments/queries/useEnvironmentList.ts index 4b00f0997..ba05e9e23 100644 --- a/app/react/portainer/environments/queries/useEnvironmentList.ts +++ b/app/react/portainer/environments/queries/useEnvironmentList.ts @@ -12,10 +12,16 @@ import { queryKeys } from './query-keys'; 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 & { page?: number; pageLimit?: number; - sort?: string; + sort?: SortType; order?: 'asc' | 'desc'; };