chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { vi } from 'vitest';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import { server } from '@/setup-tests/server';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||
import {
|
||||
createMockUsers,
|
||||
createMockStack,
|
||||
createMockEnvironment,
|
||||
} from '@/react-tools/test-mocks';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import { Role } from '@/portainer/users/types';
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { StackEditorTab } from './StackEditorTab';
|
||||
|
||||
const defaultProps = {
|
||||
stack: createMockStack({
|
||||
EndpointId: 5,
|
||||
PreviousDeploymentInfo: {
|
||||
Version: 1,
|
||||
FileVersion: 2,
|
||||
},
|
||||
Option: {
|
||||
Prune: false,
|
||||
Force: false,
|
||||
},
|
||||
Webhook: '',
|
||||
}),
|
||||
originalFileContent: 'version: "3"\nservices:\n web:\n image: nginx',
|
||||
isOrphaned: false,
|
||||
containerNames: [],
|
||||
originalContainerNames: [],
|
||||
};
|
||||
|
||||
// Mock the hooks and child component
|
||||
vi.mock('@@/WebEditorForm', () => ({
|
||||
usePreventExit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./useVersionedStackFile', () => ({
|
||||
useVersionedStackFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||
...(await importOriginal()),
|
||||
useCurrentStateAndParams: vi.fn(() => ({
|
||||
params: { endpointId: 5 },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/common/stacks/common/confirm-stack-update', () => ({
|
||||
confirmStackUpdate: vi.fn(() =>
|
||||
Promise.resolve({ repullImageAndRedeploy: false })
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/portainer/services/notifications', () => ({
|
||||
notifyError: vi.fn(),
|
||||
notifySuccess: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMswHandlers();
|
||||
});
|
||||
|
||||
describe('initial loading', () => {
|
||||
it('should be empty when environment data is not loaded', async () => {
|
||||
const restoreConsole = suppressConsoleLogs();
|
||||
setupMswHandlers({ shouldReturnEnv: false });
|
||||
|
||||
const { container } = renderComponent();
|
||||
|
||||
// Wait for queries to settle
|
||||
await waitFor(() => {
|
||||
expect(container.innerHTML).toBe('<div></div>');
|
||||
});
|
||||
|
||||
restoreConsole();
|
||||
});
|
||||
|
||||
it('should be empty when schema data is not loaded', async () => {
|
||||
setupMswHandlers({ shouldReturnSchema: false });
|
||||
|
||||
const { container } = renderComponent();
|
||||
|
||||
// Wait for queries to settle
|
||||
await waitFor(() => {
|
||||
expect(container.innerHTML).toBe('<div></div>');
|
||||
});
|
||||
});
|
||||
|
||||
it('should render StackEditorTabInner when both environment and schema are loaded', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch current environment data on mount', async () => {
|
||||
let envFetched = false;
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoints/:id', () => {
|
||||
envFetched = true;
|
||||
return HttpResponse.json(
|
||||
createMockEnvironment({
|
||||
Id: 1,
|
||||
Name: 'local',
|
||||
Type: EnvironmentType.Docker,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(envFetched).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch API version for environment', async () => {
|
||||
let versionFetched = false;
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoints/:id/docker/version', () => {
|
||||
versionFetched = true;
|
||||
return HttpResponse.json({
|
||||
ApiVersion: '1.47',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(versionFetched).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('form submission', () => {
|
||||
it('should show confirmation dialog before submitting', async () => {
|
||||
const restoreConsole = suppressConsoleLogs();
|
||||
|
||||
const mockConfirm = vi.mocked(confirmStackUpdate);
|
||||
renderComponent();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deployButton = screen.getByTestId('stack-deploy-button');
|
||||
await waitFor(() => {
|
||||
expect(deployButton).toBeEnabled();
|
||||
});
|
||||
|
||||
await user.click(deployButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfirm).toHaveBeenCalledWith(
|
||||
'Do you want to force an update of the stack?',
|
||||
false // stackType is DockerCompose
|
||||
);
|
||||
});
|
||||
|
||||
restoreConsole();
|
||||
});
|
||||
|
||||
it('should call mutation API with correct payload', async () => {
|
||||
const restoreConsole = suppressConsoleLogs();
|
||||
|
||||
let capturedRequestBody: unknown;
|
||||
|
||||
server.use(
|
||||
http.put('/api/stacks/:id', async ({ request }) => {
|
||||
capturedRequestBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
Id: 1,
|
||||
Name: 'test-stack',
|
||||
Type: 2,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
renderComponent({ stack: createMockStack({ Id: 42 }) });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deployButton = screen.getByTestId('stack-deploy-button');
|
||||
await waitFor(() => {
|
||||
expect(deployButton).toBeEnabled();
|
||||
});
|
||||
|
||||
await user.click(deployButton);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(capturedRequestBody).toEqual({
|
||||
stackFileContent: defaultProps.originalFileContent,
|
||||
env: [],
|
||||
prune: false,
|
||||
repullImageAndRedeploy: false,
|
||||
});
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
|
||||
restoreConsole();
|
||||
});
|
||||
|
||||
it('should not submit if confirmation is cancelled', async () => {
|
||||
const mockConfirm = vi.mocked(confirmStackUpdate);
|
||||
mockConfirm.mockResolvedValueOnce(undefined); // User cancelled
|
||||
|
||||
let mutationCalled = false;
|
||||
server.use(
|
||||
http.put('/api/stacks/:id', () => {
|
||||
mutationCalled = true;
|
||||
return HttpResponse.json({ Id: 1 });
|
||||
})
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deployButton = screen.getByTestId('stack-deploy-button');
|
||||
await waitFor(() => {
|
||||
expect(deployButton).toBeEnabled();
|
||||
});
|
||||
|
||||
await user.click(deployButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Give some time to ensure mutation doesn't happen
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
expect(mutationCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('should call onSubmitSuccess callback and show success notification after mutation completes', async () => {
|
||||
const restoreConsole = suppressConsoleLogs();
|
||||
|
||||
const onSubmitSuccess = vi.fn();
|
||||
renderComponent({ onSubmitSuccess });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deployButton = screen.getByTestId('stack-deploy-button');
|
||||
await waitFor(() => {
|
||||
expect(deployButton).toBeEnabled();
|
||||
});
|
||||
|
||||
await user.click(deployButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notifySuccess).toHaveBeenCalledWith(
|
||||
'Success',
|
||||
'Stack successfully deployed'
|
||||
);
|
||||
expect(onSubmitSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
restoreConsole();
|
||||
});
|
||||
|
||||
it('should handle API errors during submission', async () => {
|
||||
const restoreConsole = suppressConsoleLogs();
|
||||
|
||||
server.use(
|
||||
http.put('/api/stacks/:id', () =>
|
||||
HttpResponse.json({ message: 'Stack update failed' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deployButton = screen.getByTestId('stack-deploy-button');
|
||||
await waitFor(() => {
|
||||
expect(deployButton).toBeEnabled();
|
||||
});
|
||||
|
||||
await user.click(deployButton);
|
||||
|
||||
// Verify error notification is called
|
||||
await waitFor(() => {
|
||||
expect(notifyError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(deployButton).toBeEnabled();
|
||||
restoreConsole();
|
||||
});
|
||||
});
|
||||
|
||||
describe('container name validation', () => {
|
||||
it('should validate container names using provided containerNames', async () => {
|
||||
const originalFileContent = `
|
||||
version: "3"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
container_name: existing-container
|
||||
`;
|
||||
|
||||
const containerNames = ['existing-container', 'other-container'];
|
||||
|
||||
renderComponent({ originalFileContent, containerNames });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/already used by another container/)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show error when container name is in originalContainerNames', async () => {
|
||||
const originalFileContent = `
|
||||
version: "3"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
container_name: my-container
|
||||
`;
|
||||
|
||||
const containerNames = ['my-container', 'other-container'];
|
||||
const originalContainerNames = ['my-container'];
|
||||
|
||||
renderComponent({
|
||||
originalFileContent,
|
||||
containerNames,
|
||||
originalContainerNames,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should not show conflict error
|
||||
expect(
|
||||
screen.queryByText(/already used by another container/)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should validate YAML syntax errors', async () => {
|
||||
const originalFileContent = 'invalid: [yaml syntax';
|
||||
|
||||
renderComponent({ originalFileContent });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/There is an error in the yaml syntax/)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Setup MSW handlers for API requests
|
||||
*/
|
||||
function setupMswHandlers({
|
||||
shouldReturnEnv = true,
|
||||
shouldReturnSchema = true,
|
||||
envType = EnvironmentType.Docker,
|
||||
apiVersion = 1.47,
|
||||
schema = { type: 'object', properties: {} },
|
||||
stackUpdateResponse,
|
||||
}: {
|
||||
shouldReturnEnv?: boolean;
|
||||
shouldReturnSchema?: boolean;
|
||||
envType?: EnvironmentType;
|
||||
apiVersion?: number;
|
||||
schema?: object;
|
||||
stackUpdateResponse?: object | ((body: unknown) => object);
|
||||
} = {}) {
|
||||
server.use(
|
||||
http.get('/api/endpoints/:id', () => {
|
||||
if (!shouldReturnEnv) {
|
||||
return HttpResponse.json(null, { status: 404 });
|
||||
}
|
||||
return HttpResponse.json(
|
||||
createMockEnvironment({
|
||||
Id: 1,
|
||||
Name: 'local',
|
||||
Type: envType,
|
||||
})
|
||||
);
|
||||
}),
|
||||
http.get('https://raw.githubusercontent.com/*', () => {
|
||||
if (!shouldReturnSchema) {
|
||||
return HttpResponse.json(null, { status: 404 });
|
||||
}
|
||||
return HttpResponse.json(schema);
|
||||
}),
|
||||
http.get('/api/endpoints/:id/docker/version', () =>
|
||||
HttpResponse.json({
|
||||
ApiVersion: apiVersion.toString(),
|
||||
})
|
||||
),
|
||||
http.get('/api/endpoints/:id/docker/info', () =>
|
||||
HttpResponse.json({
|
||||
Swarm: {
|
||||
LocalNodeState: 'active',
|
||||
},
|
||||
})
|
||||
),
|
||||
http.put('/api/stacks/:id', async ({ request, params }) => {
|
||||
const body = await request.json();
|
||||
|
||||
if (stackUpdateResponse) {
|
||||
const response =
|
||||
typeof stackUpdateResponse === 'function'
|
||||
? stackUpdateResponse(body)
|
||||
: stackUpdateResponse;
|
||||
return HttpResponse.json(response);
|
||||
}
|
||||
|
||||
// Default success response
|
||||
return HttpResponse.json({
|
||||
Id: Number(params.id),
|
||||
Name: 'test-stack',
|
||||
Type: 2,
|
||||
EndpointId: 1,
|
||||
...(body as object),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
type RenderComponentProps = Partial<ComponentProps<typeof StackEditorTab>>;
|
||||
/**
|
||||
* Helper function to render StackEditorTab component
|
||||
*/
|
||||
function renderComponent(props: RenderComponentProps = {}) {
|
||||
const Component = createComponent();
|
||||
return render(<Component {...defaultProps} {...props} />);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create component with props and providers
|
||||
*/
|
||||
function createComponent() {
|
||||
const user = createMockUsers(1, Role.Admin)[0]; // Admin user with all permissions
|
||||
|
||||
return withTestRouter(
|
||||
withTestQueryProvider(withUserProvider(StackEditorTab, user))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Formik } from 'formik';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import _ from 'lodash';
|
||||
import { useState } from 'react';
|
||||
import uuidv4 from 'uuid/v4';
|
||||
|
||||
import { Stack, StackType } from '@/react/common/stacks/types';
|
||||
import { useDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { useUpdateStackMutation } from '../../useUpdateStack';
|
||||
|
||||
import { StackEditorFormValues } from './StackEditorTab.types';
|
||||
import { getValidationSchema } from './StackEditorTab.validation';
|
||||
import { StackEditorTabInner } from './StackEditorTabInner';
|
||||
|
||||
interface StackEditorTabProps {
|
||||
isOrphaned: boolean;
|
||||
containerNames?: string[];
|
||||
originalContainerNames?: string[];
|
||||
stack: Stack;
|
||||
originalFileContent: string;
|
||||
|
||||
onSubmitSuccess?(): void;
|
||||
}
|
||||
|
||||
export function StackEditorTab({
|
||||
isOrphaned,
|
||||
|
||||
containerNames = [],
|
||||
originalContainerNames = [],
|
||||
originalFileContent,
|
||||
onSubmitSuccess = () => {},
|
||||
stack,
|
||||
}: StackEditorTabProps) {
|
||||
const versions = _.compact([
|
||||
stack.StackFileVersion,
|
||||
stack.PreviousDeploymentInfo?.FileVersion,
|
||||
]);
|
||||
const router = useRouter();
|
||||
const mutation = useUpdateStackMutation();
|
||||
const envQuery = useCurrentEnvironment();
|
||||
const schemaQuery = useDockerComposeSchema();
|
||||
const [webhookId] = useState(() => stack.Webhook || uuidv4());
|
||||
|
||||
if (!envQuery.data || !schemaQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const envType = envQuery.data?.Type;
|
||||
const composeSyntaxMaxVersion = parseFloat(
|
||||
envQuery.data?.ComposeSyntaxMaxVersion
|
||||
);
|
||||
|
||||
const initialValues: StackEditorFormValues = {
|
||||
environmentVariables: stack.Env || [],
|
||||
prune: !!(stack.Option && stack.Option.Prune),
|
||||
stackFileContent: originalFileContent,
|
||||
enabledWebhook: !!stack.Webhook,
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={getValidationSchema(
|
||||
containerNames,
|
||||
originalContainerNames
|
||||
)}
|
||||
onSubmit={async (values) => {
|
||||
const response = await confirmStackUpdate(
|
||||
'Do you want to force an update of the stack?',
|
||||
stack.Type === StackType.DockerSwarm
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutation.mutate(
|
||||
{
|
||||
stackId: stack.Id,
|
||||
environmentId: envQuery.data.Id,
|
||||
payload: {
|
||||
stackFileContent: values.stackFileContent,
|
||||
env: values.environmentVariables,
|
||||
prune: values.prune,
|
||||
webhook: values.enabledWebhook ? webhookId : undefined,
|
||||
repullImageAndRedeploy: response.repullImageAndRedeploy,
|
||||
rollbackTo: values.rollbackTo,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Stack successfully deployed');
|
||||
router.stateService.reload();
|
||||
onSubmitSuccess();
|
||||
},
|
||||
onError(err) {
|
||||
notifyError('Failure', err as Error, 'Unable to create stack');
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
validateOnMount
|
||||
enableReinitialize
|
||||
>
|
||||
<StackEditorTabInner
|
||||
stackId={stack.Id}
|
||||
stackType={stack.Type}
|
||||
composeSyntaxMaxVersion={composeSyntaxMaxVersion}
|
||||
isOrphaned={isOrphaned}
|
||||
envType={envType}
|
||||
schema={schemaQuery.data}
|
||||
versions={versions}
|
||||
isSubmitting={mutation.isLoading}
|
||||
isSaved={mutation.isSuccess}
|
||||
webhookId={webhookId}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||
|
||||
export interface StackEditorFormValues {
|
||||
stackFileContent: string;
|
||||
environmentVariables: EnvVarValues;
|
||||
rollbackTo?: number;
|
||||
prune: boolean;
|
||||
enabledWebhook: boolean;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { object, string, boolean, SchemaOf, array, number } from 'yup';
|
||||
|
||||
import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||
|
||||
import { validateYAML } from '../../common/stackYamlValidation';
|
||||
|
||||
import { StackEditorFormValues } from './StackEditorTab.types';
|
||||
|
||||
export function getValidationSchema(
|
||||
containerNames: string[] = [],
|
||||
originalContainerNames: string[] = []
|
||||
): SchemaOf<StackEditorFormValues> {
|
||||
return object({
|
||||
stackFileContent: string()
|
||||
.required('Stack file content is required')
|
||||
.min(1, 'Stack file content cannot be empty')
|
||||
.test('valid-yaml', 'Invalid YAML', function validateYamlTest(value) {
|
||||
if (!value) {
|
||||
return true; // Let required validation handle empty values
|
||||
}
|
||||
|
||||
const yamlError = validateYAML(
|
||||
value,
|
||||
containerNames,
|
||||
originalContainerNames
|
||||
);
|
||||
|
||||
if (yamlError) {
|
||||
return this.createError({ message: yamlError });
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
environmentVariables: envVarValidation(),
|
||||
prune: boolean().default(false),
|
||||
registries: array(number().required()).default([]),
|
||||
rollbackTo: number().notRequired(),
|
||||
enabledWebhook: boolean().default(false),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DefaultBodyType, http, HttpResponse } from 'msw';
|
||||
import uuidv4 from 'uuid/v4';
|
||||
|
||||
import { server } from '@/setup-tests/server';
|
||||
import { Stack } from '@/react/common/stacks/types';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import {
|
||||
createMockEnvironment,
|
||||
createMockStack,
|
||||
createMockUsers,
|
||||
} from '@/react-tools/test-mocks';
|
||||
import { Role } from '@/portainer/users/types';
|
||||
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||
|
||||
import { StackEditorTab } from './StackEditorTab';
|
||||
|
||||
vi.mock('uuid/v4', () => ({
|
||||
default: vi.fn(() => 'test-webhook-id-1234'),
|
||||
}));
|
||||
|
||||
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||
...(await importOriginal()),
|
||||
useCurrentStateAndParams: vi.fn(() => ({
|
||||
params: { endpointId: 1 },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/common/stacks/common/confirm-stack-update', () => ({
|
||||
confirmStackUpdate: vi.fn(() =>
|
||||
Promise.resolve({ repullImageAndRedeploy: false })
|
||||
),
|
||||
}));
|
||||
|
||||
describe('StackEditorTab - Webhook ID Handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
server.use(
|
||||
http.get('/api/endpoints/1', () =>
|
||||
HttpResponse.json(
|
||||
createMockEnvironment({
|
||||
Id: 1,
|
||||
Type: EnvironmentType.Docker,
|
||||
ComposeSyntaxMaxVersion: '3',
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
describe('Editor stack with existing webhook', () => {
|
||||
it('should display existing webhook ID', async () => {
|
||||
const existingWebhookId = 'existing-webhook-123';
|
||||
|
||||
const stack = createMockStack({
|
||||
Id: 1,
|
||||
Webhook: existingWebhookId,
|
||||
});
|
||||
|
||||
renderComponent({ stack });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stack-deploy-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const webhookDisplay = screen.queryByRole('textbox', {
|
||||
name: /webhook url/i,
|
||||
});
|
||||
expect(webhookDisplay).toBeInTheDocument();
|
||||
expect(webhookDisplay).toHaveTextContent(existingWebhookId);
|
||||
});
|
||||
|
||||
expect(vi.mocked(uuidv4)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor stack without webhook', () => {
|
||||
it('should not display webhook ID and should call uuid once for fallback', async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const stack = createMockStack({
|
||||
Id: 1,
|
||||
Webhook: '',
|
||||
});
|
||||
|
||||
renderComponent({ stack });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stack-deploy-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const webhookDisplay = screen.queryByRole('textbox', {
|
||||
name: /webhook url/i,
|
||||
});
|
||||
expect(webhookDisplay).not.toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(uuidv4)).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form submission', () => {
|
||||
it('should send webhook ID in API request when stack has webhook', async () => {
|
||||
const restoreConsole = suppressConsoleLogs();
|
||||
|
||||
const user = userEvent.setup();
|
||||
let capturedRequestBody: DefaultBodyType;
|
||||
|
||||
server.use(
|
||||
http.put('/api/stacks/:id', async ({ request }) => {
|
||||
capturedRequestBody = await request.json();
|
||||
return HttpResponse.json({ Id: 1, Name: 'test-stack' });
|
||||
})
|
||||
);
|
||||
|
||||
const stack = createMockStack({
|
||||
Id: 1,
|
||||
Webhook: 'existing-webhook-123',
|
||||
});
|
||||
|
||||
renderComponent({ stack });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stack-deploy-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
|
||||
const deployButton = screen.getByTestId('stack-deploy-button');
|
||||
await waitFor(() => {
|
||||
expect(deployButton).toBeEnabled();
|
||||
});
|
||||
|
||||
await user.click(deployButton);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(capturedRequestBody).toBeDefined();
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
|
||||
assert(capturedRequestBody && typeof capturedRequestBody === 'object');
|
||||
expect(capturedRequestBody?.webhook).toBe('existing-webhook-123');
|
||||
|
||||
restoreConsole();
|
||||
});
|
||||
|
||||
it('should not send webhook ID in API request when stack has no webhook', async () => {
|
||||
const restoreConsole = suppressConsoleLogs();
|
||||
const user = userEvent.setup();
|
||||
let capturedRequestBody: DefaultBodyType;
|
||||
|
||||
server.use(
|
||||
http.put('/api/stacks/:id', async ({ request }) => {
|
||||
capturedRequestBody = await request.json();
|
||||
return HttpResponse.json({ Id: 1, Name: 'test-stack' });
|
||||
})
|
||||
);
|
||||
|
||||
const stack = createMockStack({
|
||||
Id: 1,
|
||||
Webhook: '',
|
||||
});
|
||||
|
||||
renderComponent({ stack });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stack-deploy-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
|
||||
const deployButton = screen.getByTestId('stack-deploy-button');
|
||||
await waitFor(() => {
|
||||
expect(deployButton).toBeEnabled();
|
||||
});
|
||||
|
||||
await user.click(deployButton);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(capturedRequestBody).toBeDefined();
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
|
||||
expect(capturedRequestBody).not.toHaveProperty('webhook');
|
||||
restoreConsole();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function renderComponent({ stack }: { stack: Stack }) {
|
||||
const user = createMockUsers(1, Role.Admin)[0];
|
||||
|
||||
const Component = withTestRouter(
|
||||
withUserProvider(
|
||||
withTestQueryProvider(() => (
|
||||
<StackEditorTab
|
||||
stack={stack}
|
||||
isOrphaned={false}
|
||||
originalFileContent={`services:
|
||||
web:
|
||||
image: nginx`}
|
||||
/>
|
||||
)),
|
||||
user
|
||||
)
|
||||
);
|
||||
|
||||
return render(<Component />);
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Formik } from 'formik';
|
||||
import { vi } from 'vitest';
|
||||
import { ComponentProps } from 'react';
|
||||
import { JSONSchema7 } from 'json-schema';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { createMockUser, createMockUsers } from '@/react-tools/test-mocks';
|
||||
import { Role } from '@/portainer/users/types';
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { server } from '@/setup-tests/server';
|
||||
|
||||
import { usePreventExit } from '@@/WebEditorForm';
|
||||
|
||||
import { StackEditorTabInner } from './StackEditorTabInner';
|
||||
import { StackEditorFormValues } from './StackEditorTab.types';
|
||||
import { useVersionedStackFile } from './useVersionedStackFile';
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('@@/WebEditorForm', () => ({
|
||||
usePreventExit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./useVersionedStackFile', () => ({
|
||||
useVersionedStackFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||
...(await importOriginal()),
|
||||
useCurrentStateAndParams: vi.fn(() => ({
|
||||
params: { endpointId: 5 },
|
||||
})),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
stackType: StackType.DockerCompose,
|
||||
composeSyntaxMaxVersion: 3,
|
||||
envType: EnvironmentType.Docker,
|
||||
schema: { type: 'object' } as JSONSchema7,
|
||||
isOrphaned: false,
|
||||
stackId: 1,
|
||||
isSubmitting: false,
|
||||
isSaved: false,
|
||||
webhookId: '',
|
||||
};
|
||||
|
||||
const defaultInitialValues: StackEditorFormValues = {
|
||||
stackFileContent: 'version: "3"\nservices:\n web:\n image: nginx',
|
||||
environmentVariables: [],
|
||||
enabledWebhook: false,
|
||||
prune: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initial rendering', () => {
|
||||
it('should render the form with all main sections', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/This stack will be deployed using/)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// Code editor should be present
|
||||
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
|
||||
|
||||
// Environment variables panel
|
||||
expect(screen.getByText(/Environment variables/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Docker Compose version 2 message when composeSyntaxMaxVersion is 2', async () => {
|
||||
renderComponent({
|
||||
stackType: StackType.DockerCompose,
|
||||
composeSyntaxMaxVersion: 2,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Only Compose file format version/)
|
||||
).toBeVisible();
|
||||
expect(screen.getByText(/2/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show Docker Compose generic message when composeSyntaxMaxVersion > 2', async () => {
|
||||
renderComponent({
|
||||
stackType: StackType.DockerCompose,
|
||||
composeSyntaxMaxVersion: 3,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/This stack will be deployed using/)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show documentation link', () => {
|
||||
renderComponent();
|
||||
|
||||
const link = screen.getByRole('link', {
|
||||
name: /official documentation/i,
|
||||
});
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.docker.com/compose/compose-file/'
|
||||
);
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('should call usePreventExit with correct parameters', () => {
|
||||
const mockUsePreventExit = vi.mocked(usePreventExit);
|
||||
renderComponent();
|
||||
|
||||
expect(mockUsePreventExit).toHaveBeenCalledWith(
|
||||
defaultInitialValues.stackFileContent,
|
||||
defaultInitialValues.stackFileContent,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should call useVersionedStackFile with stackId and rollbackTo', () => {
|
||||
const mockUseVersionedStackFile = vi.mocked(useVersionedStackFile);
|
||||
renderComponent({ stackId: 42 });
|
||||
|
||||
expect(mockUseVersionedStackFile).toHaveBeenCalledWith({
|
||||
stackId: 42,
|
||||
version: undefined,
|
||||
onLoad: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditional rendering - Prune services field', () => {
|
||||
it('should show Prune field for DockerSwarm with API >= 1.27', async () => {
|
||||
renderComponent(
|
||||
{
|
||||
stackType: StackType.DockerSwarm,
|
||||
},
|
||||
{
|
||||
apiVersion: 1.27,
|
||||
}
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('stack-prune-services-switch')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Prune services')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show Prune field for DockerCompose with API >= 1.27', async () => {
|
||||
renderComponent(
|
||||
{
|
||||
stackType: StackType.DockerCompose,
|
||||
},
|
||||
{
|
||||
apiVersion: 1.27,
|
||||
}
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('stack-prune-services-switch')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide Prune field for DockerSwarm with API < 1.27', () => {
|
||||
renderComponent(
|
||||
{
|
||||
stackType: StackType.DockerSwarm,
|
||||
},
|
||||
{
|
||||
apiVersion: 1.26,
|
||||
}
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('stack-prune-services-switch')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide Prune field for DockerCompose with API < 1.27', () => {
|
||||
renderComponent(
|
||||
{
|
||||
stackType: StackType.DockerCompose,
|
||||
},
|
||||
{
|
||||
apiVersion: 1.26,
|
||||
}
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('stack-prune-services-switch')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide Prune field for Kubernetes stack', () => {
|
||||
renderComponent({
|
||||
stackType: StackType.Kubernetes,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('stack-prune-services-switch')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Unskip these tests once WebhookFieldset authorization is properly mocked
|
||||
// These tests fail because WebhookFieldset has complex authorization checks
|
||||
// (PortainerWebhookCreate, PortainerWebhookList, PortainerWebhookDelete)
|
||||
// that aren't properly set up in the test environment
|
||||
describe.skip('conditional rendering - Webhook field', () => {
|
||||
it('should show WebhookFieldset for Docker environment', () => {
|
||||
renderComponent({
|
||||
envType: EnvironmentType.Docker,
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Webhook/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show WebhookFieldset for KubernetesLocal environment', () => {
|
||||
renderComponent({
|
||||
envType: EnvironmentType.KubernetesLocal,
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Webhook/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide WebhookFieldset for EdgeAgentOnDocker environment', () => {
|
||||
renderComponent({
|
||||
envType: EnvironmentType.EdgeAgentOnDocker,
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/Webhook/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('orphaned stack behavior', () => {
|
||||
it('should disable CodeEditor when stack is orphaned', () => {
|
||||
renderComponent({ isOrphaned: true });
|
||||
|
||||
const editor = screen.getByTestId('stack-editor');
|
||||
expect(editor).toHaveAttribute('readonly');
|
||||
});
|
||||
|
||||
it('should disable deploy button when stack is orphaned', async () => {
|
||||
renderComponent({ isOrphaned: true });
|
||||
|
||||
await waitFor(() => {
|
||||
const deployButton = screen.queryByTestId('stack-deploy-button');
|
||||
|
||||
expect(deployButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable CodeEditor when stack is not orphaned', async () => {
|
||||
renderComponent({ isOrphaned: false });
|
||||
|
||||
const editor = screen.getByTestId('stack-editor');
|
||||
await waitFor(() => {
|
||||
expect(editor).not.toHaveAttribute('readonly');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('form field updates', () => {
|
||||
it('should update stackFileContent when CodeEditor changes', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderComponent({}, { onSubmit });
|
||||
const user = userEvent.setup();
|
||||
|
||||
const editor = screen.getByTestId('stack-editor');
|
||||
await waitFor(() => {
|
||||
expect(editor).not.toHaveAttribute('readonly');
|
||||
});
|
||||
|
||||
await user.clear(editor);
|
||||
await user.type(editor, 'version: "3.8"');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editor).toHaveValue('version: "3.8"');
|
||||
});
|
||||
});
|
||||
|
||||
it('should update prune field when SwitchField changes', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderComponent(
|
||||
{
|
||||
stackType: StackType.DockerSwarm,
|
||||
},
|
||||
{ onSubmit }
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('stack-prune-services-switch')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const pruneSwitchField = screen.getByTestId('stack-prune-services-switch');
|
||||
await user.click(pruneSwitchField);
|
||||
|
||||
const pruneSwitch = pruneSwitchField.querySelector(
|
||||
'input[type="checkbox"]'
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pruneSwitch).toBeChecked();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('version rollback', () => {
|
||||
it('should call useVersionedStackFile with rollbackTo value', () => {
|
||||
const mockUseVersionedStackFile = vi.mocked(useVersionedStackFile);
|
||||
const initialValues = {
|
||||
...defaultInitialValues,
|
||||
rollbackTo: 2,
|
||||
};
|
||||
|
||||
renderComponent({ stackId: 5 }, { initialValues });
|
||||
|
||||
expect(mockUseVersionedStackFile).toHaveBeenCalledWith({
|
||||
stackId: 5,
|
||||
version: 2,
|
||||
onLoad: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should update stackFileContent when version loaded', () => {
|
||||
const mockUseVersionedStackFile = vi.mocked(useVersionedStackFile);
|
||||
let capturedOnLoad: ((content: string) => void) | undefined;
|
||||
|
||||
mockUseVersionedStackFile.mockImplementation(({ onLoad }) => {
|
||||
capturedOnLoad = onLoad;
|
||||
|
||||
return {
|
||||
content: '',
|
||||
isLoading: false,
|
||||
};
|
||||
});
|
||||
|
||||
renderComponent({ versions: [3, 2, 1] });
|
||||
|
||||
expect(capturedOnLoad).toBeDefined();
|
||||
|
||||
// Simulate loading a different version
|
||||
if (capturedOnLoad) {
|
||||
capturedOnLoad('version: "2"\nservices:\n db:\n image: postgres');
|
||||
}
|
||||
|
||||
// The form value should be updated through setFieldValue
|
||||
// This would be verified by checking the editor value in a full integration test
|
||||
});
|
||||
|
||||
it('should set rollbackTo when version selected and multiple versions available', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const versions = [3, 2, 1];
|
||||
renderComponent({ versions }, { onSubmit });
|
||||
const user = userEvent.setup();
|
||||
|
||||
const versionSelect = screen.getByRole('combobox', { name: /version/i });
|
||||
await user.selectOptions(versionSelect, '2');
|
||||
|
||||
await waitFor(() => {
|
||||
// Check that the form value was updated (through Formik)
|
||||
expect(versionSelect).toHaveValue('2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('form submission', () => {
|
||||
it('should enable submit button when form is valid', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
const deployButton = screen.getByTestId('stack-deploy-button');
|
||||
expect(deployButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable submit button when stack is orphaned', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderComponent({ isOrphaned: true }, { onSubmit });
|
||||
|
||||
await waitFor(() => {
|
||||
const deployButton = screen.queryByTestId('stack-deploy-button');
|
||||
|
||||
expect(deployButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show loading text during submission', async () => {
|
||||
renderComponent({ isSubmitting: true }, {});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Deployment in progress.../)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onSubmit with form values when button is clicked', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderComponent({}, { onSubmit });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await waitFor(() => {
|
||||
const deployButton = screen.getByTestId('stack-deploy-button');
|
||||
expect(deployButton).toBeEnabled();
|
||||
});
|
||||
|
||||
const deployButton = screen.getByTestId('stack-deploy-button');
|
||||
await user.click(deployButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorization', () => {
|
||||
it('should hide Prune field when user lacks PortainerStackUpdate authorization', () => {
|
||||
const unauthorizedUser = createMockUsers(1, Role.Standard)[0];
|
||||
|
||||
renderComponent(
|
||||
{
|
||||
stackType: StackType.DockerSwarm,
|
||||
},
|
||||
{ user: unauthorizedUser }
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('stack-prune-services-switch')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide FormActions when user lacks PortainerStackUpdate authorization', () => {
|
||||
const unauthorizedUser = createMockUsers(1, Role.Standard)[0];
|
||||
|
||||
renderComponent({}, { user: unauthorizedUser });
|
||||
|
||||
expect(screen.queryByTestId('stack-deploy-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSaved state', () => {
|
||||
it('should not prevent exit when isSaved is true', () => {
|
||||
const mockUsePreventExit = vi.mocked(usePreventExit);
|
||||
renderComponent({ isSaved: true });
|
||||
|
||||
expect(mockUsePreventExit).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
false // Should be false when isSaved is true
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent exit when isSaved is false and form is dirty', () => {
|
||||
const mockUsePreventExit = vi.mocked(usePreventExit);
|
||||
const initialValues = {
|
||||
...defaultInitialValues,
|
||||
stackFileContent: 'original content',
|
||||
};
|
||||
|
||||
renderComponent({ isSaved: false }, { initialValues });
|
||||
|
||||
expect(mockUsePreventExit).toHaveBeenCalledWith(
|
||||
'original content',
|
||||
'original content',
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to render StackEditorTabInner with Formik wrapper
|
||||
*/
|
||||
function renderComponent(
|
||||
props: Partial<ComponentProps<typeof StackEditorTabInner>> = {},
|
||||
{
|
||||
apiVersion = 1.47,
|
||||
onSubmit = vi.fn(),
|
||||
initialValues = defaultInitialValues,
|
||||
validationErrors = {},
|
||||
user = createMockUser({ Role: Role.Admin }), // Default admin user
|
||||
}: {
|
||||
apiVersion?: number;
|
||||
onSubmit?: (values: StackEditorFormValues) => void | Promise<void>;
|
||||
initialValues?: StackEditorFormValues;
|
||||
validationErrors?: Partial<Record<keyof StackEditorFormValues, string>>;
|
||||
user?: ReturnType<typeof createMockUser>;
|
||||
} = {}
|
||||
) {
|
||||
server.use(
|
||||
http.get('/api/endpoints/:endpointId/docker/version', () =>
|
||||
HttpResponse.json({ ApiVersion: String(apiVersion) })
|
||||
)
|
||||
);
|
||||
|
||||
// Create validation function that returns errors
|
||||
function validate() {
|
||||
return validationErrors;
|
||||
}
|
||||
|
||||
const Component = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(StackEditorTabInner, user))
|
||||
);
|
||||
|
||||
return render(
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
validate={validate}
|
||||
enableReinitialize
|
||||
>
|
||||
<Component {...defaultProps} {...props} />
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Form, useFormikContext } from 'formik';
|
||||
import { JSONSchema7 } from 'json-schema';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { Stack, StackType } from '@/react/common/stacks/types';
|
||||
import { PruneField } from '@/react/common/stacks/PruneField';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
|
||||
|
||||
import { CodeEditor } from '@@/CodeEditor';
|
||||
import { StackEnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
import { usePreventExit } from '@@/WebEditorForm';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { WebhookFieldset } from '../../common/WebhookFieldset';
|
||||
|
||||
import { StackEditorFormValues } from './StackEditorTab.types';
|
||||
import { useVersionedStackFile } from './useVersionedStackFile';
|
||||
|
||||
interface StackEditorTabInnerProps {
|
||||
stackType: StackType | undefined;
|
||||
composeSyntaxMaxVersion: number;
|
||||
envType: EnvironmentType;
|
||||
schema: JSONSchema7;
|
||||
isOrphaned: boolean;
|
||||
versions?: Array<number>;
|
||||
stackId: Stack['Id'];
|
||||
isSaved: boolean;
|
||||
isSubmitting: boolean;
|
||||
webhookId: string;
|
||||
}
|
||||
|
||||
export function StackEditorTabInner({
|
||||
stackType,
|
||||
composeSyntaxMaxVersion,
|
||||
envType,
|
||||
schema,
|
||||
isOrphaned,
|
||||
versions,
|
||||
stackId,
|
||||
isSaved,
|
||||
isSubmitting,
|
||||
webhookId,
|
||||
}: StackEditorTabInnerProps) {
|
||||
const { authorized: isAuthorizedToUpdate } = useAuthorizations(
|
||||
'PortainerStackUpdate'
|
||||
);
|
||||
|
||||
const { values, errors, setFieldValue, isValid, initialValues } =
|
||||
useFormikContext<StackEditorFormValues>();
|
||||
|
||||
usePreventExit(
|
||||
initialValues.stackFileContent,
|
||||
values.stackFileContent,
|
||||
!isSubmitting && !isSaved
|
||||
);
|
||||
|
||||
const handleLoadFile = useCallback(
|
||||
(content: string) => {
|
||||
setFieldValue('stackFileContent', content);
|
||||
},
|
||||
[setFieldValue]
|
||||
);
|
||||
|
||||
useVersionedStackFile({
|
||||
stackId,
|
||||
version: values.rollbackTo,
|
||||
onLoad: handleLoadFile,
|
||||
});
|
||||
|
||||
const isDeployDisabled = isOrphaned;
|
||||
|
||||
return (
|
||||
<Form className="form-horizontal">
|
||||
{/* Docker Compose Info Section */}
|
||||
<div className="form-group mb-0 space-y-2">
|
||||
{stackType === StackType.DockerCompose &&
|
||||
composeSyntaxMaxVersion === 2 && (
|
||||
<span className="col-sm-12 text-muted small">
|
||||
This stack will be deployed using the equivalent of{' '}
|
||||
<code>docker compose</code>. Only Compose file format version{' '}
|
||||
<b>2</b> is supported at the moment.
|
||||
</span>
|
||||
)}
|
||||
{stackType === StackType.DockerCompose &&
|
||||
composeSyntaxMaxVersion > 2 && (
|
||||
<span className="col-sm-12 text-muted small">
|
||||
This stack will be deployed using <code>docker compose</code>.
|
||||
</span>
|
||||
)}
|
||||
<span className="col-sm-12 text-muted small">
|
||||
You can get more information about Compose file format in the{' '}
|
||||
<a
|
||||
href="https://docs.docker.com/compose/compose-file/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
official documentation
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<div className="col-sm-12">
|
||||
{errors.stackFileContent && (
|
||||
<FormError>{errors.stackFileContent}</FormError>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<CodeEditor
|
||||
id="stack-editor"
|
||||
textTip="Define or paste the content of your docker compose file here"
|
||||
type="yaml"
|
||||
onChange={(value) => setFieldValue('stackFileContent', value)}
|
||||
value={values.stackFileContent}
|
||||
readonly={isOrphaned || !isAuthorizedToUpdate}
|
||||
schema={schema}
|
||||
data-cy="stack-editor"
|
||||
onVersionChange={handleVersionChange}
|
||||
versions={versions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StackEnvironmentVariablesPanel
|
||||
values={values.environmentVariables}
|
||||
onChange={(envVars) => setFieldValue('environmentVariables', envVars)}
|
||||
errors={errors.environmentVariables}
|
||||
showHelpMessage
|
||||
isFoldable
|
||||
/>
|
||||
|
||||
{envType !== EnvironmentType.EdgeAgentOnDocker && (
|
||||
<WebhookFieldset
|
||||
onChange={(value) => setFieldValue('enabledWebhook', value)}
|
||||
value={values.enabledWebhook}
|
||||
webhookId={webhookId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Authorized authorizations="PortainerStackUpdate">
|
||||
<PruneField
|
||||
stackType={stackType}
|
||||
checked={values.prune}
|
||||
onChange={(checked) => setFieldValue('prune', checked)}
|
||||
/>
|
||||
</Authorized>
|
||||
|
||||
<Authorized authorizations="PortainerStackUpdate">
|
||||
<FormActions
|
||||
isValid={isValid && !isDeployDisabled}
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Deployment in progress..."
|
||||
submitLabel="Update the stack"
|
||||
data-cy="stack-deploy-button"
|
||||
/>
|
||||
</Authorized>
|
||||
</Form>
|
||||
);
|
||||
|
||||
async function handleVersionChange(newVersion: number) {
|
||||
if (versions && versions.length > 1) {
|
||||
setFieldValue(
|
||||
'rollbackTo',
|
||||
newVersion < versions[0] ? newVersion : versions[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { server } from '@/setup-tests/server';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||
|
||||
import { useVersionedStackFile } from './useVersionedStackFile';
|
||||
|
||||
describe('useVersionedStackFile', () => {
|
||||
const defaultStackId = 1;
|
||||
const defaultVersion = 2;
|
||||
const mockOnLoad = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMswHandlers();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should return loading state initially when version is provided', () => {
|
||||
const { result } = renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: defaultVersion,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not fetch when version is undefined', () => {
|
||||
let fetchAttempted = false;
|
||||
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', () => {
|
||||
fetchAttempted = true;
|
||||
return HttpResponse.json({
|
||||
StackFileContent: 'version: "3"',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: undefined,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
expect(fetchAttempted).toBe(false);
|
||||
expect(mockOnLoad).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not fetch when version is empty string', () => {
|
||||
let fetchAttempted = false;
|
||||
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', () => {
|
||||
fetchAttempted = true;
|
||||
return HttpResponse.json({
|
||||
StackFileContent: 'version: "3"',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
expect(fetchAttempted).toBe(false);
|
||||
expect(mockOnLoad).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful data fetching', () => {
|
||||
it('should fetch stack file content when version is provided', async () => {
|
||||
const stackContent = 'version: "3"\nservices:\n web:\n image: nginx';
|
||||
|
||||
setupMswHandlers({ stackContent });
|
||||
|
||||
const { result } = renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: defaultVersion,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.content).toBe(stackContent);
|
||||
});
|
||||
|
||||
it('should call onLoad callback with content when data is fetched successfully', async () => {
|
||||
const stackContent =
|
||||
'version: "3.8"\nservices:\n db:\n image: postgres';
|
||||
|
||||
setupMswHandlers({ stackContent });
|
||||
|
||||
renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: defaultVersion,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnLoad).toHaveBeenCalledWith(stackContent);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onLoad only once for the same data', async () => {
|
||||
const stackContent = 'version: "3"';
|
||||
|
||||
setupMswHandlers({ stackContent });
|
||||
|
||||
renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: defaultVersion,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnLoad).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Wait a bit to ensure no additional calls
|
||||
await waitFor(() => expect(true).toBe(true));
|
||||
|
||||
expect(mockOnLoad).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should include version parameter in API request', async () => {
|
||||
let capturedVersion: string | null = null;
|
||||
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
capturedVersion = url.searchParams.get('version');
|
||||
|
||||
return HttpResponse.json({
|
||||
StackFileContent: 'version: "3"',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: 5,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedVersion).toBe('5');
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch from correct stack ID endpoint', async () => {
|
||||
let capturedStackId: string | null = null;
|
||||
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', ({ params }) => {
|
||||
capturedStackId = params.id as string;
|
||||
|
||||
return HttpResponse.json({
|
||||
StackFileContent: 'version: "3"',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
renderHookWithProviders({
|
||||
stackId: 42,
|
||||
version: defaultVersion,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedStackId).toBe('42');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('version changes', () => {
|
||||
it('should refetch when version changes', async () => {
|
||||
const firstContent = 'version: "3"\nservices:\n web:\n image: nginx';
|
||||
const secondContent =
|
||||
'version: "2"\nservices:\n web:\n image: apache';
|
||||
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const version = url.searchParams.get('version');
|
||||
|
||||
if (version === '3') {
|
||||
return HttpResponse.json({
|
||||
StackFileContent: firstContent,
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
StackFileContent: secondContent,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const { rerender } = renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: 3,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnLoad).toHaveBeenCalledWith(firstContent);
|
||||
});
|
||||
|
||||
expect(mockOnLoad).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change version to 2
|
||||
rerender({
|
||||
stackId: defaultStackId,
|
||||
version: 2,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnLoad).toHaveBeenCalledWith(secondContent);
|
||||
});
|
||||
|
||||
expect(mockOnLoad).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should call onLoad with new content when version changes', async () => {
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const version = url.searchParams.get('version');
|
||||
|
||||
return HttpResponse.json({
|
||||
StackFileContent: `content for version ${version}`,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const { rerender } = renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: 1,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnLoad).toHaveBeenCalledWith('content for version 1');
|
||||
});
|
||||
|
||||
mockOnLoad.mockClear();
|
||||
|
||||
// Change to version 2
|
||||
rerender({
|
||||
stackId: defaultStackId,
|
||||
version: 2,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnLoad).toHaveBeenCalledWith('content for version 2');
|
||||
});
|
||||
});
|
||||
|
||||
it('should stop fetching when version becomes undefined', async () => {
|
||||
let fetchCount = 0;
|
||||
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', () => {
|
||||
fetchCount++;
|
||||
return HttpResponse.json({
|
||||
StackFileContent: 'version: "3"',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const { rerender } = renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: 3,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchCount).toBe(1);
|
||||
});
|
||||
|
||||
const initialFetchCount = fetchCount;
|
||||
|
||||
// Change version to undefined
|
||||
rerender({
|
||||
stackId: defaultStackId,
|
||||
version: undefined,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
// Wait to ensure no new fetch
|
||||
await waitFor(() => expect(true).toBe(true));
|
||||
|
||||
expect(fetchCount).toBe(initialFetchCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
const restoreConsole = suppressConsoleLogs();
|
||||
afterAll(restoreConsole);
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', () =>
|
||||
HttpResponse.json({ message: 'Stack not found' }, { status: 404 })
|
||||
)
|
||||
);
|
||||
|
||||
const { result } = renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: defaultVersion,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// onLoad should not be called on error
|
||||
expect(mockOnLoad).not.toHaveBeenCalled();
|
||||
expect(result.current.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not call onLoad when StackFileContent is empty', async () => {
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', () =>
|
||||
HttpResponse.json({
|
||||
StackFileContent: '',
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: defaultVersion,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(true).toBe(true));
|
||||
|
||||
expect(mockOnLoad).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onLoad when StackFileContent is null', async () => {
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', () =>
|
||||
HttpResponse.json({
|
||||
StackFileContent: null,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: defaultVersion,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(true).toBe(true));
|
||||
|
||||
expect(mockOnLoad).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onLoad when StackFileContent is missing from response', async () => {
|
||||
server.use(http.get('/api/stacks/:id/file', () => HttpResponse.json({})));
|
||||
|
||||
renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: defaultVersion,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(true).toBe(true));
|
||||
|
||||
expect(mockOnLoad).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading states', () => {
|
||||
it('should show loading state while fetching', () => {
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', async () => {
|
||||
// Delay response
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
return HttpResponse.json({
|
||||
StackFileContent: 'version: "3"',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: defaultVersion,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it('should clear loading state after successful fetch', async () => {
|
||||
setupMswHandlers();
|
||||
|
||||
const { result } = renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: defaultVersion,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear loading state after failed fetch', async () => {
|
||||
const restoreConsole = suppressConsoleLogs();
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', () =>
|
||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
|
||||
const { result } = renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: defaultVersion,
|
||||
onLoad: mockOnLoad,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
restoreConsole();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLoad callback stability', () => {
|
||||
it('should handle onLoad callback changes without refetching', async () => {
|
||||
let fetchCount = 0;
|
||||
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', () => {
|
||||
fetchCount++;
|
||||
return HttpResponse.json({
|
||||
StackFileContent: 'version: "3"',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const firstCallback = vi.fn();
|
||||
const { rerender } = renderHookWithProviders({
|
||||
stackId: defaultStackId,
|
||||
version: defaultVersion,
|
||||
onLoad: firstCallback,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(firstCallback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const initialFetchCount = fetchCount;
|
||||
const secondCallback = vi.fn();
|
||||
|
||||
// Change callback
|
||||
rerender({
|
||||
stackId: defaultStackId,
|
||||
version: defaultVersion,
|
||||
onLoad: secondCallback,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// The new callback should be called with existing data
|
||||
expect(secondCallback).toHaveBeenCalledWith('version: "3"');
|
||||
});
|
||||
|
||||
// But no new fetch should occur
|
||||
expect(fetchCount).toBe(initialFetchCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Setup MSW handlers for API requests
|
||||
*/
|
||||
function setupMswHandlers({
|
||||
stackContent = 'version: "3"\nservices:\n web:\n image: nginx',
|
||||
}: { stackContent?: string } = {}) {
|
||||
server.use(
|
||||
http.get('/api/stacks/:id/file', () =>
|
||||
HttpResponse.json({
|
||||
StackFileContent: stackContent,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to render hook with providers
|
||||
*/
|
||||
function renderHookWithProviders({
|
||||
stackId,
|
||||
version,
|
||||
onLoad,
|
||||
}: {
|
||||
stackId: number;
|
||||
version?: number;
|
||||
onLoad: (content: string) => void;
|
||||
}) {
|
||||
const Wrapper = withTestQueryProvider<{
|
||||
stackId: number;
|
||||
version?: number;
|
||||
onLoad: (content: string) => void;
|
||||
}>(({ children }) => <>{children}</>);
|
||||
|
||||
return renderHook(useVersionedStackFile, {
|
||||
initialProps: { stackId, version, onLoad },
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useStackFile } from '@/react/common/stacks/queries/useStackFile';
|
||||
import { Stack } from '@/react/common/stacks/types';
|
||||
|
||||
export function useVersionedStackFile({
|
||||
stackId,
|
||||
version,
|
||||
onLoad,
|
||||
}: {
|
||||
stackId: Stack['Id'];
|
||||
version?: number;
|
||||
onLoad(content: string): void;
|
||||
}) {
|
||||
const fileQuery = useStackFile(stackId, { version }, { enabled: !!version });
|
||||
useEffect(() => {
|
||||
if (fileQuery.isSuccess && fileQuery.data?.StackFileContent) {
|
||||
onLoad(fileQuery.data.StackFileContent);
|
||||
}
|
||||
}, [
|
||||
fileQuery.isSuccess,
|
||||
fileQuery.data?.StackFileContent,
|
||||
onLoad,
|
||||
version, // reload on version change
|
||||
]);
|
||||
|
||||
return {
|
||||
isLoading: fileQuery.isLoading,
|
||||
content: fileQuery.data?.StackFileContent,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user