refactor(stacks): migrate duplication form to react [BE-12353] (#1357)

This commit is contained in:
Chaim Lev-Ari
2025-11-04 18:44:54 +02:00
committed by GitHub
parent 1be96e1bd1
commit 73ad27640c
27 changed files with 2347 additions and 329 deletions

View File

@@ -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,
};
});
}

View File

@@ -1,74 +0,0 @@
<div authorization="PortainerStackMigrate">
<div class="col-sm-12 form-section-title"> Stack duplication / migration </div>
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="dupStackForm">
<div class="form-group">
<span class="small" style="margin-top: 10px">
<p class="text-muted"> This feature allows you to duplicate or migrate this stack. </p>
</span>
</div>
<div class="form-group">
<input
class="form-control"
placeholder="Stack name (optional for migration)"
aria-placeholder="Stack name"
name="new_stack_name"
ng-pattern="$ctrl.STACK_NAME_VALIDATION_REGEX"
ng-model="$ctrl.formValues.newName"
/>
</div>
<div class="form-group" ng-show="dupStackForm.new_stack_name.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="dupStackForm.new_stack_name.$error">
<p ng-message="pattern">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
<span>This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').</span>
</p>
</div>
</div>
</div>
<div class="form-group" ng-if="$ctrl.endpoints && $ctrl.groups">
<por-select value="$ctrl.formValues.endpointId" on-change="($ctrl.onChangeEnvironment)" options="$ctrl.environmentSelectorOptions"></por-select>
</div>
<div class="form-group">
<button
class="btn btn-sm btn-primary"
ng-click="$ctrl.migrateStack()"
ng-disabled="$ctrl.isMigrationButtonDisabled()"
style="margin-top: 7px; margin-left: 0"
button-spinner="$ctrl.state.migrationInProgress"
>
<span ng-hide="$ctrl.state.migrationInProgress">
<pr-icon icon="'arrow-right'" class-name="'mr-1'"></pr-icon>
Migrate
</span>
<span ng-show="$ctrl.state.migrationInProgress">Migration in progress...</span>
</button>
<button
class="btn btn-sm btn-primary"
ng-click="$ctrl.duplicateStack()"
ng-disabled="!$ctrl.isFormValidForDuplication() || $ctrl.state.duplicationInProgress || $ctrl.state.migrationInProgress"
style="margin-top: 7px; margin-left: 0"
button-spinner="$ctrl.state.duplicationInProgress"
>
<span ng-hide="$ctrl.state.duplicationInProgress">
<pr-icon icon="'copy'" class-name="'space-right'"></pr-icon>
Duplicate
</span>
<span ng-show="$ctrl.state.duplicationInProgress">Duplication in progress...</span>
</button>
</div>
<div class="form-group">
<div ng-if="$ctrl.yamlError && $ctrl.isEndpointSelected()">
<span class="text-danger small">{{ $ctrl.yamlError }}</span>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>

View File

@@ -1,12 +0,0 @@
angular.module('portainer.app').component('stackDuplicationForm', {
templateUrl: './stack-duplication-form.html',
controller: 'StackDuplicationFormController',
bindings: {
onDuplicate: '&',
onMigrate: '&',
endpoints: '<',
groups: '<',
currentEndpointId: '<',
yamlError: '<',
},
});

View File

@@ -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',

View File

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

View File

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

View File

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

View File

@@ -119,14 +119,13 @@
endpoint="applicationState.endpoint"
>
</stack-redeploy-git-form>
<stack-duplication-form
ng-if="regular && endpoints.length > 0"
endpoints="endpoints"
groups="groups"
current-endpoint-id="endpoint.Id"
on-duplicate="duplicateStack(name, endpointId)"
on-migrate="migrateStack(name, endpointId)"
ng-if="stack && regular"
current-environment-id="endpoint.Id"
yaml-error="state.yamlError"
stack="stack"
original-file-content="stackFileContent"
>
</stack-duplication-form>
</div>

View File

@@ -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);

View File

@@ -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();

View File

@@ -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(
<Component value={undefined} onChange={onChange} error={error} />
);
}
});
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'
);
});
});

View File

@@ -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 (
<div className="form-group">
<PortainerSelect
value={value}
onChange={onChange}
options={environmentOptions}
placeholder="Select an environment"
data-cy="stack-duplicate-environment-select"
/>
{error && (
<div className="col-sm-12">
<FormError>{error}</FormError>
</div>
)}
</div>
);
}
/**
* Transforms environments and groups into grouped options for PortainerSelect
*/
export function getEnvironmentOptions(
groups: EnvironmentGroup[],
environments: Environment[],
currentEnvironmentId?: number
): GroupOption<number>[] {
if (!groups || !environments) {
return [];
}
// Group environments by their GroupId
const groupedEnvironments = environments.reduce<
Record<number, Array<{ label: string; value: number }>>
>((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,
};
});
}

