From 73ad27640c5a6789ee3c7716897064bece53db83 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 4 Nov 2025 18:44:54 +0200 Subject: [PATCH] refactor(stacks): migrate duplication form to react [BE-12353] (#1357) --- .../stack-duplication-form-controller.js | 117 ------ .../stack-duplication-form.html | 74 ---- .../stack-duplication-form.js | 12 - app/portainer/react/components/index.ts | 2 + app/portainer/react/components/stacks.ts | 18 + app/portainer/rest/stack.js | 1 - app/portainer/services/api/stackService.js | 41 -- app/portainer/views/stacks/edit/stack.html | 11 +- .../views/stacks/edit/stackController.js | 76 ---- .../CreateContainerInstanceForm.test.tsx | 4 +- app/react/common/stacks/ItemView/.keep | 0 .../StackDuplicationForm/EnvSelector.test.tsx | 196 +++++++++ .../StackDuplicationForm/EnvSelector.tsx | 99 +++++ .../StackDuplicationForm.test.tsx | 105 +++++ .../StackDuplicationForm.tsx | 169 ++++++++ .../StackDuplicationForm.types.ts | 10 + .../StackDuplicationForm.validation.test.ts | 347 ++++++++++++++++ .../StackDuplicationForm.validation.ts | 94 +++++ .../StackDuplicationFormInner.test.tsx | 378 ++++++++++++++++++ .../StackDuplicationFormInner.tsx | 122 ++++++ .../useDuplicateStackMutation.test.tsx | 277 +++++++++++++ .../useDuplicateStackMutation.ts | 51 +++ .../useMigrateStackMutation.test.tsx | 353 ++++++++++++++++ .../useMigrateStackMutation.ts | 101 +++++ .../components/form-components/FormError.tsx | 2 + package.json | 1 + yarn.lock | 15 + 27 files changed, 2347 insertions(+), 329 deletions(-) delete mode 100644 app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js delete mode 100644 app/portainer/components/stack-duplication-form/stack-duplication-form.html delete mode 100644 app/portainer/components/stack-duplication-form/stack-duplication-form.js create mode 100644 app/portainer/react/components/stacks.ts delete mode 100644 app/react/common/stacks/ItemView/.keep create mode 100644 app/react/common/stacks/ItemView/StackDuplicationForm/EnvSelector.test.tsx create mode 100644 app/react/common/stacks/ItemView/StackDuplicationForm/EnvSelector.tsx create mode 100644 app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.test.tsx create mode 100644 app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.tsx create mode 100644 app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.types.ts create mode 100644 app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.validation.test.ts create mode 100644 app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.validation.ts create mode 100644 app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationFormInner.test.tsx create mode 100644 app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationFormInner.tsx create mode 100644 app/react/common/stacks/ItemView/StackDuplicationForm/useDuplicateStackMutation.test.tsx create mode 100644 app/react/common/stacks/ItemView/StackDuplicationForm/useDuplicateStackMutation.ts create mode 100644 app/react/common/stacks/ItemView/StackDuplicationForm/useMigrateStackMutation.test.tsx create mode 100644 app/react/common/stacks/ItemView/StackDuplicationForm/useMigrateStackMutation.ts diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js b/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js deleted file mode 100644 index e597bc000..000000000 --- a/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js +++ /dev/null @@ -1,117 +0,0 @@ -import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants'; - -angular.module('portainer.app').controller('StackDuplicationFormController', [ - 'Notifications', - '$scope', - function StackDuplicationFormController(Notifications, $scope) { - var ctrl = this; - - ctrl.environmentSelectorOptions = null; - - ctrl.state = { - duplicationInProgress: false, - migrationInProgress: false, - }; - - ctrl.formValues = { - endpointId: null, - newName: '', - }; - - ctrl.STACK_NAME_VALIDATION_REGEX = STACK_NAME_VALIDATION_REGEX; - - ctrl.isFormValidForDuplication = isFormValidForDuplication; - ctrl.isFormValidForMigration = isFormValidForMigration; - ctrl.duplicateStack = duplicateStack; - ctrl.migrateStack = migrateStack; - ctrl.isMigrationButtonDisabled = isMigrationButtonDisabled; - ctrl.isEndpointSelected = isEndpointSelected; - ctrl.onChangeEnvironment = onChangeEnvironment; - ctrl.$onChanges = $onChanges; - - function isFormValidForMigration() { - return ctrl.formValues.endpointId; - } - - function isFormValidForDuplication() { - return isFormValidForMigration() && ctrl.formValues.newName && !ctrl.yamlError; - } - - function onChangeEnvironment(endpointId) { - return $scope.$evalAsync(() => { - ctrl.formValues.endpointId = endpointId; - }); - } - - function duplicateStack() { - if (!ctrl.formValues.newName) { - Notifications.error('Failure', null, 'Stack name is required for duplication'); - return; - } - ctrl.state.duplicationInProgress = true; - ctrl - .onDuplicate({ - endpointId: ctrl.formValues.endpointId, - name: ctrl.formValues.newName ? ctrl.formValues.newName : undefined, - }) - .finally(function () { - ctrl.state.duplicationInProgress = false; - }); - } - - function migrateStack() { - ctrl.state.migrationInProgress = true; - ctrl - .onMigrate({ - endpointId: ctrl.formValues.endpointId, - name: ctrl.formValues.newName ? ctrl.formValues.newName : undefined, - }) - .finally(function () { - ctrl.state.migrationInProgress = false; - }); - } - - function isMigrationButtonDisabled() { - return !ctrl.isFormValidForMigration() || ctrl.state.duplicationInProgress || ctrl.state.migrationInProgress || isTargetEndpointAndCurrentEquals(); - } - - function isTargetEndpointAndCurrentEquals() { - return ctrl.formValues.endpointId === ctrl.currentEndpointId; - } - - function isEndpointSelected() { - return ctrl.formValues.endpointId; - } - - function $onChanges() { - ctrl.environmentSelectorOptions = getOptions(ctrl.groups, ctrl.endpoints); - } - }, -]); - -function getOptions(groups, environments) { - if (!groups || !environments) { - return []; - } - - const groupSet = environments.reduce((groupSet, environment) => { - const groupEnvironments = groupSet[environment.GroupId] || []; - - return { - ...groupSet, - [environment.GroupId]: [...groupEnvironments, { label: environment.Name, value: environment.Id }], - }; - }, {}); - - return Object.entries(groupSet).map(([groupId, environments]) => { - const group = groups.find((group) => group.Id === parseInt(groupId, 10)); - if (!group) { - throw new Error('missing group'); - } - - return { - label: group.Name, - options: environments, - }; - }); -} diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form.html b/app/portainer/components/stack-duplication-form/stack-duplication-form.html deleted file mode 100644 index 424479eb8..000000000 --- a/app/portainer/components/stack-duplication-form/stack-duplication-form.html +++ /dev/null @@ -1,74 +0,0 @@ -
-
Stack duplication / migration
- - -
-
- -

This feature allows you to duplicate or migrate this stack.

-
-
- -
- -
-
-
-
-

- - This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123'). -

-
-
-
- -
- -
- -
- - -
- -
-
- {{ $ctrl.yamlError }} -
-
-
-
-
-
diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form.js b/app/portainer/components/stack-duplication-form/stack-duplication-form.js deleted file mode 100644 index c9460611d..000000000 --- a/app/portainer/components/stack-duplication-form/stack-duplication-form.js +++ /dev/null @@ -1,12 +0,0 @@ -angular.module('portainer.app').component('stackDuplicationForm', { - templateUrl: './stack-duplication-form.html', - controller: 'StackDuplicationFormController', - bindings: { - onDuplicate: '&', - onMigrate: '&', - endpoints: '<', - groups: '<', - currentEndpointId: '<', - yamlError: '<', - }, -}); diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index f43cd3c16..b97afb650 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -53,6 +53,7 @@ import { accountModule } from './account'; import { usersModule } from './users'; import { activityLogsModule } from './activity-logs'; import { rbacModule } from './rbac'; +import { stacksModule } from './stacks'; export const ngModule = angular .module('portainer.app.react.components', [ @@ -66,6 +67,7 @@ export const ngModule = angular usersModule, activityLogsModule, rbacModule, + stacksModule, ]) .component( 'tagSelector', diff --git a/app/portainer/react/components/stacks.ts b/app/portainer/react/components/stacks.ts new file mode 100644 index 000000000..ceedda0dd --- /dev/null +++ b/app/portainer/react/components/stacks.ts @@ -0,0 +1,18 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { StackDuplicationForm } from '@/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm'; +import { withUIRouter } from '@/react-tools/withUIRouter'; + +export const stacksModule = angular + .module('portainer.app.react.components.stacks', []) + .component( + 'stackDuplicationForm', + r2a(withUIRouter(withReactQuery(StackDuplicationForm)), [ + 'yamlError', + 'currentEnvironmentId', + 'originalFileContent', + 'stack', + ]) + ).name; diff --git a/app/portainer/rest/stack.js b/app/portainer/rest/stack.js index 4bc761552..85803ca02 100644 --- a/app/portainer/rest/stack.js +++ b/app/portainer/rest/stack.js @@ -16,7 +16,6 @@ function StackFactory($resource, API_ENDPOINT_STACKS) { associate: { method: 'PUT', params: { id: '@id', swarmId: '@swarmId', endpointId: '@endpointId', orphanedRunning: '@orphanedRunning', action: 'associate' } }, remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } }, getStackFile: { method: 'GET', params: { id: '@id', action: 'file' } }, - migrate: { method: 'POST', params: { id: '@id', action: 'migrate', endpointId: '@endpointId' }, ignoreLoadingBar: true }, start: { method: 'POST', params: { id: '@id', action: 'start', endpointId: '@endpointId' } }, stop: { method: 'POST', params: { id: '@id', action: 'stop', endpointId: '@endpointId' } }, updateGit: { method: 'PUT', params: { id: '@id', action: 'git', subaction: 'redeploy' } }, diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index abeb09165..5d347111d 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -48,42 +48,6 @@ angular.module('portainer.app').factory('StackService', [ return deferred.promise; }; - service.migrateSwarmStack = function (stack, targetEndpointId, newName) { - var deferred = $q.defer(); - - SwarmService.swarm(targetEndpointId) - .then(function success(data) { - var swarm = data; - if (swarm.ID === stack.SwarmId) { - deferred.reject({ msg: 'Target environment is located in the same Swarm cluster as the current environment', err: null }); - return; - } - return Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, SwarmID: swarm.ID, Name: newName }).$promise; - }) - .then(function success() { - deferred.resolve(); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to migrate stack', err: err }); - }); - - return deferred.promise; - }; - - service.migrateComposeStack = function (stack, targetEndpointId, newName) { - var deferred = $q.defer(); - - Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, Name: newName }) - .$promise.then(function success() { - deferred.resolve(); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to migrate stack', err: err }); - }); - - return deferred.promise; - }; - service.stacks = function (compose, swarm, endpointId, includeOrphanedStacks = false) { var deferred = $q.defer(); @@ -410,11 +374,6 @@ angular.module('portainer.app').factory('StackService', [ return deferred.promise; }; - service.duplicateStack = function duplicateStack(name, stackFileContent, env, endpointId, type) { - var action = type === 1 ? service.createSwarmStackFromFileContent : service.createComposeStackFromFileContent; - return action(name, stackFileContent, env, endpointId); - }; - async function kubernetesDeployAsync(endpointId, method, payload) { try { await Stack.create({ endpointId: endpointId }, { method, type: 'kubernetes', ...payload }).$promise; diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index 8ecf54e06..8ca1f3a44 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -119,14 +119,13 @@ endpoint="applicationState.endpoint" > + diff --git a/app/portainer/views/stacks/edit/stackController.js b/app/portainer/views/stacks/edit/stackController.js index ca46380fd..8c0280f3e 100644 --- a/app/portainer/views/stacks/edit/stackController.js +++ b/app/portainer/views/stacks/edit/stackController.js @@ -1,7 +1,6 @@ import { ResourceControlType } from '@/react/portainer/access-control/types'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { FeatureId } from '@/react/portainer/feature-flags/enums'; -import { getEnvironments } from '@/react/portainer/environments/environment.service'; import { StackStatus, StackType } from '@/react/common/stacks/types'; import { extractContainerNames } from '@/portainer/helpers/stackHelper'; import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update'; @@ -26,7 +25,6 @@ angular.module('portainer.app').controller('StackController', [ 'TaskHelper', 'Notifications', 'FormHelper', - 'GroupService', 'StackHelper', 'ResourceControlService', 'Authentication', @@ -48,7 +46,6 @@ angular.module('portainer.app').controller('StackController', [ TaskHelper, Notifications, FormHelper, - GroupService, StackHelper, ResourceControlService, Authentication, @@ -109,43 +106,10 @@ angular.module('portainer.app').controller('StackController', [ }); }; - $scope.duplicateStack = function duplicateStack(name, targetEndpointId) { - var stack = $scope.stack; - var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env); - - return StackService.duplicateStack(name, $scope.stackFileContent, env, targetEndpointId, stack.Type).then(onDuplicationSuccess).catch(notifyOnError); - - function onDuplicationSuccess() { - Notifications.success('Success', 'Stack successfully duplicated'); - $state.go('docker.stacks', {}, { reload: true }); - } - - function notifyOnError(err) { - Notifications.error('Failure', err, 'Unable to duplicate stack'); - } - }; - $scope.showEditor = function () { $scope.state.showEditorTab = true; }; - $scope.migrateStack = function (name, endpointId) { - return $q(async function (resolve) { - const confirmed = await confirm({ - title: 'Are you sure?', - modalType: ModalType.Warn, - message: - 'This action will deploy a new instance of this stack on the target environment, please note that this does NOT relocate the content of any persistent volumes that may be attached to this stack.', - confirmButton: buildConfirmButton('Migrate', 'danger'), - }); - - if (!confirmed) { - return resolve(); - } - return resolve(migrateStack(name, endpointId)); - }); - }; - $scope.removeStack = function () { confirmDelete('Do you want to remove the stack? Associated services will be removed as well').then((confirmed) => { if (!confirmed) { @@ -165,36 +129,6 @@ angular.module('portainer.app').controller('StackController', [ }); }; - function migrateStack(name, targetEndpointId) { - const stack = $scope.stack; - - let migrateRequest = StackService.migrateSwarmStack; - if (stack.Type === 2) { - migrateRequest = StackService.migrateComposeStack; - } - - // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 - // The EndpointID property is not available for these stacks, we can pass - // the current endpoint identifier as a part of the migrate request. It will be used if - // the EndpointID property is not defined on the stack. - if (!stack.EndpointId) { - stack.EndpointId = endpoint.Id; - } - - $scope.state.migrationInProgress = true; - return migrateRequest(stack, targetEndpointId, name) - .then(function success() { - Notifications.success('Stack successfully migrated', stack.Name); - $state.go('docker.stacks', {}, { reload: true }); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to migrate stack'); - }) - .finally(function final() { - $scope.state.migrationInProgress = false; - }); - } - function deleteStack() { var endpointId = +$state.params.endpointId; var stack = $scope.stack; @@ -322,22 +256,12 @@ angular.module('portainer.app').controller('StackController', [ return $async(async () => { var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; - getEnvironments() - .then(function success(data) { - $scope.endpoints = data.value; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve environments'); - }); - $q.all({ stack: StackService.stack(id), - groups: GroupService.groups(), containers: ContainerService.containers(endpoint.Id, true), }) .then(function success(data) { var stack = data.stack; - $scope.groups = data.groups; $scope.stack = stack; $scope.containerNames = ContainerHelper.getContainerNames(data.containers); diff --git a/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.test.tsx b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.test.tsx index d2fcb9716..761e2775e 100644 --- a/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.test.tsx +++ b/app/react/azure/container-instances/CreateView/CreateContainerInstanceForm.test.tsx @@ -32,10 +32,10 @@ test('submit button should be disabled when name or image is missing', async () expect(button).toBeVisible(); expect(button).toBeDisabled(); - const nameInput = getByLabelText(/name/i); + const nameInput = getByLabelText(/name/i, { selector: 'input' }); await userEvent.type(nameInput, 'name'); - const imageInput = getByLabelText(/image/i); + const imageInput = getByLabelText(/image/i, { selector: 'input' }); await userEvent.type(imageInput, 'image'); await expect(findByText(/Deploy the container/)).resolves.toBeEnabled(); diff --git a/app/react/common/stacks/ItemView/.keep b/app/react/common/stacks/ItemView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/common/stacks/ItemView/StackDuplicationForm/EnvSelector.test.tsx b/app/react/common/stacks/ItemView/StackDuplicationForm/EnvSelector.test.tsx new file mode 100644 index 000000000..9335f2f69 --- /dev/null +++ b/app/react/common/stacks/ItemView/StackDuplicationForm/EnvSelector.test.tsx @@ -0,0 +1,196 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; + +import { server } from '@/setup-tests/server'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { Environment } from '@/react/portainer/environments/types'; +import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types'; + +import { EnvSelector, getEnvironmentOptions } from './EnvSelector'; + +describe('EnvSelector', () => { + it('should render when environment options are available', async () => { + const mockEnvironments: Environment[] = [ + { + Id: 1, + Name: 'Environment 1', + GroupId: 1, + } as Environment, + { + Id: 2, + Name: 'Environment 2', + GroupId: 1, + } as Environment, + ]; + + const mockGroups: EnvironmentGroup[] = [ + { + Id: 1, + Name: 'Unassigned', + } as EnvironmentGroup, + ]; + + renderComponent({ + environments: mockEnvironments, + groups: mockGroups, + }); + await waitFor(() => { + // render select + const select = screen.getByRole('combobox'); + expect(select).toBeVisible(); + + // placeholder text + expect(screen.getByText('Select an environment')).toBeVisible(); + + // no error displayed + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + // data-cy + expect( + screen.getByTestId('stack-duplicate-environment-select') + ).toBeInTheDocument(); + }); + }); + + it('should return null when no environment options exist', async () => { + const { container } = renderComponent(); + + await waitFor(() => { + expect(container.firstChild).toBeNull(); + }); + }); + + it('should display FormError when error prop is provided', async () => { + const mockEnvironments: Environment[] = [ + { + Id: 1, + Name: 'Environment 1', + GroupId: 1, + } as Environment, + ]; + + const mockGroups: EnvironmentGroup[] = [ + { + Id: 1, + Name: 'Group 1', + } as EnvironmentGroup, + ]; + + const error = 'Environment is required'; + + renderComponent({ + environments: mockEnvironments, + groups: mockGroups, + error, + }); + + await waitFor(() => { + expect(screen.getByRole('alert', { name: error })).toBeVisible(); + }); + }); + + function renderComponent({ + environments = [], + groups = [], + onChange = vi.fn(), + error, + }: { + environments?: Environment[]; + groups?: EnvironmentGroup[]; + onChange?: (value: number | undefined) => void; + error?: string; + } = {}) { + const Component = withTestQueryProvider(EnvSelector); + + server.use( + http.get('/api/endpoints', () => HttpResponse.json(environments)), + http.get('/api/endpoint_groups', () => HttpResponse.json(groups)) + ); + + return render( + + ); + } +}); + +describe('getEnvironmentOptions', () => { + it('should return empty array when no data provided', () => { + expect(getEnvironmentOptions([], [])).toEqual([]); + + expect( + getEnvironmentOptions( + [ + { + Id: 1, + Name: 'Group 1', + } as EnvironmentGroup, + ], + [] + ) + ).toEqual([]); + }); + + it('should exclude current environment when currentEnvironmentId is provided', () => { + const groups: EnvironmentGroup[] = [ + { Id: 1, Name: 'Group 1' } as EnvironmentGroup, + ]; + const environments: Environment[] = [ + { Id: 1, Name: 'Env 1', GroupId: 1 } as Environment, + { Id: 2, Name: 'Env 2', GroupId: 1 } as Environment, + ]; + + const result = getEnvironmentOptions(groups, environments, 1); + + expect(result).toHaveLength(1); + expect(result[0].options).toHaveLength(1); + expect(result[0].options[0]).toEqual({ label: 'Env 2', value: 2 }); + }); + + it('should group environments by GroupId with correct structure', () => { + const groups: EnvironmentGroup[] = [ + { Id: 1, Name: 'Group 1' } as EnvironmentGroup, + { Id: 2, Name: 'Group 2' } as EnvironmentGroup, + ]; + const environments: Environment[] = [ + { Id: 1, Name: 'Env 1', GroupId: 1 } as Environment, + { Id: 2, Name: 'Env 2', GroupId: 1 } as Environment, + { Id: 3, Name: 'Env 3', GroupId: 2 } as Environment, + ]; + + const result = getEnvironmentOptions(groups, environments); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + label: 'Group 1', + options: [ + { label: 'Env 1', value: 1 }, + { label: 'Env 2', value: 2 }, + ], + }); + expect(result[1]).toEqual({ + label: 'Group 2', + options: [{ label: 'Env 3', value: 3 }], + }); + }); + + it('should create "Unassigned" group for GroupId = 1', () => { + const environments: Environment[] = [ + { Id: 1, Name: 'Env 1', GroupId: 1 } as Environment, + ]; + + const result = getEnvironmentOptions([], environments); + + expect(result[0].label).toBe('Unassigned'); + expect(result[0].options[0]).toEqual({ label: 'Env 1', value: 1 }); + }); + + it('should throw error if group is missing for non-unassigned GroupId', () => { + const environments: Environment[] = [ + { Id: 1, Name: 'Env 1', GroupId: 2 } as Environment, + ]; + + expect(() => getEnvironmentOptions([], environments)).toThrow( + 'Missing group with id 2' + ); + }); +}); diff --git a/app/react/common/stacks/ItemView/StackDuplicationForm/EnvSelector.tsx b/app/react/common/stacks/ItemView/StackDuplicationForm/EnvSelector.tsx new file mode 100644 index 000000000..b8c46a20c --- /dev/null +++ b/app/react/common/stacks/ItemView/StackDuplicationForm/EnvSelector.tsx @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; + +import { useEnvironmentList } from '@/react/portainer/environments/queries'; +import { useGroups } from '@/react/portainer/environments/environment-groups/queries'; +import { Environment } from '@/react/portainer/environments/types'; +import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types'; + +import { + PortainerSelect, + GroupOption, +} from '@@/form-components/PortainerSelect'; +import { FormError } from '@@/form-components/FormError'; + +export function EnvSelector({ + value, + onChange, + error, +}: { + value: number | undefined; + onChange: (value: number | undefined) => void; + error?: string; +}) { + const envsQuery = useEnvironmentList(); + const groupsQuery = useGroups(); + + const environmentOptions = useMemo(() => { + if (!envsQuery.environments || !groupsQuery.data) { + return []; + } + + return getEnvironmentOptions(groupsQuery.data, envsQuery.environments); + }, [envsQuery.environments, groupsQuery.data]); + + if (!environmentOptions.length) { + return null; + } + + return ( +
+ + {error && ( +
+ {error} +
+ )} +
+ ); +} + +/** + * Transforms environments and groups into grouped options for PortainerSelect + */ +export function getEnvironmentOptions( + groups: EnvironmentGroup[], + environments: Environment[], + currentEnvironmentId?: number +): GroupOption[] { + if (!groups || !environments) { + return []; + } + + // Group environments by their GroupId + const groupedEnvironments = environments.reduce< + Record> + >((acc, environment) => { + if (environment.Id === currentEnvironmentId) { + return acc; + } + + const groupId = environment.GroupId; + if (!acc[groupId]) { + acc[groupId] = []; + } + acc[groupId].push({ + label: environment.Name, + value: environment.Id, + }); + return acc; + }, {}); + + return Object.entries(groupedEnvironments).map(([groupId, envOptions]) => { + const parsedGroupId = parseInt(groupId, 10); + const group = groups.find((g) => g.Id === parsedGroupId); + if (!group && parsedGroupId !== 1) { + throw new Error(`Missing group with id ${groupId}`); + } + + return { + label: group?.Name || 'Unassigned', + options: envOptions, + }; + }); +} diff --git a/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.test.tsx b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.test.tsx new file mode 100644 index 000000000..2b232fcf2 --- /dev/null +++ b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.test.tsx @@ -0,0 +1,105 @@ +import { render, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { UIRouterContext, UIRouterReact } from '@uirouter/react'; + +import { server } from '@/setup-tests/server'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { Environment } from '@/react/portainer/environments/types'; +import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types'; + +import { Stack, StackType } from '../../types'; + +import { StackDuplicationForm } from './StackDuplicationForm'; + +it('should render Widget with title and inner component', async () => { + const { getByText } = renderComponent(); + + expect(getByText('Stack duplication / migration')).toBeVisible(); + await waitFor(() => { + expect( + getByText('This feature allows you to duplicate or migrate this stack.') + ).toBeVisible(); + }); +}); + +it('should initialize form with empty name and no selected environment', async () => { + const { getByPlaceholderText, getByRole } = renderComponent(); + + await waitFor(() => { + expect( + getByPlaceholderText('Stack name (optional for migration)') + ).toHaveValue(''); + expect(getByRole('button', { name: /migrate/i })).toBeDisabled(); + expect(getByRole('button', { name: /duplicate/i })).toBeDisabled(); + }); +}); + +function createMockStack(overrides?: Partial): Stack { + return { + Id: 1, + Name: 'test-stack', + Type: StackType.DockerCompose, + EndpointId: 1, + SwarmId: '', + EntryPoint: 'docker-compose.yml', + Env: [{ name: 'VAR1', value: 'value1' }], + Status: 1, + ProjectPath: '/data/compose/1', + CreationDate: Date.now(), + CreatedBy: 'admin', + UpdateDate: Date.now(), + UpdatedBy: 'admin', + FromAppTemplate: false, + IsComposeFormat: true, + SupportRelativePath: false, + FilesystemPath: '/data/compose/1', + StackFileVersion: '3.7', + PreviousDeploymentInfo: null, + ...overrides, + }; +} + +function renderComponent({ + stack = createMockStack(), + currentEnvironmentId = 1, + yamlError, + originalFileContent = 'version: "3"\nservices:\n app:\n image: nginx', +}: { + stack?: Stack; + currentEnvironmentId?: number; + yamlError?: string; + originalFileContent?: string; +} = {}) { + const mockEnvironments: Environment[] = [ + { Id: 1, Name: 'Current Environment', GroupId: 1 } as Environment, + { Id: 2, Name: 'Target Environment', GroupId: 1 } as Environment, + ]; + + const mockGroups: EnvironmentGroup[] = [ + { Id: 1, Name: 'Unassigned' } as EnvironmentGroup, + ]; + + server.use( + http.get('/api/endpoints', () => HttpResponse.json(mockEnvironments)), + http.get('/api/endpoint_groups', () => HttpResponse.json(mockGroups)) + ); + + const mockRouter = { + stateService: { + go: vi.fn(), + }, + } as unknown as UIRouterReact; + + const Component = withTestQueryProvider(() => ( + + + + )); + + return { ...render(), mockRouter }; +} diff --git a/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.tsx b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.tsx new file mode 100644 index 000000000..19a921a11 --- /dev/null +++ b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.tsx @@ -0,0 +1,169 @@ +import { Formik } from 'formik'; +import { Copy } from 'lucide-react'; +import { useRouter } from '@uirouter/react'; + +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; + +import { Widget } from '@@/Widget'; +import { WidgetBody } from '@@/Widget/WidgetBody'; +import { WidgetTitle } from '@@/Widget/WidgetTitle'; +import { validateForm } from '@@/form-components/validate-form'; +import { confirm } from '@@/modals/confirm'; +import { ModalType } from '@@/modals'; +import { buildConfirmButton } from '@@/modals/utils'; + +import { Stack } from '../../types'; + +import { FormSubmitValues } from './StackDuplicationForm.types'; +import { StackDuplicationFormInner } from './StackDuplicationFormInner'; +import { + getBaseValidationSchema, + getDuplicateValidationSchema, + getMigrateValidationSchema, +} from './StackDuplicationForm.validation'; +import { useDuplicateStackMutation } from './useDuplicateStackMutation'; +import { useMigrateStackMutation } from './useMigrateStackMutation'; + +interface StackDuplicationFormProps { + currentEnvironmentId: number; + + yamlError?: string; + + originalFileContent: string; + stack: Stack; +} + +export function StackDuplicationForm({ + yamlError, + originalFileContent, + currentEnvironmentId, + stack, +}: StackDuplicationFormProps) { + const router = useRouter(); + const duplicateMutation = useDuplicateStackMutation(); + const migrateMutation = useMigrateStackMutation(); + const initialValues: FormSubmitValues = { + environmentId: undefined, + newName: '', + actionType: 'migrate', // Default value, will be set by button clicks + }; + + return ( + + + + + + + + + ); + + async function handleSubmit(values: FormSubmitValues) { + const { actionType, environmentId, newName } = values; + + switch (actionType) { + case 'duplicate': + await handleDuplicate(environmentId!, newName); + break; + case 'migrate': + await handleMigrate(environmentId!, newName); + break; + + default: + break; + } + } + + async function handleDuplicate(environmentId: number, name: string) { + const schema = getDuplicateValidationSchema(); + const errors = await validateForm(() => schema, { environmentId, name }); + if (errors) { + notifyError( + 'Validation Error', + undefined, + 'Please fix the errors and try again.' + ); + return; + } + + duplicateMutation.mutate( + { + fileContent: originalFileContent, + name, + type: stack.Type, + env: stack.Env, + targetEnvironmentId: environmentId, + }, + { + onSuccess() { + notifySuccess('Success', 'Stack successfully duplicated'); + router.stateService.go('docker.stacks', {}, { reload: true }); + }, + onError(error) { + notifyError('Failure', error as Error, 'Unable to duplicate stack'); + }, + } + ); + } + + async function handleMigrate( + environmentId: number, + name: string | undefined + ) { + const confirmed = await confirm({ + title: 'Are you sure?', + modalType: ModalType.Warn, + message: + 'This action will deploy a new instance of this stack on the target environment, please note that this does NOT relocate the content of any persistent volumes that may be attached to this stack.', + confirmButton: buildConfirmButton('Migrate', 'danger'), + }); + + if (!confirmed) { + return; + } + + const schema = getMigrateValidationSchema(currentEnvironmentId); + const errors = await validateForm(() => schema, { + environmentId, + name, + }); + + if (errors) { + notifyError( + 'Validation Error', + undefined, + 'Please fix the errors and try again.' + ); + return; + } + + migrateMutation.mutate( + { + name, + stackType: stack.Type, + fromEnvId: currentEnvironmentId, + id: stack.Id, + targetEnvId: environmentId, + fromSwarmId: stack.SwarmId, + }, + { + onSuccess() { + notifySuccess('Stack successfully migrated', name || stack.Name); + router.stateService.go('docker.stacks', {}, { reload: true }); + }, + onError(error) { + notifyError('Failure', error as Error, 'Unable to migrate stack'); + }, + } + ); + } +} diff --git a/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.types.ts b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.types.ts new file mode 100644 index 000000000..a8580657c --- /dev/null +++ b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.types.ts @@ -0,0 +1,10 @@ +export interface FormValues { + environmentId: number | undefined; + newName: string; +} + +export type ActionType = 'duplicate' | 'migrate'; + +export interface FormSubmitValues extends FormValues { + actionType: ActionType; +} diff --git a/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.validation.test.ts b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.validation.test.ts new file mode 100644 index 000000000..d0f814364 --- /dev/null +++ b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.validation.test.ts @@ -0,0 +1,347 @@ +import { waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import type { AnySchema } from 'yup'; + +import { + getBaseValidationSchema, + getDuplicateValidationSchema, + getMigrateValidationSchema, + useValidation, +} from './StackDuplicationForm.validation'; +import { FormSubmitValues } from './StackDuplicationForm.types'; + +describe('getDuplicateValidationSchema', () => { + const schema = getDuplicateValidationSchema(); + + describe('name validation', () => { + it('should require stack name', async () => { + await expect( + schema.validate({ name: '', environmentId: 2 }) + ).rejects.toThrow('Stack name is required'); + }); + + it.each([ + ['lowercase alphanumeric', 'mystack123'], + ['with underscores', 'my_stack'], + ['with hyphens', 'my-stack'], + ['with underscores and hyphens', 'my_stack-123'], + ])('should accept valid names: %s', async (_, name) => { + await expect( + schema.validate({ name, environmentId: 2 }) + ).resolves.toBeTruthy(); + }); + + it.each([ + ['uppercase letters', 'MyStack'], + ['spaces', 'my stack'], + ['special characters', 'my@stack'], + ])('should reject names with %s', async (_, name) => { + await expect(schema.validate({ name, environmentId: 2 })).rejects.toThrow( + "Stack name must consist of lower case alphanumeric characters, '_' or '-'" + ); + }); + }); + + describe('environmentId validation', () => { + testEnvironmentIdValidation(schema); + }); +}); + +describe('getMigrateValidationSchema', () => { + const currentEnvironmentId = 1; + const schema = getMigrateValidationSchema(currentEnvironmentId); + + describe('name validation (optional)', () => { + it.each([ + ['empty string', ''], + ['undefined', undefined], + ])('should accept %s', async (_, name) => { + await expect( + schema.validate({ name, environmentId: 2 }) + ).resolves.toBeTruthy(); + }); + + it.each([ + ['valid format', 'mystack'], + ['with underscores and hyphens', 'my_stack-123'], + ])('should accept valid name when provided: %s', async (_, name) => { + await expect( + schema.validate({ name, environmentId: 2 }) + ).resolves.toBeTruthy(); + }); + + it.each([ + ['uppercase letters', 'MyStack'], + ['special characters', 'my@stack'], + ])('should reject invalid format when provided: %s', async (_, name) => { + await expect(schema.validate({ name, environmentId: 2 })).rejects.toThrow( + "Stack name must consist of lower case alphanumeric characters, '_' or '-'" + ); + }); + }); + + describe('environmentId validation', () => { + testEnvironmentIdValidation(schema, currentEnvironmentId); + }); +}); + +describe('getBaseValidationSchema', () => { + const schema = getBaseValidationSchema(); + + describe('name validation (optional)', () => { + it.each([ + ['empty string', ''], + ['undefined', undefined], + ])('should accept %s', async (_, name) => { + await expect( + schema.validate({ name, environmentId: 2 }) + ).resolves.toBeTruthy(); + }); + + it.each([ + ['valid format', 'mystack'], + ['with underscores and hyphens', 'my_stack-123'], + ])('should accept valid name when provided: %s', async (_, name) => { + await expect( + schema.validate({ name, environmentId: 2 }) + ).resolves.toBeTruthy(); + }); + + it.each([ + ['uppercase letters', 'MyStack'], + ['special characters', 'my@stack'], + ])('should reject invalid format when provided: %s', async (_, name) => { + await expect(schema.validate({ name, environmentId: 2 })).rejects.toThrow( + "Stack name must consist of lower case alphanumeric characters, '_' or '-'" + ); + }); + }); + + describe('environmentId validation', () => { + testEnvironmentIdValidation(schema); + }); +}); + +describe('useValidation', () => { + const currentEnvironmentId = 1; + + it('should start with both migrate and duplicate as false', () => { + const { result } = renderHook(() => + useValidation({ + values: { + environmentId: undefined, + newName: '', + actionType: 'migrate', + }, + currentEnvironmentId, + }) + ); + + expect(result.current.migrate).toBe(false); + expect(result.current.duplicate).toBe(false); + }); + + describe('migrate validation state', () => { + it('should set migrate to true when valid environmentId is selected', async () => { + const { result } = renderHook(() => + useValidation({ + values: { + environmentId: 2, + newName: '', + actionType: 'migrate', + }, + currentEnvironmentId, + }) + ); + + await waitFor(() => { + expect(result.current.migrate).toBe(true); + }); + }); + + it('should set migrate to true when valid environmentId and valid name provided', async () => { + const { result } = renderHook(() => + useValidation({ + values: { + environmentId: 2, + newName: 'mystack', + actionType: 'migrate', + }, + currentEnvironmentId, + }) + ); + + await waitFor(() => { + expect(result.current.migrate).toBe(true); + }); + }); + + it.each([ + ['matches current environment', currentEnvironmentId, ''], + ['is undefined', undefined, ''], + ['has invalid name format', 2, 'InvalidName'], + ])( + 'should set migrate to false when environmentId %s', + async (_, environmentId, newName) => { + const { result } = renderHook(() => + useValidation({ + values: { + environmentId, + newName, + actionType: 'migrate', + }, + currentEnvironmentId, + }) + ); + + await waitFor(() => { + expect(result.current.migrate).toBe(false); + }); + } + ); + }); + + describe('duplicate validation state', () => { + it('should set duplicate to true when valid name and environmentId provided', async () => { + const { result } = renderHook(() => + useValidation({ + values: { + environmentId: 2, + newName: 'mystack', + actionType: 'duplicate', + }, + currentEnvironmentId, + }) + ); + + await waitFor(() => { + expect(result.current.duplicate).toBe(true); + }); + }); + + it.each([ + ['name is empty', 2, ''], + ['name format is invalid', 2, 'Invalid@Name'], + [ + 'environmentId matches current environment', + currentEnvironmentId, + 'mystack', + ], + ['environmentId is undefined', undefined, 'mystack'], + ])( + 'should set duplicate to false when %s', + async (_, environmentId, newName) => { + const { result } = renderHook(() => + useValidation({ + values: { + environmentId, + newName, + actionType: 'duplicate', + }, + currentEnvironmentId, + }) + ); + + await waitFor(() => { + expect(result.current.duplicate).toBe(false); + }); + } + ); + }); + + describe('reactive updates', () => { + it('should revalidate when environmentId changes', async () => { + const { result, rerender } = renderHook( + ({ values }: { values: FormSubmitValues }) => + useValidation({ values, currentEnvironmentId }), + { + initialProps: { + values: { + environmentId: undefined as number | undefined, + newName: 'mystack', + actionType: 'duplicate' as const, + }, + }, + } + ); + + await waitFor(() => { + expect(result.current.duplicate).toBe(false); + }); + + rerender({ + values: { + environmentId: 2, + newName: 'mystack', + actionType: 'duplicate', + }, + }); + + await waitFor(() => { + expect(result.current.duplicate).toBe(true); + }); + }); + + it('should revalidate when newName changes', async () => { + const { result, rerender } = renderHook( + ({ values }: { values: FormSubmitValues }) => + useValidation({ values, currentEnvironmentId }), + { + initialProps: { + values: { + environmentId: 2, + newName: '', + actionType: 'duplicate' as const, + }, + }, + } + ); + + await waitFor(() => { + expect(result.current.duplicate).toBe(false); + }); + + rerender({ + values: { + environmentId: 2, + newName: 'mystack', + actionType: 'duplicate', + }, + }); + + await waitFor(() => { + expect(result.current.duplicate).toBe(true); + }); + }); + }); +}); + +function testEnvironmentIdValidation( + schema: AnySchema, + currentEnvironmentId?: number +) { + it('should require environmentId', async () => { + await expect( + schema.validate({ name: 'mystack', environmentId: undefined }) + ).rejects.toThrow('Target environment must be selected'); + }); + + if (currentEnvironmentId !== undefined) { + it('should reject environmentId that matches currentEnvironmentId', async () => { + await expect( + schema.validate({ + name: 'mystack', + environmentId: currentEnvironmentId, + }) + ).rejects.toThrow( + 'Target environment must be different from the current environment' + ); + }); + } + + it('should accept environmentId different from currentEnvironmentId', async () => { + await expect( + schema.validate({ name: 'mystack', environmentId: 2 }) + ).resolves.toBeTruthy(); + }); +} diff --git a/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.validation.ts b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.validation.ts new file mode 100644 index 000000000..6aded3f8f --- /dev/null +++ b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm.validation.ts @@ -0,0 +1,94 @@ +import { object, string, number } from 'yup'; +import { useEffect, useState } from 'react'; + +import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { validateForm } from '@@/form-components/validate-form'; + +import { FormSubmitValues } from './StackDuplicationForm.types'; + +/** + * since this form has two actions, we need to manage separate validation state. Ideally we would use separate forms + */ +export function useValidation({ + values, + currentEnvironmentId, +}: { + values: FormSubmitValues; + currentEnvironmentId: EnvironmentId; +}) { + const [validState, setValidState] = useState({ + migrate: false, + duplicate: false, + }); + + useEffect(() => { + async function validateSchemas() { + const migrateSchema = getMigrateValidationSchema(currentEnvironmentId); + + const migrateErrors = await validateForm(() => migrateSchema, { + environmentId: values.environmentId || undefined, + name: values.newName, + }); + + setValidState((state) => ({ ...state, migrate: !migrateErrors })); + } + + validateSchemas(); + }, [values.environmentId, values.newName, currentEnvironmentId]); + + useEffect(() => { + async function validateSchema() { + const duplicateSchema = getDuplicateValidationSchema(); + const duplicateErrors = await validateForm(() => duplicateSchema, { + environmentId: values.environmentId || undefined, + name: values.newName, + }); + + setValidState((state) => ({ ...state, duplicate: !duplicateErrors })); + } + validateSchema(); + }, [values.environmentId, values.newName, currentEnvironmentId]); + + return validState; +} + +const regexp = new RegExp(STACK_NAME_VALIDATION_REGEX); + +const baseNameValidation = string().test( + 'valid-format-if-provided', + "Stack name must consist of lower case alphanumeric characters, '_' or '-'", + (value) => !value || regexp.test(value) +); + +const baseEnvValidation = number().required( + 'Target environment must be selected' +); + +export function getBaseValidationSchema() { + return object({ + name: baseNameValidation, + environmentId: baseEnvValidation, + }); +} + +export function getDuplicateValidationSchema() { + return object({ + name: baseNameValidation.required('Stack name is required'), + environmentId: baseEnvValidation, + }); +} + +export function getMigrateValidationSchema( + currentEnvironmentId: EnvironmentId | undefined +) { + return object({ + name: baseNameValidation, + environmentId: baseEnvValidation.test( + 'not-same-as-current', + 'Target environment must be different from the current environment', + (value) => value !== currentEnvironmentId + ), + }); +} diff --git a/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationFormInner.test.tsx b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationFormInner.test.tsx new file mode 100644 index 000000000..6680568bf --- /dev/null +++ b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationFormInner.test.tsx @@ -0,0 +1,378 @@ +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Formik } from 'formik'; +import { http, HttpResponse } from 'msw'; + +import { server } from '@/setup-tests/server'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { Environment } from '@/react/portainer/environments/types'; +import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types'; + +import { StackDuplicationFormInner } from './StackDuplicationFormInner'; +import { FormSubmitValues } from './StackDuplicationForm.types'; + +describe('StackDuplicationFormInner', () => { + describe('initial rendering', () => { + it('should render form with description text', () => { + const { getByText } = renderFormInner(); + + expect( + getByText('This feature allows you to duplicate or migrate this stack.') + ).toBeVisible(); + }); + + it('should render stack name input field', async () => { + const { getByPlaceholderText } = renderFormInner(); + + await waitFor(() => { + const input = getByPlaceholderText( + 'Stack name (optional for migration)' + ); + expect(input).toBeVisible(); + }); + }); + + it('should render Migrate button', () => { + const { getByRole } = renderFormInner(); + + const migrateButton = getByRole('button', { name: /migrate/i }); + expect(migrateButton).toBeVisible(); + }); + + it('should render Duplicate button', () => { + const { getByRole } = renderFormInner(); + + const duplicateButton = getByRole('button', { name: /duplicate/i }); + expect(duplicateButton).toBeVisible(); + }); + + it('should have correct data-cy attributes', async () => { + const { container } = renderFormInner(); + + await waitFor(() => { + expect( + container.querySelector('[data-cy="stack-duplicate-name-input"]') + ).toBeInTheDocument(); + expect( + container.querySelector('[data-cy="stack-migrate-button"]') + ).toBeInTheDocument(); + expect( + container.querySelector('[data-cy="stack-duplicate-button"]') + ).toBeInTheDocument(); + }); + }); + }); + + describe('button states - migrate', () => { + it('should disable Migrate button when no environment is selected', async () => { + const { getByRole } = renderFormInner(); + + await waitFor(() => { + const migrateButton = getByRole('button', { name: /migrate/i }); + expect(migrateButton).toBeDisabled(); + }); + }); + + it('should enable Migrate button when valid environment is selected', async () => { + const { getByRole } = renderFormInner({ + initialValues: { + environmentId: 2, + newName: '', + actionType: 'migrate', + }, + }); + + await waitFor(() => { + const migrateButton = getByRole('button', { name: /migrate/i }); + expect(migrateButton).toBeEnabled(); + }); + }); + + it('should disable Migrate button when environmentId matches current environment', async () => { + const { getByRole } = renderFormInner({ + initialValues: { + environmentId: 1, + newName: '', + actionType: 'migrate', + }, + currentEnvironmentId: 1, + }); + + await waitFor(() => { + const migrateButton = getByRole('button', { name: /migrate/i }); + expect(migrateButton).toBeDisabled(); + }); + }); + }); + + describe('button states - duplicate', () => { + it('should disable Duplicate button when name is empty', async () => { + const { getByRole } = renderFormInner({ + initialValues: { + environmentId: 2, + newName: '', + actionType: 'duplicate', + }, + }); + + await waitFor(() => { + const duplicateButton = getByRole('button', { name: /duplicate/i }); + expect(duplicateButton).toBeDisabled(); + }); + }); + + it('should disable Duplicate button when no environment is selected', async () => { + const { getByRole } = renderFormInner({ + initialValues: { + environmentId: undefined, + newName: 'mystack', + actionType: 'duplicate', + }, + }); + + await waitFor(() => { + const duplicateButton = getByRole('button', { name: /duplicate/i }); + expect(duplicateButton).toBeDisabled(); + }); + }); + + it('should disable Duplicate button when yamlError is present', async () => { + const { getByRole } = renderFormInner({ + yamlError: 'Invalid YAML', + initialValues: { + environmentId: 2, + newName: 'mystack', + actionType: 'duplicate', + }, + }); + + await waitFor(() => { + const duplicateButton = getByRole('button', { name: /duplicate/i }); + expect(duplicateButton).toBeDisabled(); + }); + }); + + it('should enable Duplicate button when valid name and environment selected and no yamlError', async () => { + const { getByRole } = renderFormInner({ + initialValues: { + environmentId: 2, + newName: 'mystack', + actionType: 'duplicate', + }, + }); + + await waitFor(() => { + const duplicateButton = getByRole('button', { name: /duplicate/i }); + expect(duplicateButton).toBeEnabled(); + }); + }); + }); + + describe('form interactions', () => { + it('should update newName field when user types', async () => { + const { getByPlaceholderText } = renderFormInner(); + const user = userEvent.setup(); + + await waitFor(() => { + const input = getByPlaceholderText( + 'Stack name (optional for migration)' + ); + expect(input).toBeVisible(); + }); + + const input = getByPlaceholderText('Stack name (optional for migration)'); + await user.type(input, 'mystack'); + + expect(input).toHaveValue('mystack'); + }); + + it('should display FormError for newName when validation error exists', async () => { + const { getByText } = renderFormInner(); + + // Formik with validation schema will show errors + // This test demonstrates the error display mechanism + // In a real scenario, validation would trigger after user interaction + await waitFor(() => { + const form = getByText( + 'This feature allows you to duplicate or migrate this stack.' + ); + expect(form).toBeVisible(); + }); + }); + }); + + describe('action handlers', () => { + it('should call onSubmit with actionType "migrate" when Migrate button clicked', async () => { + const onSubmit = vi.fn(); + const { getByRole } = renderFormInner({ + onSubmit, + initialValues: { + environmentId: 2, + newName: '', + actionType: 'migrate', + }, + }); + const user = userEvent.setup(); + + await waitFor(() => { + const migrateButton = getByRole('button', { name: /migrate/i }); + expect(migrateButton).toBeEnabled(); + }); + + const migrateButton = getByRole('button', { name: /migrate/i }); + await user.click(migrateButton); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + actionType: 'migrate', + environmentId: 2, + newName: '', + }), + expect.anything() + ); + }); + }); + + it('should call onSubmit with actionType "duplicate" when Duplicate button clicked', async () => { + const onSubmit = vi.fn(); + const { getByRole } = renderFormInner({ + onSubmit, + initialValues: { + environmentId: 2, + newName: 'mystack', + actionType: 'duplicate', + }, + }); + const user = userEvent.setup(); + + await waitFor(() => { + const duplicateButton = getByRole('button', { name: /duplicate/i }); + expect(duplicateButton).toBeEnabled(); + }); + + const duplicateButton = getByRole('button', { name: /duplicate/i }); + await user.click(duplicateButton); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + actionType: 'duplicate', + environmentId: 2, + newName: 'mystack', + }), + expect.anything() + ); + }); + }); + }); + + describe('YAML error display', () => { + it('should display yamlError when environment is selected and error exists', async () => { + const yamlError = 'Invalid YAML format'; + const { getByText } = renderFormInner({ + yamlError, + initialValues: { + environmentId: 2, + newName: 'mystack', + actionType: 'duplicate', + }, + }); + + await waitFor(() => { + const errorElement = getByText(yamlError); + expect(errorElement).toBeVisible(); + expect(errorElement).toHaveClass('text-danger'); + }); + }); + + it('should not display yamlError when no environment is selected', () => { + const yamlError = 'Invalid YAML format'; + const { queryByText } = renderFormInner({ + yamlError, + initialValues: { + environmentId: undefined, + newName: 'mystack', + actionType: 'duplicate', + }, + }); + + const errorElement = queryByText(yamlError); + expect(errorElement).toBeNull(); + }); + + it('should not display yamlError when no error exists', async () => { + const { container } = renderFormInner({ + initialValues: { + environmentId: 2, + newName: 'mystack', + actionType: 'duplicate', + }, + }); + + await waitFor(() => { + const errorElements = container.querySelectorAll('.text-danger'); + expect(errorElements).toHaveLength(0); + }); + }); + + it('should display error in red text (text-danger class)', async () => { + const yamlError = 'Invalid YAML format'; + const { getByText } = renderFormInner({ + yamlError, + initialValues: { + environmentId: 2, + newName: 'mystack', + actionType: 'duplicate', + }, + }); + + await waitFor(() => { + const errorElement = getByText(yamlError); + expect(errorElement).toHaveClass('text-danger'); + expect(errorElement).toHaveClass('small'); + }); + }); + }); +}); + +function renderFormInner({ + yamlError, + currentEnvironmentId = 1, + onSubmit = vi.fn(), + initialValues = { + environmentId: undefined, + newName: '', + actionType: 'migrate' as const, + }, +}: { + yamlError?: string; + currentEnvironmentId?: number; + onSubmit?: (values: FormSubmitValues) => void | Promise; + initialValues?: FormSubmitValues; +} = {}) { + const mockEnvironments: Environment[] = [ + { Id: 1, Name: 'Current Environment', GroupId: 1 } as Environment, + { Id: 2, Name: 'Target Environment', GroupId: 1 } as Environment, + ]; + + const mockGroups: EnvironmentGroup[] = [ + { Id: 1, Name: 'Unassigned' } as EnvironmentGroup, + ]; + + server.use( + http.get('/api/endpoints', () => HttpResponse.json(mockEnvironments)), + http.get('/api/endpoint_groups', () => HttpResponse.json(mockGroups)) + ); + + const Component = withTestQueryProvider(() => ( + + + + )); + + return render(); +} diff --git a/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationFormInner.tsx b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationFormInner.tsx new file mode 100644 index 000000000..ac552c288 --- /dev/null +++ b/app/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationFormInner.tsx @@ -0,0 +1,122 @@ +import { useState } from 'react'; +import { Field, Form, useFormikContext } from 'formik'; +import { Copy, ArrowRight } from 'lucide-react'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { LoadingButton } from '@@/buttons/LoadingButton'; +import { Input } from '@@/form-components/Input'; +import { FormError } from '@@/form-components/FormError'; + +import { FormSubmitValues, ActionType } from './StackDuplicationForm.types'; +import { useValidation } from './StackDuplicationForm.validation'; +import { EnvSelector } from './EnvSelector'; + +interface Props { + yamlError?: string; + currentEnvironmentId: EnvironmentId; +} + +export function StackDuplicationFormInner({ + yamlError, + currentEnvironmentId, +}: Props) { + const { values, errors, setFieldValue, submitForm, isSubmitting } = + useFormikContext(); + + const validState = useValidation({ + values, + currentEnvironmentId, + }); + + const [actionType, setActionType] = useState(null); + + const isEnvSelected = !!values.environmentId; + + async function handleAction(type: ActionType) { + setActionType(type); + // Set the actionType in form values before submitting + await setFieldValue('actionType', type); + await submitForm(); + } + + const isMigrateInProgress = isSubmitting && actionType === 'migrate'; + const isDuplicateInProgress = isSubmitting && actionType === 'duplicate'; + + const isMigrateDisabled = isSubmitting || !validState.migrate; + const isDuplicateDisabled = + isSubmitting || !validState.duplicate || !!yamlError; + + return ( +
+
+ +

+ This feature allows you to duplicate or migrate this stack. +

+
+
+ +
+ + {errors.newName && ( +
+ {errors.newName} +
+ )} +
+ + setFieldValue('environmentId', value)} + value={values.environmentId} + error={errors.environmentId} + /> + +
+ handleAction('migrate')} + icon={ArrowRight} + data-cy="stack-migrate-button" + className="!ml-0" + > + Migrate + + + handleAction('duplicate')} + icon={Copy} + data-cy="stack-duplicate-button" + > + Duplicate + +
+ + {yamlError && isEnvSelected && ( +
+
+ {yamlError} +
+
+ )} + + ); +} diff --git a/app/react/common/stacks/ItemView/StackDuplicationForm/useDuplicateStackMutation.test.tsx b/app/react/common/stacks/ItemView/StackDuplicationForm/useDuplicateStackMutation.test.tsx new file mode 100644 index 000000000..60b44e0e5 --- /dev/null +++ b/app/react/common/stacks/ItemView/StackDuplicationForm/useDuplicateStackMutation.test.tsx @@ -0,0 +1,277 @@ +import { http, HttpResponse } from 'msw'; + +import { server } from '@/setup-tests/server'; + +import { StackType } from '../../types'; + +import { duplicateStack } from './useDuplicateStackMutation'; + +type StackRequestBody = { + name: string; + swarmID: string; + env?: Array<{ name: string; value: string }>; +}; + +describe('Swarm stack duplication', () => { + it('should call getSwarm with targetEnvironmentId for Swarm stacks', async () => { + const swarmId = 'swarm123'; + let swarmRequestCalled = false; + + server.use( + http.get('/api/endpoints/:id/docker/swarm', ({ params }) => { + swarmRequestCalled = true; + expect(params.id).toBe('2'); + return HttpResponse.json({ ID: swarmId }); + }), + http.post('/api/stacks/create/:type/:method', async () => + HttpResponse.json({ Id: 123 }) + ) + ); + + await duplicateStack({ + name: 'test-stack', + fileContent: 'version: "3"\nservices:\n app:\n image: nginx', + targetEnvironmentId: 2, + type: StackType.DockerSwarm, + env: [{ name: 'VAR1', value: 'value1' }], + }); + + expect(swarmRequestCalled).toBe(true); + }); + + it('should call createSwarmStackFromFileContent with correct parameters', async () => { + const swarmId = 'swarm123'; + let stackRequestBody: undefined | StackRequestBody; + + server.use( + http.get('/api/endpoints/:id/docker/swarm', () => + HttpResponse.json({ ID: swarmId }) + ), + http.post('/api/stacks/create/:type/:method', async ({ request }) => { + stackRequestBody = (await request.json()) as StackRequestBody; + + return HttpResponse.json({ Id: 123 }); + }) + ); + + const fileContent = 'version: "3"\nservices:\n app:\n image: nginx'; + const env = [{ name: 'VAR1', value: 'value1' }]; + + await duplicateStack({ + name: 'test-stack', + fileContent, + targetEnvironmentId: 2, + type: StackType.DockerSwarm, + env, + }); + + expect(stackRequestBody).toBeDefined(); + expect(stackRequestBody?.name).toBe('test-stack'); + expect(stackRequestBody?.swarmID).toBe(swarmId); + }); + + it('should throw error if Swarm ID is missing', async () => { + server.use( + http.get('/api/endpoints/:id/docker/swarm', () => + HttpResponse.json({ ID: '' }) + ) + ); + + await expect( + duplicateStack({ + name: 'test-stack', + fileContent: 'version: "3"\nservices:\n app:\n image: nginx', + targetEnvironmentId: 2, + type: StackType.DockerSwarm, + }) + ).rejects.toThrow('Swarm ID is required'); + }); + + it('should pass swarmID from getSwarm response', async () => { + const swarmId = 'custom-swarm-id-456'; + let stackRequestBody: StackRequestBody | undefined; + + server.use( + http.get('/api/endpoints/:id/docker/swarm', () => + HttpResponse.json({ ID: swarmId }) + ), + http.post('/api/stacks/create/:type/:method', async ({ request }) => { + stackRequestBody = (await request.json()) as StackRequestBody; + return HttpResponse.json({ Id: 123 }); + }) + ); + + await duplicateStack({ + name: 'test-stack', + fileContent: 'version: "3"\nservices:\n app:\n image: nginx', + targetEnvironmentId: 2, + type: StackType.DockerSwarm, + }); + + expect(stackRequestBody?.swarmID).toBe(swarmId); + }); + + it('should pass environmentId, name, stackFileContent, env', async () => { + const swarmId = 'swarm123'; + let stackRequestBody: StackRequestBody | undefined; + + server.use( + http.get('/api/endpoints/:id/docker/swarm', () => + HttpResponse.json({ ID: swarmId }) + ), + http.post('/api/stacks/create/:type/:method', async ({ request }) => { + stackRequestBody = (await request.json()) as StackRequestBody; + return HttpResponse.json({ Id: 123 }); + }) + ); + + const fileContent = 'version: "3"\nservices:\n app:\n image: nginx'; + const env = [ + { name: 'VAR1', value: 'value1' }, + { name: 'VAR2', value: 'value2' }, + ]; + + await duplicateStack({ + name: 'my-swarm-stack', + fileContent, + targetEnvironmentId: 3, + type: StackType.DockerSwarm, + env, + }); + + expect(stackRequestBody).toBeDefined(); + expect(stackRequestBody?.name).toBe('my-swarm-stack'); + expect(stackRequestBody?.env).toEqual(env); + }); +}); + +describe('Standalone stack duplication', () => { + it('should call createStandaloneStackFromFileContent for non-Swarm stacks', async () => { + let stackRequestBody: StackRequestBody | undefined; + + server.use( + http.post('/api/stacks/create/:type/:method', async ({ request }) => { + stackRequestBody = (await request.json()) as StackRequestBody; + return HttpResponse.json({ Id: 123 }); + }) + ); + + await duplicateStack({ + name: 'standalone-stack', + fileContent: 'version: "3"\nservices:\n app:\n image: nginx', + targetEnvironmentId: 2, + type: StackType.DockerCompose, + }); + + expect(stackRequestBody).toBeDefined(); + expect(stackRequestBody?.name).toBe('standalone-stack'); + }); + + it('should pass environmentId, name, stackFileContent, env', async () => { + let stackRequestBody: StackRequestBody | undefined; + + server.use( + http.post('/api/stacks/create/:type/:method', async ({ request }) => { + stackRequestBody = (await request.json()) as StackRequestBody; + return HttpResponse.json({ Id: 123 }); + }) + ); + + const fileContent = 'version: "3"\nservices:\n web:\n image: nginx'; + const env = [{ name: 'PORT', value: '8080' }]; + + await duplicateStack({ + name: 'compose-stack', + fileContent, + targetEnvironmentId: 5, + type: StackType.DockerCompose, + env, + }); + + expect(stackRequestBody).toBeDefined(); + expect(stackRequestBody?.name).toBe('compose-stack'); + expect(stackRequestBody?.env).toEqual(env); + }); + + it('should not call getSwarm for standalone stacks', async () => { + let swarmRequestCalled = false; + + server.use( + http.get('/api/endpoints/:id/docker/swarm', () => { + swarmRequestCalled = true; + return HttpResponse.json({ ID: 'swarm123' }); + }), + http.post('/api/stacks/create/:type/:method', () => + HttpResponse.json({ Id: 123 }) + ) + ); + + await duplicateStack({ + name: 'standalone-stack', + fileContent: 'version: "3"\nservices:\n app:\n image: nginx', + targetEnvironmentId: 2, + type: StackType.DockerCompose, + }); + + expect(swarmRequestCalled).toBe(false); + }); +}); + +describe('error handling', () => { + it('should propagate errors from getSwarm', async () => { + server.use( + http.get('/api/endpoints/:id/docker/swarm', () => + HttpResponse.json({ message: 'Swarm not found' }, { status: 404 }) + ) + ); + + await expect( + duplicateStack({ + name: 'test-stack', + fileContent: 'version: "3"\nservices:\n app:\n image: nginx', + targetEnvironmentId: 2, + type: StackType.DockerSwarm, + }) + ).rejects.toThrow(); + }); + + it('should propagate errors from createSwarmStackFromFileContent', async () => { + server.use( + http.get('/api/endpoints/:id/docker/swarm', () => + HttpResponse.json({ ID: 'swarm123' }) + ), + http.post('/api/stacks/create/:type/:method', () => + HttpResponse.json({ message: 'Stack creation failed' }, { status: 500 }) + ) + ); + + await expect( + duplicateStack({ + name: 'test-stack', + fileContent: 'version: "3"\nservices:\n app:\n image: nginx', + targetEnvironmentId: 2, + type: StackType.DockerSwarm, + }) + ).rejects.toThrow(); + }); + + it('should propagate errors from createStandaloneStackFromFileContent', async () => { + server.use( + http.post('/api/stacks/create/:type/:method', () => + HttpResponse.json( + { message: 'Stack name already exists' }, + { status: 409 } + ) + ) + ); + + await expect( + duplicateStack({ + name: 'existing-stack', + fileContent: 'version: "3"\nservices:\n app:\n image: nginx', + targetEnvironmentId: 2, + type: StackType.DockerCompose, + }) + ).rejects.toThrow(); + }); +}); diff --git a/app/react/common/stacks/ItemView/StackDuplicationForm/useDuplicateStackMutation.ts b/app/react/common/stacks/ItemView/StackDuplicationForm/useDuplicateStackMutation.ts new file mode 100644 index 000000000..a847174b9 --- /dev/null +++ b/app/react/common/stacks/ItemView/StackDuplicationForm/useDuplicateStackMutation.ts @@ -0,0 +1,51 @@ +import { useMutation } from '@tanstack/react-query'; + +import { getSwarm } from '@/react/docker/proxy/queries/useSwarm'; +import { Pair } from '@/react/portainer/settings/types'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { createStandaloneStackFromFileContent } from '../../queries/useCreateStack/createStandaloneStackFromFileContent'; +import { createSwarmStackFromFileContent } from '../../queries/useCreateStack/createSwarmStackFromFileContent'; +import { StackType } from '../../types'; + +export function useDuplicateStackMutation() { + return useMutation({ + mutationFn: duplicateStack, + }); +} + +export async function duplicateStack({ + name, + fileContent, + targetEnvironmentId, + type, + env, +}: { + name: string; + fileContent: string; + targetEnvironmentId: EnvironmentId; + type: StackType; + env?: Array; +}) { + if (type === StackType.DockerSwarm) { + const swarm = await getSwarm(targetEnvironmentId); + + if (!swarm.ID) { + throw new Error('Swarm ID is required to duplicate a Swarm stack'); + } + + return createSwarmStackFromFileContent({ + environmentId: targetEnvironmentId, + name, + stackFileContent: fileContent, + swarmID: swarm.ID, + env, + }); + } + return createStandaloneStackFromFileContent({ + environmentId: targetEnvironmentId, + name, + stackFileContent: fileContent, + env, + }); +} diff --git a/app/react/common/stacks/ItemView/StackDuplicationForm/useMigrateStackMutation.test.tsx b/app/react/common/stacks/ItemView/StackDuplicationForm/useMigrateStackMutation.test.tsx new file mode 100644 index 000000000..9ef13feb9 --- /dev/null +++ b/app/react/common/stacks/ItemView/StackDuplicationForm/useMigrateStackMutation.test.tsx @@ -0,0 +1,353 @@ +import { http, HttpResponse } from 'msw'; + +import { server } from '@/setup-tests/server'; + +import { StackType } from '../../types'; + +import { routeMigrationRequest } from './useMigrateStackMutation'; + +type MigrateRequestBody = { + EndpointID: number; + Name?: string; + SwarmID?: string; +}; + +describe('Swarm stack migration', () => { + server.use( + http.get('/api/endpoints/:id/docker/swarm', () => + HttpResponse.json({ ID: 'target-swarm-456' }) + ), + http.post('/api/stacks/:id/migrate', () => HttpResponse.json({})) + ); + + it('should throw error if fromSwarmId is missing', async () => { + await expect( + routeMigrationRequest({ + stackType: StackType.DockerSwarm, + id: 1, + fromEnvId: 1, + targetEnvId: 2, + name: 'test-stack', + fromSwarmId: undefined, + }) + ).rejects.toThrow('Original Swarm ID is required'); + }); + + it('should call getSwarm with targetEnvId', async () => { + let swarmRequestCalled = false; + let capturedTargetEnvId: string | undefined; + + server.use( + http.get('/api/endpoints/:id/docker/swarm', ({ params }) => { + swarmRequestCalled = true; + capturedTargetEnvId = params.id as string; + return HttpResponse.json({ ID: 'target-swarm-456' }); + }), + http.post('/api/stacks/:id/migrate', () => HttpResponse.json({})) + ); + + await routeMigrationRequest({ + stackType: StackType.DockerSwarm, + id: 1, + fromEnvId: 1, + targetEnvId: 3, + name: 'test-stack', + fromSwarmId: 'source-swarm-123', + }); + + expect(swarmRequestCalled).toBe(true); + expect(capturedTargetEnvId).toBe('3'); + }); + + it('should throw error if target Swarm ID matches source Swarm ID', async () => { + const sameSwarmId = 'swarm123'; + + server.use( + http.get('/api/endpoints/:id/docker/swarm', () => + HttpResponse.json({ ID: sameSwarmId }) + ) + ); + + await expect( + routeMigrationRequest({ + stackType: StackType.DockerSwarm, + id: 1, + fromEnvId: 1, + targetEnvId: 2, + name: 'test-stack', + fromSwarmId: sameSwarmId, + }) + ).rejects.toThrow('same Swarm cluster'); + }); + + it('should call migrateStack with targetSwarmId', async () => { + const targetSwarmId = 'target-swarm-456'; + let migrateRequestBody: MigrateRequestBody | undefined; + + server.use( + http.get('/api/endpoints/:id/docker/swarm', () => + HttpResponse.json({ ID: targetSwarmId }) + ), + http.post('/api/stacks/:id/migrate', async ({ request }) => { + migrateRequestBody = (await request.json()) as MigrateRequestBody; + return HttpResponse.json({}); + }) + ); + + await routeMigrationRequest({ + stackType: StackType.DockerSwarm, + id: 5, + fromEnvId: 1, + targetEnvId: 2, + name: 'swarm-stack', + fromSwarmId: 'source-swarm-123', + }); + + expect(migrateRequestBody).toBeDefined(); + expect(migrateRequestBody?.SwarmID).toBe(targetSwarmId); + }); + + it('should include name parameter if provided', async () => { + let migrateRequestBody: MigrateRequestBody | undefined; + + server.use( + http.get('/api/endpoints/:id/docker/swarm', () => + HttpResponse.json({ ID: 'target-swarm-456' }) + ), + http.post('/api/stacks/:id/migrate', async ({ request }) => { + migrateRequestBody = (await request.json()) as MigrateRequestBody; + return HttpResponse.json({}); + }) + ); + + await routeMigrationRequest({ + stackType: StackType.DockerSwarm, + id: 5, + fromEnvId: 1, + targetEnvId: 2, + name: 'new-stack-name', + fromSwarmId: 'source-swarm-123', + }); + + expect(migrateRequestBody?.Name).toBe('new-stack-name'); + }); +}); + +describe('Standalone stack migration', () => { + it('should call migrateStack without targetSwarmId for standalone stacks', async () => { + let migrateRequestBody: MigrateRequestBody | undefined; + + server.use( + http.post('/api/stacks/:id/migrate', async ({ request }) => { + migrateRequestBody = (await request.json()) as MigrateRequestBody; + return HttpResponse.json({}); + }) + ); + + await routeMigrationRequest({ + stackType: StackType.DockerCompose, + id: 3, + fromEnvId: 1, + targetEnvId: 2, + name: 'compose-stack', + }); + + expect(migrateRequestBody).toBeDefined(); + expect(migrateRequestBody?.SwarmID).toBeUndefined(); + }); + + it('should pass id, fromEnvId, targetEnvId, name', async () => { + let migrateRequestBody: MigrateRequestBody | undefined; + let migrateRequestParams: { endpointId: string | null } | undefined; + let stackId: string | undefined; + + server.use( + http.post('/api/stacks/:id/migrate', async ({ request, params }) => { + migrateRequestBody = (await request.json()) as MigrateRequestBody; + stackId = params.id as string; + const url = new URL(request.url); + migrateRequestParams = { + endpointId: url.searchParams.get('endpointId'), + }; + return HttpResponse.json({}); + }) + ); + + await routeMigrationRequest({ + stackType: StackType.DockerCompose, + id: 7, + fromEnvId: 4, + targetEnvId: 5, + name: 'my-stack', + }); + + expect(stackId).toBe('7'); + expect(migrateRequestBody?.EndpointID).toBe(5); + expect(migrateRequestBody?.Name).toBe('my-stack'); + expect(migrateRequestParams?.endpointId).toBe('4'); + }); + + it('should not call getSwarm for standalone stacks', async () => { + let swarmRequestCalled = false; + + server.use( + http.get('/api/endpoints/:id/docker/swarm', () => { + swarmRequestCalled = true; + return HttpResponse.json({ ID: 'swarm123' }); + }), + http.post('/api/stacks/:id/migrate', () => HttpResponse.json({})) + ); + + await routeMigrationRequest({ + stackType: StackType.DockerCompose, + id: 3, + fromEnvId: 1, + targetEnvId: 2, + name: 'compose-stack', + }); + + expect(swarmRequestCalled).toBe(false); + }); +}); + +describe('API request structure', () => { + it('should POST to correct URL (buildStackUrl(id, "migrate"))', async () => { + let requestPath: string | undefined; + + server.use( + http.post('/api/stacks/:id/migrate', ({ request }) => { + requestPath = new URL(request.url).pathname; + return HttpResponse.json({}); + }) + ); + + await routeMigrationRequest({ + stackType: StackType.DockerCompose, + id: 123, + fromEnvId: 1, + targetEnvId: 2, + }); + + expect(requestPath).toBe('/api/stacks/123/migrate'); + }); + + it('should include EndpointID, Name, SwarmID in request body', async () => { + let requestBody: MigrateRequestBody | undefined; + + server.use( + http.post('/api/stacks/:id/migrate', async ({ request }) => { + requestBody = (await request.json()) as MigrateRequestBody; + return HttpResponse.json({}); + }) + ); + + await routeMigrationRequest({ + stackType: StackType.DockerCompose, + id: 10, + fromEnvId: 1, + targetEnvId: 3, + name: 'test-stack', + }); + + expect(requestBody).toBeDefined(); + expect(requestBody).toHaveProperty('EndpointID'); + expect(requestBody).toHaveProperty('Name'); + expect(requestBody?.EndpointID).toBe(3); + expect(requestBody?.Name).toBe('test-stack'); + }); + + it('should include endpointId in query params (fromEnvId)', async () => { + let queryParams: URLSearchParams | undefined; + + server.use( + http.post('/api/stacks/:id/migrate', ({ request }) => { + queryParams = new URL(request.url).searchParams; + return HttpResponse.json({}); + }) + ); + + await routeMigrationRequest({ + stackType: StackType.DockerCompose, + id: 10, + fromEnvId: 7, + targetEnvId: 3, + }); + + expect(queryParams?.get('endpointId')).toBe('7'); + }); +}); + +describe('error handling', () => { + it('should parse axios errors correctly', async () => { + server.use( + http.post('/api/stacks/:id/migrate', () => + HttpResponse.json( + { message: 'Migration failed', details: 'Stack not found' }, + { status: 404 } + ) + ) + ); + + await expect( + routeMigrationRequest({ + stackType: StackType.DockerCompose, + id: 999, + fromEnvId: 1, + targetEnvId: 2, + }) + ).rejects.toThrow(); + }); + + it('should propagate errors from getSwarm', async () => { + server.use( + http.get('/api/endpoints/:id/docker/swarm', () => + HttpResponse.json({ message: 'Swarm not available' }, { status: 503 }) + ) + ); + + await expect( + routeMigrationRequest({ + stackType: StackType.DockerSwarm, + id: 1, + fromEnvId: 1, + targetEnvId: 2, + fromSwarmId: 'swarm123', + }) + ).rejects.toThrow(); + }); + + it('should propagate errors from API call', async () => { + server.use( + http.post('/api/stacks/:id/migrate', () => + HttpResponse.json( + { message: 'Insufficient permissions' }, + { status: 403 } + ) + ) + ); + + await expect( + routeMigrationRequest({ + stackType: StackType.DockerCompose, + id: 1, + fromEnvId: 1, + targetEnvId: 2, + }) + ).rejects.toThrow(); + }); + + it('should handle network errors', async () => { + server.use( + http.post('/api/stacks/:id/migrate', () => HttpResponse.error()) + ); + + await expect( + routeMigrationRequest({ + stackType: StackType.DockerCompose, + id: 1, + fromEnvId: 1, + targetEnvId: 2, + }) + ).rejects.toThrow(); + }); +}); diff --git a/app/react/common/stacks/ItemView/StackDuplicationForm/useMigrateStackMutation.ts b/app/react/common/stacks/ItemView/StackDuplicationForm/useMigrateStackMutation.ts new file mode 100644 index 000000000..8dc7aad7d --- /dev/null +++ b/app/react/common/stacks/ItemView/StackDuplicationForm/useMigrateStackMutation.ts @@ -0,0 +1,101 @@ +import { useMutation } from '@tanstack/react-query'; + +import { getSwarm } from '@/react/docker/proxy/queries/useSwarm'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { StackType } from '../../types'; +import { buildStackUrl } from '../../queries/buildUrl'; + +export function useMigrateStackMutation() { + return useMutation({ + mutationFn: routeMigrationRequest, + }); +} + +export function routeMigrationRequest({ + stackType, + id, + fromEnvId, + targetEnvId, + name, + fromSwarmId, +}: { + stackType: StackType; + fromSwarmId?: string; + id: number; + fromEnvId: EnvironmentId; + targetEnvId: EnvironmentId; + name?: string; +}) { + if (stackType === StackType.DockerSwarm) { + return migrateSwarmStack({ id, fromEnvId, targetEnvId, name, fromSwarmId }); + } + + return migrateStack({ id, fromEnvId, targetEnvId, name }); +} + +export async function migrateSwarmStack({ + id, + fromEnvId, + targetEnvId, + fromSwarmId, + name, +}: { + id: number; + fromEnvId: EnvironmentId; + targetEnvId: EnvironmentId; + fromSwarmId?: string; + name?: string; +}) { + if (!fromSwarmId) { + throw new Error('Original Swarm ID is required to migrate a Swarm stack'); + } + + const targetSwarm = await getSwarm(targetEnvId); + if (fromSwarmId === targetSwarm.ID) { + throw new Error( + 'Target environment is located in the same Swarm cluster as the current environment' + ); + } + + return migrateStack({ + id, + fromEnvId, + targetEnvId, + name, + targetSwarmId: targetSwarm.ID, + }); +} + +export async function migrateStack({ + id, + fromEnvId, + targetEnvId, + name, + targetSwarmId, +}: { + id: number; + fromEnvId: EnvironmentId; + targetEnvId: EnvironmentId; + name?: string; + targetSwarmId?: string; +}) { + try { + return await axios.post( + buildStackUrl(id, 'migrate'), + { + EndpointID: targetEnvId, + Name: name, + SwarmID: targetSwarmId, + }, + { + params: { + endpointId: fromEnvId, + }, + } + ); + } catch (err) { + throw parseAxiosError(err); + } +} diff --git a/app/react/components/form-components/FormError.tsx b/app/react/components/form-components/FormError.tsx index 3b3d4ba42..1b638a07a 100644 --- a/app/react/components/form-components/FormError.tsx +++ b/app/react/components/form-components/FormError.tsx @@ -19,6 +19,8 @@ export function FormError({ children, className }: PropsWithChildren) { `text-muted help-block !inline-flex gap-1 !align-top text-xs`, className )} + role="alert" + aria-label={typeof children === 'string' ? children : undefined} >