refactor(stacks): migrate duplication form to react [BE-12353] (#1357)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,12 +0,0 @@
|
||||
angular.module('portainer.app').component('stackDuplicationForm', {
|
||||
templateUrl: './stack-duplication-form.html',
|
||||
controller: 'StackDuplicationFormController',
|
||||
bindings: {
|
||||
onDuplicate: '&',
|
||||
onMigrate: '&',
|
||||
endpoints: '<',
|
||||
groups: '<',
|
||||
currentEndpointId: '<',
|
||||
yamlError: '<',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
18
app/portainer/react/components/stacks.ts
Normal file
18
app/portainer/react/components/stacks.ts
Normal 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;
|
||||
@@ -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' } },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface FormValues {
|
||||
environmentId: number | undefined;
|
||||
newName: string;
|
||||
}
|
||||
|
||||
export type ActionType = 'duplicate' | 'migrate';
|
||||
|
||||
export interface FormSubmitValues extends FormValues {
|
||||
actionType: ActionType;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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 />);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
15
yarn.lock
15
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user