From 9c70a43ac3af5891554c2232b3827be9f4b4696b Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 2 Jun 2024 15:43:37 +0300 Subject: [PATCH] refactor(edge/groups): migrate view to react [EE-2219] (#11758) --- app/edge/__module.js | 4 +- app/edge/components/group-form/groupForm.html | 79 ---------------- .../group-form/groupFormController.js | 86 ------------------ app/edge/components/group-form/index.js | 16 ---- app/edge/react/components/index.ts | 19 ++-- app/edge/react/views/groups.ts | 20 +++++ app/edge/react/views/index.ts | 7 +- app/edge/rest/edge-groups.js | 4 - app/edge/services/edge-group.js | 16 ---- .../createEdgeGroupView.html | 18 ---- .../createEdgeGroupViewController.js | 47 ---------- .../edge-groups/createEdgeGroupView/index.js | 8 -- .../editEdgeGroupView/editEdgeGroupView.html | 19 ---- .../editEdgeGroupViewController.js | 44 --------- .../edge-groups/editEdgeGroupView/index.js | 8 -- app/portainer/react/components/index.ts | 1 + app/react/components/Redirect.tsx | 10 +++ .../components/TagSelector/TagSelector.tsx | 39 +++++--- .../form-components/FormActions.tsx | 9 ++ .../AssociatedEdgeEnvironmentsSelector.tsx | 13 +++ .../components/EdgeGroupAssociationTable.tsx | 12 +-- .../Selectors/EdgeGroupSelector.tsx | 4 +- app/react/edge/edge-groups/.keep | 0 app/react/edge/edge-groups/CreateView/.keep | 0 .../edge-groups/CreateView/CreateView.tsx | 55 ++++++++++++ app/react/edge/edge-groups/ItemView/.keep | 0 .../edge/edge-groups/ItemView/ItemView.tsx | 72 +++++++++++++++ .../EdgeGroupForm/DynamicGroupFieldset.tsx | 45 ++++++++++ .../EdgeGroupForm/EdgeGroupForm.tsx | 90 +++++++++++++++++++ .../components/EdgeGroupForm/NameField.tsx | 42 +++++++++ .../EdgeGroupForm/StaticGroupFieldset.tsx | 40 +++++++++ .../EdgeGroupForm}/group-type-options.tsx | 0 .../EdgeGroupForm}/tag-options.tsx | 0 .../components/EdgeGroupForm/types.tsx | 10 +++ .../EdgeGroupForm/useValidation.tsx | 27 ++++++ .../edge/edge-groups/queries/query-keys.ts | 1 + .../queries/useCreateEdgeGroupMutation.ts | 2 +- .../edge/edge-groups/queries/useEdgeGroup.ts | 47 ++++++++++ .../queries/useUpdateEdgeGroupMutation.ts | 51 +++++++++++ 39 files changed, 579 insertions(+), 386 deletions(-) delete mode 100644 app/edge/components/group-form/groupForm.html delete mode 100644 app/edge/components/group-form/groupFormController.js delete mode 100644 app/edge/components/group-form/index.js create mode 100644 app/edge/react/views/groups.ts delete mode 100644 app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupView.html delete mode 100644 app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js delete mode 100644 app/edge/views/edge-groups/createEdgeGroupView/index.js delete mode 100644 app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupView.html delete mode 100644 app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js delete mode 100644 app/edge/views/edge-groups/editEdgeGroupView/index.js create mode 100644 app/react/components/Redirect.tsx delete mode 100644 app/react/edge/edge-groups/.keep delete mode 100644 app/react/edge/edge-groups/CreateView/.keep create mode 100644 app/react/edge/edge-groups/CreateView/CreateView.tsx delete mode 100644 app/react/edge/edge-groups/ItemView/.keep create mode 100644 app/react/edge/edge-groups/ItemView/ItemView.tsx create mode 100644 app/react/edge/edge-groups/components/EdgeGroupForm/DynamicGroupFieldset.tsx create mode 100644 app/react/edge/edge-groups/components/EdgeGroupForm/EdgeGroupForm.tsx create mode 100644 app/react/edge/edge-groups/components/EdgeGroupForm/NameField.tsx create mode 100644 app/react/edge/edge-groups/components/EdgeGroupForm/StaticGroupFieldset.tsx rename app/react/edge/edge-groups/{CreateView => components/EdgeGroupForm}/group-type-options.tsx (100%) rename app/react/edge/edge-groups/{CreateView => components/EdgeGroupForm}/tag-options.tsx (100%) create mode 100644 app/react/edge/edge-groups/components/EdgeGroupForm/types.tsx create mode 100644 app/react/edge/edge-groups/components/EdgeGroupForm/useValidation.tsx create mode 100644 app/react/edge/edge-groups/queries/useEdgeGroup.ts create mode 100644 app/react/edge/edge-groups/queries/useUpdateEdgeGroupMutation.ts diff --git a/app/edge/__module.js b/app/edge/__module.js index be1da9d61..d507ed391 100644 --- a/app/edge/__module.js +++ b/app/edge/__module.js @@ -35,7 +35,7 @@ angular url: '/new', views: { 'content@': { - component: 'createEdgeGroupView', + component: 'edgeGroupsCreateView', }, }, }; @@ -45,7 +45,7 @@ angular url: '/:groupId', views: { 'content@': { - component: 'editEdgeGroupView', + component: 'edgeGroupsItemView', }, }, }; diff --git a/app/edge/components/group-form/groupForm.html b/app/edge/components/group-form/groupForm.html deleted file mode 100644 index 9bd0c0ffd..000000000 --- a/app/edge/components/group-form/groupForm.html +++ /dev/null @@ -1,79 +0,0 @@ -
-
- -
- -
-
-
-

This field is required.

-
-
-
-
-
- -
Group type
- - - - -
-
- -
Associated environments
-
- -
-
-
-
- No Edge environments are available. Head over to the Environments view to add environments. -
-
-
- - - -
-
Tags
- - - - - - -
- - - -
Actions
-
-
- -
-
-
diff --git a/app/edge/components/group-form/groupFormController.js b/app/edge/components/group-form/groupFormController.js deleted file mode 100644 index f668c5877..000000000 --- a/app/edge/components/group-form/groupFormController.js +++ /dev/null @@ -1,86 +0,0 @@ -import { confirmDestructive } from '@@/modals/confirm'; -import { EdgeTypes } from '@/react/portainer/environments/types'; -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'; - -export class EdgeGroupFormController { - /* @ngInject */ - constructor($async, $scope) { - this.$async = $async; - this.$scope = $scope; - - this.groupTypeOptions = groupTypeOptions; - this.tagOptions = tagOptions; - - this.dynamicQuery = { - types: EdgeTypes, - tagIds: [], - tagsPartialMatch: false, - }; - - this.onChangeEnvironments = this.onChangeEnvironments.bind(this); - this.onChangeTags = this.onChangeTags.bind(this); - this.onChangeDynamic = this.onChangeDynamic.bind(this); - this.onChangeModel = this.onChangeModel.bind(this); - this.onChangePartialMatch = this.onChangePartialMatch.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - - $scope.$watch( - () => this.model, - () => { - if (this.model.Dynamic) { - this.dynamicQuery = { - types: EdgeTypes, - tagIds: this.model.TagIds, - tagsPartialMatch: this.model.PartialMatch, - }; - } - }, - true - ); - } - - onChangeModel(model) { - return this.$scope.$evalAsync(() => { - this.model = { - ...this.model, - ...model, - }; - }); - } - - onChangePartialMatch(value) { - return this.onChangeModel({ PartialMatch: value }); - } - - onChangeDynamic(value) { - this.onChangeModel({ Dynamic: value }); - } - - onChangeTags(value) { - this.onChangeModel({ TagIds: value }); - } - - onChangeEnvironments(value, meta) { - return this.$async(async () => { - 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; - } - } - - this.onChangeModel({ Endpoints: value }); - }); - } - - handleSubmit() { - this.formAction(this.model); - } -} diff --git a/app/edge/components/group-form/index.js b/app/edge/components/group-form/index.js deleted file mode 100644 index 23ed1bbd0..000000000 --- a/app/edge/components/group-form/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import angular from 'angular'; - -import { EdgeGroupFormController } from './groupFormController'; - -angular.module('portainer.edge').component('edgeGroupForm', { - templateUrl: './groupForm.html', - controller: EdgeGroupFormController, - bindings: { - model: '<', - formActionLabel: '@', - formAction: '<', - actionInProgress: '<', - loaded: '<', - pageType: '@', - }, -}); diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts index d552e1a86..916005936 100644 --- a/app/edge/react/components/index.ts +++ b/app/edge/react/components/index.ts @@ -1,14 +1,13 @@ import angular from 'angular'; -import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector'; import { r2a } from '@/react-tools/react2angular'; import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; +import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm'; import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField'; import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm'; -import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm'; -import { withUIRouter } from '@/react-tools/withUIRouter'; -import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable'; -import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; +import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector'; import { edgeJobsModule } from './edge-jobs'; @@ -57,20 +56,12 @@ const ngModule = angular 'fieldSettings', ]) ) - .component( - 'edgeGroupAssociationTable', - r2a(withReactQuery(EdgeGroupAssociationTable), [ - 'onClickRow', - 'query', - 'title', - 'data-cy', - ]) - ) .component( 'associatedEdgeEnvironmentsSelector', r2a(withReactQuery(AssociatedEdgeEnvironmentsSelector), [ 'onChange', 'value', + 'error', ]) ); diff --git a/app/edge/react/views/groups.ts b/app/edge/react/views/groups.ts new file mode 100644 index 000000000..deb51b0f0 --- /dev/null +++ b/app/edge/react/views/groups.ts @@ -0,0 +1,20 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ListView } from '@/react/edge/edge-groups/ListView'; +import { CreateView } from '@/react/edge/edge-groups/CreateView/CreateView'; +import { ItemView } from '@/react/edge/edge-groups/ItemView/ItemView'; + +export const groupsModule = angular + .module('portainer.edge.react.views.groups', []) + .component('edgeGroupsView', r2a(withUIRouter(withCurrentUser(ListView)), [])) + .component( + 'edgeGroupsCreateView', + r2a(withUIRouter(withCurrentUser(CreateView)), []) + ) + .component( + 'edgeGroupsItemView', + r2a(withUIRouter(withCurrentUser(ItemView)), []) + ).name; diff --git a/app/edge/react/views/index.ts b/app/edge/react/views/index.ts index e4f010382..9b35040eb 100644 --- a/app/edge/react/views/index.ts +++ b/app/edge/react/views/index.ts @@ -5,23 +5,20 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { WaitingRoomView } from '@/react/edge/edge-devices/WaitingRoomView'; -import { ListView as EdgeGroupsListView } from '@/react/edge/edge-groups/ListView'; import { templatesModule } from './templates'; import { jobsModule } from './jobs'; import { stacksModule } from './edge-stacks'; +import { groupsModule } from './groups'; export const viewsModule = angular .module('portainer.edge.react.views', [ templatesModule, jobsModule, stacksModule, + groupsModule, ]) .component( 'waitingRoomView', r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), []) - ) - .component( - 'edgeGroupsView', - r2a(withUIRouter(withCurrentUser(EdgeGroupsListView)), []) ).name; diff --git a/app/edge/rest/edge-groups.js b/app/edge/rest/edge-groups.js index 676b10281..9c0a6cbb6 100644 --- a/app/edge/rest/edge-groups.js +++ b/app/edge/rest/edge-groups.js @@ -5,11 +5,7 @@ angular.module('portainer.edge').factory('EdgeGroups', function EdgeGroupsFactor API_ENDPOINT_EDGE_GROUPS + '/:id/:action', {}, { - create: { method: 'POST', ignoreLoadingBar: true }, query: { method: 'GET', isArray: true }, - get: { method: 'GET', params: { id: '@id' } }, - update: { method: 'PUT', params: { id: '@Id' } }, - remove: { method: 'DELETE', params: { id: '@id' } }, } ); }); diff --git a/app/edge/services/edge-group.js b/app/edge/services/edge-group.js index 48a27f377..62f87db71 100644 --- a/app/edge/services/edge-group.js +++ b/app/edge/services/edge-group.js @@ -3,25 +3,9 @@ import angular from 'angular'; angular.module('portainer.edge').factory('EdgeGroupService', function EdgeGroupServiceFactory(EdgeGroups) { var service = {}; - service.group = function group(groupId) { - return EdgeGroups.get({ id: groupId }).$promise; - }; - service.groups = function groups() { return EdgeGroups.query({}).$promise; }; - service.remove = function remove(groupId) { - return EdgeGroups.remove({ id: groupId }).$promise; - }; - - service.create = function create(group) { - return EdgeGroups.create(group).$promise; - }; - - service.update = function update(group) { - return EdgeGroups.update(group).$promise; - }; - return service; }); diff --git a/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupView.html b/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupView.html deleted file mode 100644 index 883cceaa1..000000000 --- a/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupView.html +++ /dev/null @@ -1,18 +0,0 @@ - - -
-
- - - - - -
-
diff --git a/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js b/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js deleted file mode 100644 index c82a63f1d..000000000 --- a/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js +++ /dev/null @@ -1,47 +0,0 @@ -export class CreateEdgeGroupController { - /* @ngInject */ - constructor(EdgeGroupService, GroupService, Notifications, $state, $async) { - this.EdgeGroupService = EdgeGroupService; - this.GroupService = GroupService; - this.Notifications = Notifications; - this.$state = $state; - this.$async = $async; - - this.state = { - actionInProgress: false, - loaded: false, - }; - - this.model = { - Name: '', - Endpoints: [], - Dynamic: false, - TagIds: [], - PartialMatch: false, - }; - - this.createGroup = this.createGroup.bind(this); - } - - async $onInit() { - const endpointGroups = await this.GroupService.groups(); - - this.endpointGroups = endpointGroups; - this.state.loaded = true; - } - - async createGroup(model) { - return this.$async(async () => { - this.state.actionInProgress = true; - try { - await this.EdgeGroupService.create(model); - this.Notifications.success('Success', 'Edge group successfully created'); - this.$state.go('edge.groups'); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to create edge group'); - } finally { - this.state.actionInProgress = false; - } - }); - } -} diff --git a/app/edge/views/edge-groups/createEdgeGroupView/index.js b/app/edge/views/edge-groups/createEdgeGroupView/index.js deleted file mode 100644 index c7c22d3ef..000000000 --- a/app/edge/views/edge-groups/createEdgeGroupView/index.js +++ /dev/null @@ -1,8 +0,0 @@ -import angular from 'angular'; - -import { CreateEdgeGroupController } from './createEdgeGroupViewController'; - -angular.module('portainer.edge').component('createEdgeGroupView', { - templateUrl: './createEdgeGroupView.html', - controller: CreateEdgeGroupController, -}); diff --git a/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupView.html b/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupView.html deleted file mode 100644 index 985fdebf3..000000000 --- a/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupView.html +++ /dev/null @@ -1,19 +0,0 @@ - - -
-
- - - - - -
-
diff --git a/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js b/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js deleted file mode 100644 index 3838206ca..000000000 --- a/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js +++ /dev/null @@ -1,44 +0,0 @@ -export class EditEdgeGroupController { - /* @ngInject */ - constructor(EdgeGroupService, GroupService, Notifications, $state, $async) { - this.EdgeGroupService = EdgeGroupService; - this.GroupService = GroupService; - this.Notifications = Notifications; - this.$state = $state; - this.$async = $async; - - this.state = { - actionInProgress: false, - loaded: false, - }; - - this.updateGroup = this.updateGroup.bind(this); - } - - async $onInit() { - const [endpointGroups, group] = await Promise.all([this.GroupService.groups(), this.EdgeGroupService.group(this.$state.params.groupId)]); - - if (!group) { - this.Notifications.error('Failed to find edge group', {}); - this.$state.go('edge.groups'); - } - this.endpointGroups = endpointGroups; - this.model = group; - this.state.loaded = true; - } - - updateGroup(group) { - return this.$async(async () => { - this.state.actionInProgress = true; - try { - await this.EdgeGroupService.update(group); - this.Notifications.success('Success', 'Edge group successfully updated'); - this.$state.go('edge.groups'); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to update edge group'); - } finally { - this.state.actionInProgress = false; - } - }); - } -} diff --git a/app/edge/views/edge-groups/editEdgeGroupView/index.js b/app/edge/views/edge-groups/editEdgeGroupView/index.js deleted file mode 100644 index 24c63736e..000000000 --- a/app/edge/views/edge-groups/editEdgeGroupView/index.js +++ /dev/null @@ -1,8 +0,0 @@ -import angular from 'angular'; - -import { EditEdgeGroupController } from './editEdgeGroupViewController'; - -angular.module('portainer.edge').component('editEdgeGroupView', { - templateUrl: './editEdgeGroupView.html', - controller: EditEdgeGroupController, -}); diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 44fd7cc5f..705962997 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -70,6 +70,7 @@ export const ngModule = angular 'allowCreate', 'onChange', 'value', + 'errors', ]) ) .component( diff --git a/app/react/components/Redirect.tsx b/app/react/components/Redirect.tsx new file mode 100644 index 000000000..13d5d52e9 --- /dev/null +++ b/app/react/components/Redirect.tsx @@ -0,0 +1,10 @@ +import { useRouter } from '@uirouter/react'; +import { useEffect } from 'react'; + +export function Redirect({ to, params = {} }: { to: string; params?: object }) { + const router = useRouter(); + useEffect(() => { + router.stateService.go(to, params); + }, [params, router.stateService, to]); + return null; +} diff --git a/app/react/components/TagSelector/TagSelector.tsx b/app/react/components/TagSelector/TagSelector.tsx index 4c30bf604..425337c64 100644 --- a/app/react/components/TagSelector/TagSelector.tsx +++ b/app/react/components/TagSelector/TagSelector.tsx @@ -6,6 +6,7 @@ import { useCreateTagMutation, useTags } from '@/portainer/tags/queries'; import { Creatable, Select } from '@@/form-components/ReactSelect'; import { FormControl } from '@@/form-components/FormControl'; import { Link } from '@@/Link'; +import { ArrayError } from '@@/form-components/InputList/InputList'; import { TagButton } from '../TagButton'; @@ -13,6 +14,7 @@ interface Props { value: TagId[]; allowCreate?: boolean; onChange(value: TagId[]): void; + errors?: ArrayError; } interface Option { @@ -20,7 +22,12 @@ interface Option { label: string; } -export function TagSelector({ value, allowCreate = false, onChange }: Props) { +export function TagSelector({ + value, + allowCreate = false, + onChange, + errors, +}: Props) { // change the struct because react-select has a bug with Creatable (https://github.com/JedWatson/react-select/issues/3417#issuecomment-461868989) const tagsQuery = useTags({ select: (tags) => tags?.map((opt) => ({ label: opt.Name, value: opt.ID })), @@ -62,19 +69,29 @@ export function TagSelector({ value, allowCreate = false, onChange }: Props) { <> {value.length > 0 && ( - {selectedTags.map((tag) => ( - handleRemove(tag.value)} - /> - ))} +
+ {selectedTags.map((tag) => ( + handleRemove(tag.value)} + /> + ))} +
)} - + e?.toString()) + } + > ) { return ( @@ -35,6 +37,13 @@ export function FormActions({ > {submitLabel} + + {!isValid && ( +
+ {JSON.stringify(errors)} +
+ )} + {children} diff --git a/app/react/edge/components/AssociatedEdgeEnvironmentsSelector.tsx b/app/react/edge/components/AssociatedEdgeEnvironmentsSelector.tsx index ba3569582..7c8bce1d5 100644 --- a/app/react/edge/components/AssociatedEdgeEnvironmentsSelector.tsx +++ b/app/react/edge/components/AssociatedEdgeEnvironmentsSelector.tsx @@ -1,16 +1,21 @@ import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types'; +import { FormError } from '@@/form-components/FormError'; +import { ArrayError } from '@@/form-components/InputList/InputList'; + import { EdgeGroupAssociationTable } from './EdgeGroupAssociationTable'; export function AssociatedEdgeEnvironmentsSelector({ onChange, value, + error, }: { onChange: ( value: EnvironmentId[], meta: { type: 'add' | 'remove'; value: EnvironmentId } ) => void; value: EnvironmentId[]; + error?: ArrayError>; }) { return ( <> @@ -20,6 +25,14 @@ export function AssociatedEdgeEnvironmentsSelector({ environment entry to move it from one table to the other. + {error && ( +
+ + {typeof error === 'string' ? error : error.join(', ')} + +
+ )} +
diff --git a/app/react/edge/components/EdgeGroupAssociationTable.tsx b/app/react/edge/components/EdgeGroupAssociationTable.tsx index d2f9c9d8a..70ff66a08 100644 --- a/app/react/edge/components/EdgeGroupAssociationTable.tsx +++ b/app/react/edge/components/EdgeGroupAssociationTable.tsx @@ -2,15 +2,15 @@ import { createColumnHelper } from '@tanstack/react-table'; import { truncate } from 'lodash'; import { useMemo, useState } from 'react'; +import { useTags } from '@/portainer/tags/queries'; +import { useGroups } from '@/react/portainer/environments/environment-groups/queries'; +import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service'; import { useEnvironmentList } from '@/react/portainer/environments/queries'; import { Environment } 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'; +import { useTableStateWithoutStorage } from '@@/datatables/useTableState'; type DecoratedEnvironment = Environment & { Tags: string[]; @@ -41,12 +41,12 @@ const columns = [ export function EdgeGroupAssociationTable({ title, query, - onClickRow, + onClickRow = () => {}, 'data-cy': dataCy, }: { title: string; query: EnvironmentsQueryParams; - onClickRow: (env: Environment) => void; + onClickRow?: (env: Environment) => void; } & AutomationTestingProps) { const tableState = useTableStateWithoutStorage('Name'); const [page, setPage] = useState(0); diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/EdgeGroupSelector.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/EdgeGroupSelector.tsx index 9a6f29594..71a313686 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/EdgeGroupSelector.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/EdgeGroupSelector.tsx @@ -1,11 +1,11 @@ import { notifySuccess } from '@/portainer/services/notifications'; -import { useCreateGroupMutation } from '@/react/edge/edge-groups/queries/useCreateEdgeGroupMutation'; +import { useCreateEdgeGroupMutation } from '@/react/edge/edge-groups/queries/useCreateEdgeGroupMutation'; import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups'; import { CreatableSelector } from './CreatableSelector'; export function EdgeGroupsSelector() { - const createMutation = useCreateGroupMutation(); + const createMutation = useCreateEdgeGroupMutation(); const edgeGroupsQuery = useEdgeGroups({ select: (edgeGroups) => diff --git a/app/react/edge/edge-groups/.keep b/app/react/edge/edge-groups/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/edge/edge-groups/CreateView/.keep b/app/react/edge/edge-groups/CreateView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/edge/edge-groups/CreateView/CreateView.tsx b/app/react/edge/edge-groups/CreateView/CreateView.tsx new file mode 100644 index 000000000..9d279193f --- /dev/null +++ b/app/react/edge/edge-groups/CreateView/CreateView.tsx @@ -0,0 +1,55 @@ +import { useRouter } from '@uirouter/react'; + +import { notifySuccess } from '@/portainer/services/notifications'; + +import { PageHeader } from '@@/PageHeader'; +import { Widget } from '@@/Widget'; + +import { useCreateEdgeGroupMutation } from '../queries/useCreateEdgeGroupMutation'; +import { EdgeGroupForm } from '../components/EdgeGroupForm/EdgeGroupForm'; + +export function CreateView() { + const mutation = useCreateEdgeGroupMutation(); + const router = useRouter(); + + return ( + <> + + +
+
+ + + { + mutation.mutate( + { + endpoints: environmentIds, + ...values, + }, + { + onSuccess: () => { + notifySuccess( + 'Success', + 'Edge group successfully created' + ); + router.stateService.go('^'); + }, + } + ); + }} + isLoading={mutation.isLoading} + /> + + +
+
+ + ); +} diff --git a/app/react/edge/edge-groups/ItemView/.keep b/app/react/edge/edge-groups/ItemView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/edge/edge-groups/ItemView/ItemView.tsx b/app/react/edge/edge-groups/ItemView/ItemView.tsx new file mode 100644 index 000000000..015b1e9a4 --- /dev/null +++ b/app/react/edge/edge-groups/ItemView/ItemView.tsx @@ -0,0 +1,72 @@ +import { useCurrentStateAndParams, useRouter } from '@uirouter/react'; + +import { notifySuccess } from '@/portainer/services/notifications'; + +import { PageHeader } from '@@/PageHeader'; +import { Widget } from '@@/Widget'; +import { Redirect } from '@@/Redirect'; + +import { useUpdateEdgeGroupMutation } from '../queries/useUpdateEdgeGroupMutation'; +import { EdgeGroupForm } from '../components/EdgeGroupForm/EdgeGroupForm'; +import { useEdgeGroup } from '../queries/useEdgeGroup'; + +export function ItemView() { + const { + params: { groupId: id }, + } = useCurrentStateAndParams(); + const groupQuery = useEdgeGroup(id); + const mutation = useUpdateEdgeGroupMutation(); + const router = useRouter(); + + if (groupQuery.isError) { + return ; + } + + if (!groupQuery.data) { + return null; + } + + const group = groupQuery.data; + return ( + <> + + +
+
+ + + { + mutation.mutate( + { + id, + endpoints: environmentIds, + ...values, + }, + { + onSuccess: () => { + notifySuccess( + 'Success', + 'Edge group successfully updated' + ); + router.stateService.go('^'); + }, + } + ); + }} + isLoading={mutation.isLoading} + /> + + +
+
+ + ); +} diff --git a/app/react/edge/edge-groups/components/EdgeGroupForm/DynamicGroupFieldset.tsx b/app/react/edge/edge-groups/components/EdgeGroupForm/DynamicGroupFieldset.tsx new file mode 100644 index 000000000..6d3f19760 --- /dev/null +++ b/app/react/edge/edge-groups/components/EdgeGroupForm/DynamicGroupFieldset.tsx @@ -0,0 +1,45 @@ +import { useFormikContext } from 'formik'; + +import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable'; +import { EdgeTypes } from '@/react/portainer/environments/types'; + +import { BoxSelector } from '@@/BoxSelector'; +import { TagSelector } from '@@/TagSelector'; +import { FormSection } from '@@/form-components/FormSection'; + +import { tagOptions } from './tag-options'; +import { FormValues } from './types'; + +export function DynamicGroupFieldset() { + const { values, setFieldValue, errors } = useFormikContext(); + return ( + <> + + + setFieldValue('partialMatch', partialMatch) + } + options={tagOptions} + radioName="partialMatch" + /> + setFieldValue('tagIds', tagIds)} + errors={errors.tagIds} + /> + + + + + ); +} diff --git a/app/react/edge/edge-groups/components/EdgeGroupForm/EdgeGroupForm.tsx b/app/react/edge/edge-groups/components/EdgeGroupForm/EdgeGroupForm.tsx new file mode 100644 index 000000000..1e401dfa7 --- /dev/null +++ b/app/react/edge/edge-groups/components/EdgeGroupForm/EdgeGroupForm.tsx @@ -0,0 +1,90 @@ +import { Form, Formik, useFormikContext } from 'formik'; + +import { FormSection } from '@@/form-components/FormSection'; +import { BoxSelector } from '@@/BoxSelector'; +import { FormActions } from '@@/form-components/FormActions'; + +import { EdgeGroup } from '../../types'; + +import { groupTypeOptions } from './group-type-options'; +import { FormValues } from './types'; +import { useValidation } from './useValidation'; +import { DynamicGroupFieldset } from './DynamicGroupFieldset'; +import { StaticGroupFieldset } from './StaticGroupFieldset'; +import { NameField } from './NameField'; + +export function EdgeGroupForm({ + onSubmit, + isLoading, + group, +}: { + onSubmit: (values: FormValues) => void; + isLoading: boolean; + group?: EdgeGroup; +}) { + const validation = useValidation({ id: group?.Id }); + return ( + + + + ); +} + +function InnerForm({ + isLoading, + isCreate, +}: { + isLoading: boolean; + isCreate: boolean; +}) { + const { values, setFieldValue, isValid, errors } = + useFormikContext(); + + return ( +
+ + + + setFieldValue('dynamic', dynamic)} + options={groupTypeOptions} + radioName="groupTypeDynamic" + /> + + + {values.dynamic ? : } + + + + ); +} diff --git a/app/react/edge/edge-groups/components/EdgeGroupForm/NameField.tsx b/app/react/edge/edge-groups/components/EdgeGroupForm/NameField.tsx new file mode 100644 index 000000000..3ea4702e3 --- /dev/null +++ b/app/react/edge/edge-groups/components/EdgeGroupForm/NameField.tsx @@ -0,0 +1,42 @@ +import { Field, FormikErrors } from 'formik'; +import { string } from 'yup'; +import { useMemo } from 'react'; + +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; + +import { useEdgeGroups } from '../../queries/useEdgeGroups'; +import { EdgeGroup } from '../../types'; + +export function NameField({ errors }: { errors?: FormikErrors }) { + return ( + + + + ); +} + +export function useNameValidation(id?: EdgeGroup['Id']) { + const edgeGroupsQuery = useEdgeGroups(); + + return useMemo( + () => + string() + .required('Name is required') + .test({ + name: 'is-unique', + test: (value) => + !edgeGroupsQuery.data?.find( + (group) => group.Name === value && group.Id !== id + ), + message: 'Name must be unique', + }), + [edgeGroupsQuery.data, id] + ); +} diff --git a/app/react/edge/edge-groups/components/EdgeGroupForm/StaticGroupFieldset.tsx b/app/react/edge/edge-groups/components/EdgeGroupForm/StaticGroupFieldset.tsx new file mode 100644 index 000000000..bb333af7b --- /dev/null +++ b/app/react/edge/edge-groups/components/EdgeGroupForm/StaticGroupFieldset.tsx @@ -0,0 +1,40 @@ +import { useFormikContext } from 'formik'; + +import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; + +import { FormSection } from '@@/form-components/FormSection'; +import { confirmDestructive } from '@@/modals/confirm'; +import { buildConfirmButton } from '@@/modals/utils'; + +import { FormValues } from './types'; + +export function StaticGroupFieldset({ isEdit }: { isEdit?: boolean }) { + const { values, setFieldValue, errors } = useFormikContext(); + + return ( + +
+ { + if (meta.type === 'remove' && isEdit) { + 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; + } + } + + setFieldValue('environmentIds', environmentIds); + }} + /> +
+
+ ); +} diff --git a/app/react/edge/edge-groups/CreateView/group-type-options.tsx b/app/react/edge/edge-groups/components/EdgeGroupForm/group-type-options.tsx similarity index 100% rename from app/react/edge/edge-groups/CreateView/group-type-options.tsx rename to app/react/edge/edge-groups/components/EdgeGroupForm/group-type-options.tsx diff --git a/app/react/edge/edge-groups/CreateView/tag-options.tsx b/app/react/edge/edge-groups/components/EdgeGroupForm/tag-options.tsx similarity index 100% rename from app/react/edge/edge-groups/CreateView/tag-options.tsx rename to app/react/edge/edge-groups/components/EdgeGroupForm/tag-options.tsx diff --git a/app/react/edge/edge-groups/components/EdgeGroupForm/types.tsx b/app/react/edge/edge-groups/components/EdgeGroupForm/types.tsx new file mode 100644 index 000000000..2c4e0d6bf --- /dev/null +++ b/app/react/edge/edge-groups/components/EdgeGroupForm/types.tsx @@ -0,0 +1,10 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { TagId } from '@/portainer/tags/types'; + +export interface FormValues { + name: string; + dynamic: boolean; + environmentIds: EnvironmentId[]; + partialMatch: boolean; + tagIds: TagId[]; +} diff --git a/app/react/edge/edge-groups/components/EdgeGroupForm/useValidation.tsx b/app/react/edge/edge-groups/components/EdgeGroupForm/useValidation.tsx new file mode 100644 index 000000000..7f4bc8284 --- /dev/null +++ b/app/react/edge/edge-groups/components/EdgeGroupForm/useValidation.tsx @@ -0,0 +1,27 @@ +import { SchemaOf, array, boolean, number, object } from 'yup'; +import { useMemo } from 'react'; + +import { EdgeGroup } from '../../types'; + +import { FormValues } from './types'; +import { useNameValidation } from './NameField'; + +export function useValidation({ + id, +}: { id?: EdgeGroup['Id'] } = {}): SchemaOf { + const nameValidation = useNameValidation(id); + return useMemo( + () => + object({ + name: nameValidation, + dynamic: boolean().default(false), + environmentIds: array(number().required()), + partialMatch: boolean().default(false), + tagIds: array(number().required()).when('dynamic', { + is: true, + then: (schema) => schema.min(1, 'Tags are required'), + }), + }), + [nameValidation] + ); +} diff --git a/app/react/edge/edge-groups/queries/query-keys.ts b/app/react/edge/edge-groups/queries/query-keys.ts index a46e0c0e8..5123c1cd5 100644 --- a/app/react/edge/edge-groups/queries/query-keys.ts +++ b/app/react/edge/edge-groups/queries/query-keys.ts @@ -1,3 +1,4 @@ export const queryKeys = { base: () => ['edge', 'groups'] as const, + item: (id: number) => [...queryKeys.base(), id] as const, }; diff --git a/app/react/edge/edge-groups/queries/useCreateEdgeGroupMutation.ts b/app/react/edge/edge-groups/queries/useCreateEdgeGroupMutation.ts index 0fd0f21d5..225a399e6 100644 --- a/app/react/edge/edge-groups/queries/useCreateEdgeGroupMutation.ts +++ b/app/react/edge/edge-groups/queries/useCreateEdgeGroupMutation.ts @@ -34,7 +34,7 @@ export async function createEdgeGroup(requestPayload: CreateGroupPayload) { } } -export function useCreateGroupMutation() { +export function useCreateEdgeGroupMutation() { const queryClient = useQueryClient(); return useMutation( diff --git a/app/react/edge/edge-groups/queries/useEdgeGroup.ts b/app/react/edge/edge-groups/queries/useEdgeGroup.ts new file mode 100644 index 000000000..d17d2a8f6 --- /dev/null +++ b/app/react/edge/edge-groups/queries/useEdgeGroup.ts @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { + EnvironmentId, + EnvironmentType, +} from '@/react/portainer/environments/types'; +import { withError } from '@/react-tools/react-query'; + +import { EdgeGroup } from '../types'; + +import { queryKeys } from './query-keys'; +import { buildUrl } from './build-url'; + +export interface EdgeGroupListItemResponse extends EdgeGroup { + EndpointTypes: Array; + HasEdgeStack?: boolean; + HasEdgeJob?: boolean; + HasEdgeConfig?: boolean; + TrustedEndpoints: Array; +} + +async function getEdgeGroup(id: EdgeGroup['Id']) { + try { + const { data } = await axios.get(buildUrl({ id })); + return data; + } catch (err) { + throw parseAxiosError(err as Error, 'Failed fetching edge groups'); + } +} + +export function useEdgeGroup( + id?: EdgeGroup['Id'], + { + select, + }: { + select?: (groups: EdgeGroup) => T; + } = {} +) { + return useQuery({ + queryKey: queryKeys.item(id!), + queryFn: () => getEdgeGroup(id!), + select, + enabled: !!id, + ...withError('Failed fetching edge group'), + }); +} diff --git a/app/react/edge/edge-groups/queries/useUpdateEdgeGroupMutation.ts b/app/react/edge/edge-groups/queries/useUpdateEdgeGroupMutation.ts new file mode 100644 index 000000000..9ec7cac3b --- /dev/null +++ b/app/react/edge/edge-groups/queries/useUpdateEdgeGroupMutation.ts @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { TagId } from '@/portainer/tags/types'; +import { + mutationOptions, + withError, + withInvalidate, +} from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { EdgeGroup } from '../types'; + +import { buildUrl } from './build-url'; +import { queryKeys } from './query-keys'; + +interface UpdateGroupPayload { + id: EdgeGroup['Id']; + name: string; + dynamic: boolean; + tagIds?: TagId[]; + endpoints?: EnvironmentId[]; + partialMatch?: boolean; +} + +export async function updateEdgeGroup({ + id, + ...requestPayload +}: UpdateGroupPayload) { + try { + const { data: group } = await axios.put( + buildUrl({ id }), + requestPayload + ); + return group; + } catch (e) { + throw parseAxiosError(e as Error, 'Failed to update Edge group'); + } +} + +export function useUpdateEdgeGroupMutation() { + const queryClient = useQueryClient(); + + return useMutation( + updateEdgeGroup, + mutationOptions( + withError('Failed to update Edge group'), + withInvalidate(queryClient, [queryKeys.base()]) + ) + ); +}