View File

@@ -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>): 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(() => (
<UIRouterContext.Provider value={mockRouter}>
<StackDuplicationForm
stack={stack}
currentEnvironmentId={currentEnvironmentId}
yamlError={yamlError}
originalFileContent={originalFileContent}
/>
</UIRouterContext.Provider>
));
return { ...render(<Component />), mockRouter };
}

View File

@@ -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 (
<Widget>
<WidgetTitle title="Stack duplication / migration" icon={Copy} />
<WidgetBody>
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validateOnMount
validationSchema={getBaseValidationSchema()}
>
<StackDuplicationFormInner
yamlError={yamlError}
currentEnvironmentId={currentEnvironmentId}
/>
</Formik>
</WidgetBody>
</Widget>
);
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');
},
}
);
}
}

View File

@@ -0,0 +1,10 @@
export interface FormValues {
environmentId: number | undefined;
newName: string;
}
export type ActionType = 'duplicate' | 'migrate';
export interface FormSubmitValues extends FormValues {
actionType: ActionType;
}

View File

@@ -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();
});
}

View File

@@ -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
),
});
}

View File

@@ -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<void>;
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(() => (
<Formik initialValues={initialValues} onSubmit={onSubmit}>
<StackDuplicationFormInner
yamlError={yamlError}
currentEnvironmentId={currentEnvironmentId}
/>
</Formik>
));
return render(<Component />);
}

View File

@@ -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<FormSubmitValues>();
const validState = useValidation({
values,
currentEnvironmentId,
});
const [actionType, setActionType] = useState<ActionType | null>(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 (
<Form>
<div className="form-group">
<span className="small mt-2">
<p className="text-muted">
This feature allows you to duplicate or migrate this stack.
</p>
</span>
</div>
<div className="form-group">
<Field
as={Input}
type="text"
placeholder="Stack name (optional for migration)"
aria-label="Stack name"
name="newName"
data-cy="stack-duplicate-name-input"
/>
{errors.newName && (
<div className="col-sm-12">
<FormError>{errors.newName}</FormError>
</div>
)}
</div>
<EnvSelector
onChange={(value) => setFieldValue('environmentId', value)}
value={values.environmentId}
error={errors.environmentId}
/>
<div className="form-group">
<LoadingButton
type="button"
color="primary"
size="small"
disabled={isMigrateDisabled}
isLoading={isMigrateInProgress}
loadingText="Migration in progress..."
onClick={() => handleAction('migrate')}
icon={ArrowRight}
data-cy="stack-migrate-button"
className="!ml-0"
>
Migrate
</LoadingButton>
<LoadingButton
type="button"
color="primary"
size="small"
disabled={isDuplicateDisabled}
isLoading={isDuplicateInProgress}
loadingText="Duplication in progress..."
onClick={() => handleAction('duplicate')}
icon={Copy}
data-cy="stack-duplicate-button"
>
Duplicate
</LoadingButton>
</div>
{yamlError && isEnvSelected && (
<div className="form-group">
<div>
<span className="text-danger small">{yamlError}</span>
</div>
</div>
)}
</Form>
);
}

View File

@@ -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();
});
});

View File

@@ -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<Pair>;
}) {
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,
});
}

View File

@@ -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();
});
});

View File

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

View File

@@ -19,6 +19,8 @@ export function FormError({ children, className }: PropsWithChildren<Props>) {
`text-muted help-block !inline-flex gap-1 !align-top text-xs`,
className
)}
role="alert"
aria-label={typeof children === 'string' ? children : undefined}
>
<Icon
icon={AlertTriangle}

View File

@@ -155,6 +155,7 @@
"@svgr/webpack": "^8.1.0",
"@testing-library/dom": "^9.3.4",
"@testing-library/react": "^12",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/angular": "^1.8.3",
"@types/file-saver": "^2.0.4",

View File

@@ -5982,6 +5982,14 @@
lz-string "^1.5.0"
pretty-format "^27.0.2"
"@testing-library/react-hooks@^8.0.1":
version "8.0.1"
resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12"
integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==
dependencies:
"@babel/runtime" "^7.12.5"
react-error-boundary "^3.1.0"
"@testing-library/react@^12":
version "12.1.5"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b"
@@ -15445,6 +15453,13 @@ react-element-to-jsx-string@^15.0.0:
is-plain-object "5.0.0"
react-is "18.1.0"
react-error-boundary@^3.1.0:
version "3.1.4"
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==
dependencies:
"@babel/runtime" "^7.12.5"
react-fast-compare@^2.0.1:
version "2.0.4"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"