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 @@
-
-
-
-
-
- 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';
};