diff --git a/app/portainer/__module.js b/app/portainer/__module.js
index 8887f2545..b07c557e5 100644
--- a/app/portainer/__module.js
+++ b/app/portainer/__module.js
@@ -273,8 +273,7 @@ angular
url: '/groups',
views: {
'content@': {
- templateUrl: './views/groups/groups.html',
- controller: 'GroupsController',
+ component: 'environmentGroupsListView',
},
},
data: {
diff --git a/app/portainer/components/datatables/groups-datatable/groupsDatatable.html b/app/portainer/components/datatables/groups-datatable/groupsDatatable.html
deleted file mode 100644
index 841e58f12..000000000
--- a/app/portainer/components/datatables/groups-datatable/groupsDatatable.html
+++ /dev/null
@@ -1,114 +0,0 @@
-
diff --git a/app/portainer/components/datatables/groups-datatable/groupsDatatable.js b/app/portainer/components/datatables/groups-datatable/groupsDatatable.js
deleted file mode 100644
index f8e69b55a..000000000
--- a/app/portainer/components/datatables/groups-datatable/groupsDatatable.js
+++ /dev/null
@@ -1,13 +0,0 @@
-angular.module('portainer.app').component('groupsDatatable', {
- templateUrl: './groupsDatatable.html',
- controller: 'GenericDatatableController',
- bindings: {
- titleText: '@',
- titleIcon: '@',
- dataset: '<',
- tableKey: '@',
- orderBy: '@',
- reverseOrder: '<',
- removeAction: '<',
- },
-});
diff --git a/app/portainer/react/views/env-groups.ts b/app/portainer/react/views/env-groups.ts
new file mode 100644
index 000000000..fcdeccd52
--- /dev/null
+++ b/app/portainer/react/views/env-groups.ts
@@ -0,0 +1,14 @@
+import angular from 'angular';
+
+import { r2a } from '@/react-tools/react2angular';
+import { withCurrentUser } from '@/react-tools/withCurrentUser';
+import { withReactQuery } from '@/react-tools/withReactQuery';
+import { withUIRouter } from '@/react-tools/withUIRouter';
+import { ListView } from '@/react/portainer/environments/environment-groups/ListView';
+
+export const environmentGroupModule = angular
+ .module('portainer.app.react.views.environment-groups', [])
+ .component(
+ 'environmentGroupsListView',
+ r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), [])
+ ).name;
diff --git a/app/portainer/react/views/index.ts b/app/portainer/react/views/index.ts
index 1f134debf..6e067d8b4 100644
--- a/app/portainer/react/views/index.ts
+++ b/app/portainer/react/views/index.ts
@@ -16,12 +16,14 @@ import { CreateHelmRepositoriesView } from '@/react/portainer/account/helm-repos
import { wizardModule } from './wizard';
import { teamsModule } from './teams';
import { updateSchedulesModule } from './update-schedules';
+import { environmentGroupModule } from './env-groups';
export const viewsModule = angular
.module('portainer.app.react.views', [
wizardModule,
teamsModule,
updateSchedulesModule,
+ environmentGroupModule,
])
.component(
'homeView',
diff --git a/app/portainer/views/groups/groups.html b/app/portainer/views/groups/groups.html
deleted file mode 100644
index b60ccfe2a..000000000
--- a/app/portainer/views/groups/groups.html
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
diff --git a/app/portainer/views/groups/groupsController.js b/app/portainer/views/groups/groupsController.js
deleted file mode 100644
index 3b77985af..000000000
--- a/app/portainer/views/groups/groupsController.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash-es';
-import { confirmDestructive } from '@@/modals/confirm';
-import { buildConfirmButton } from '@@/modals/utils';
-
-angular.module('portainer.app').controller('GroupsController', GroupsController);
-
-function GroupsController($scope, $state, $async, GroupService, Notifications) {
- $scope.removeAction = removeAction;
-
- function removeAction(selectedItems) {
- return $async(removeActionAsync, selectedItems);
- }
-
- async function removeActionAsync(selectedItems) {
- const confirmed = await confirmDestructive({
- title: 'Are you sure?',
- message: 'Are you sure you want to remove the selected environment group(s)?',
- confirmButton: buildConfirmButton('Remove', 'danger'),
- });
-
- if (!confirmed) {
- return;
- }
-
- for (let group of selectedItems) {
- try {
- await GroupService.deleteGroup(group.Id);
-
- Notifications.success('Environment group successfully removed', group.Name);
- _.remove($scope.groups, group);
- } catch (err) {
- Notifications.error('Failure', err, 'Unable to remove group');
- }
- }
-
- $state.reload();
- }
-
- function initView() {
- GroupService.groups()
- .then(function success(data) {
- $scope.groups = data;
- })
- .catch(function error(err) {
- Notifications.error('Failure', err, 'Unable to retrieve environment groups');
- $scope.groups = [];
- });
- }
-
- initView();
-}
diff --git a/app/react/portainer/environments/environment-groups/ListView/.keep b/app/react/portainer/environments/environment-groups/ListView/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/EnvironmentGroupsDatatable.tsx b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/EnvironmentGroupsDatatable.tsx
new file mode 100644
index 000000000..66504966b
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/EnvironmentGroupsDatatable.tsx
@@ -0,0 +1,32 @@
+import { Dice4 } from 'lucide-react';
+
+import { Datatable } from '@@/datatables';
+import { createPersistedStore } from '@@/datatables/types';
+import { useTableState } from '@@/datatables/useTableState';
+
+import { useEnvironmentGroups } from '../../queries/useEnvironmentGroups';
+
+import { columns } from './columns';
+import { TableActions } from './TableActions';
+
+const tableKey = 'environment-groups';
+const store = createPersistedStore(tableKey);
+
+export function EnvironmentGroupsDatatable() {
+ const query = useEnvironmentGroups();
+ const tableState = useTableState(store, tableKey);
+
+ return (
+ (
+
+ )}
+ />
+ );
+}
diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/TableActions.tsx b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/TableActions.tsx
new file mode 100644
index 000000000..cce18dcb8
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/TableActions.tsx
@@ -0,0 +1,37 @@
+import { notifySuccess } from '@/portainer/services/notifications';
+
+import { DeleteButton } from '@@/buttons/DeleteButton';
+import { AddButton } from '@@/buttons';
+
+import { EnvironmentGroup } from '../../types';
+
+import { useDeleteEnvironmentGroupsMutation } from './useDeleteEnvironmentGroupsMutation';
+
+export function TableActions({
+ selectedItems,
+}: {
+ selectedItems: EnvironmentGroup[];
+}) {
+ const deleteMutation = useDeleteEnvironmentGroupsMutation();
+
+ return (
+ <>
+
+
+ Add group
+ >
+ );
+
+ function handleRemove() {
+ const ids = selectedItems.map((item) => item.Id);
+ deleteMutation.mutate(ids, {
+ onSuccess() {
+ notifySuccess('Success', 'Environment Group(s) removed');
+ },
+ });
+ }
+}
diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/columns.tsx b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/columns.tsx
new file mode 100644
index 000000000..44ff3e8ff
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/columns.tsx
@@ -0,0 +1,36 @@
+import { CellContext, createColumnHelper } from '@tanstack/react-table';
+import { Users } from 'lucide-react';
+
+import { buildNameColumn } from '@@/datatables/buildNameColumn';
+import { Button } from '@@/buttons';
+import { Link } from '@@/Link';
+
+import { EnvironmentGroup } from '../../types';
+
+const columnHelper = createColumnHelper();
+
+export const columns = [
+ buildNameColumn('Name', '.group'),
+ columnHelper.display({
+ header: 'Actions',
+ cell: ActionsCell,
+ }),
+];
+
+function ActionsCell({
+ row: { original: item },
+}: CellContext) {
+ return (
+
+ );
+}
diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/index.ts b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/index.ts
new file mode 100644
index 000000000..9d5fbf0df
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/index.ts
@@ -0,0 +1 @@
+export { EnvironmentGroupsDatatable } from './EnvironmentGroupsDatatable';
diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/useDeleteEnvironmentGroupsMutation.ts b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/useDeleteEnvironmentGroupsMutation.ts
new file mode 100644
index 000000000..4542f7c83
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/useDeleteEnvironmentGroupsMutation.ts
@@ -0,0 +1,37 @@
+import { useMutation, useQueryClient } from 'react-query';
+
+import { withError, withInvalidate } from '@/react-tools/react-query';
+import { promiseSequence } from '@/portainer/helpers/promise-utils';
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+
+import { EnvironmentGroup } from '../../types';
+import { buildUrl } from '../../queries/build-url';
+import { queryKeys } from '../../queries/query-keys';
+
+export function useDeleteEnvironmentGroupsMutation() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: deleteEnvironmentGroups,
+ ...withError('Failed to delete environment groups'),
+ ...withInvalidate(queryClient, [queryKeys.base()]),
+ });
+}
+
+async function deleteEnvironmentGroups(
+ environmentGroupIds: Array
+) {
+ return promiseSequence(
+ environmentGroupIds.map(
+ (environmentGroupId) => () => deleteEnvironmentGroup(environmentGroupId)
+ )
+ );
+}
+
+async function deleteEnvironmentGroup(id: EnvironmentGroup['Id']) {
+ try {
+ await axios.delete(buildUrl(id));
+ } catch (e) {
+ throw parseAxiosError(e, 'Unable to delete environment group');
+ }
+}
diff --git a/app/react/portainer/environments/environment-groups/ListView/ListView.tsx b/app/react/portainer/environments/environment-groups/ListView/ListView.tsx
new file mode 100644
index 000000000..9413cd238
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/ListView/ListView.tsx
@@ -0,0 +1,17 @@
+import { PageHeader } from '@@/PageHeader';
+
+import { EnvironmentGroupsDatatable } from './EnvironmentGroupsDatatable';
+
+export function ListView() {
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/app/react/portainer/environments/environment-groups/ListView/index.ts b/app/react/portainer/environments/environment-groups/ListView/index.ts
new file mode 100644
index 000000000..dd06dfd19
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/ListView/index.ts
@@ -0,0 +1 @@
+export { ListView } from './ListView';
diff --git a/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts b/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts
new file mode 100644
index 000000000..4fde407b4
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts
@@ -0,0 +1,24 @@
+import { useQuery } from 'react-query';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+
+import { EnvironmentGroup } from '../types';
+
+import { queryKeys } from './query-keys';
+import { buildUrl } from './build-url';
+
+export function useEnvironmentGroups() {
+ return useQuery({
+ queryKey: queryKeys.base(),
+ queryFn: () => getEnvironmentGroups(),
+ });
+}
+
+async function getEnvironmentGroups() {
+ try {
+ const { data } = await axios.get>(buildUrl());
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e, 'Unable to get access tokens');
+ }
+}