chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:39 +08:00
commit a0df89c693
5252 changed files with 523444 additions and 0 deletions
@@ -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,
};
}