Skip to content

Commit 71d6574

Browse files
authored
Add form support to workflow actions modal (#870)
* Add forms to workflow actions * add form to modal content
1 parent 32a7dc5 commit 71d6574

14 files changed

+325
-148
lines changed

src/views/workflow-actions/__fixtures__/workflow-actions-config.ts

Lines changed: 0 additions & 44 deletions
This file was deleted.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { MdHighlightOff, MdPowerSettingsNew, MdRefresh } from 'react-icons/md';
2+
import { z } from 'zod';
3+
4+
import { type CancelWorkflowResponse } from '@/route-handlers/cancel-workflow/cancel-workflow.types';
5+
import { type ResetWorkflowResponse } from '@/route-handlers/reset-workflow/reset-workflow.types';
6+
import { type TerminateWorkflowResponse } from '@/route-handlers/terminate-workflow/terminate-workflow.types';
7+
8+
import { type WorkflowAction } from '../workflow-actions.types';
9+
10+
export const mockWorkflowActionsConfig: [
11+
WorkflowAction<any, any, CancelWorkflowResponse>,
12+
WorkflowAction<any, any, TerminateWorkflowResponse>,
13+
WorkflowAction<
14+
{ testField: string },
15+
{ transformed: string },
16+
ResetWorkflowResponse
17+
>,
18+
] = [
19+
{
20+
id: 'cancel',
21+
label: 'Mock cancel',
22+
subtitle: 'Mock cancel a workflow execution',
23+
modal: {
24+
text: 'Mock modal text to cancel a workflow execution',
25+
docsLink: {
26+
text: 'Mock docs link',
27+
href: 'https://mock.docs.link',
28+
},
29+
},
30+
icon: MdHighlightOff,
31+
getRunnableStatus: () => 'RUNNABLE',
32+
apiRoute: 'cancel',
33+
renderSuccessMessage: () => 'Mock cancel notification',
34+
},
35+
{
36+
id: 'terminate',
37+
label: 'Mock terminate',
38+
subtitle: 'Mock terminate a workflow execution',
39+
modal: {
40+
text: 'Mock modal text to terminate a workflow execution',
41+
docsLink: {
42+
text: 'Mock docs link',
43+
href: 'https://mock.docs.link',
44+
},
45+
},
46+
icon: MdPowerSettingsNew,
47+
getRunnableStatus: () => 'RUNNABLE',
48+
apiRoute: 'terminate',
49+
renderSuccessMessage: () => 'Mock terminate notification',
50+
},
51+
{
52+
id: 'restart', // TODO: rename to reset
53+
label: 'Mock reset',
54+
subtitle: 'Mock reset a workflow execution',
55+
modal: {
56+
text: 'Mock modal text to reset a workflow execution',
57+
docsLink: {
58+
text: 'Mock docs link',
59+
href: 'https://mock.docs.link',
60+
},
61+
form: ({ control, fieldErrors }) => (
62+
<div data-testid="mock-form">
63+
<input
64+
data-testid="test-input"
65+
aria-invalid={!!fieldErrors.testField}
66+
{...control.register('testField')}
67+
/>
68+
</div>
69+
),
70+
formSchema: z.object({
71+
testField: z.string().min(1),
72+
}),
73+
transformFormDataToSubmission: (data: { testField: string }) => ({
74+
transformed: data.testField,
75+
}),
76+
},
77+
icon: MdRefresh,
78+
getRunnableStatus: () => 'RUNNABLE',
79+
apiRoute: 'reset',
80+
renderSuccessMessage: ({ result }) =>
81+
`Mock reset notification (Run ID: ${result.runId})`,
82+
},
83+
] as const;

src/views/workflow-actions/config/workflow-actions.config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import WorkflowActionNewRunSuccessMsg from '../workflow-action-new-run-success-m
1515
import { type WorkflowAction } from '../workflow-actions.types';
1616

1717
const workflowActionsConfig: [
18-
WorkflowAction<CancelWorkflowResponse>,
19-
WorkflowAction<TerminateWorkflowResponse>,
20-
WorkflowAction<RestartWorkflowResponse>,
18+
WorkflowAction<any, any, CancelWorkflowResponse>,
19+
WorkflowAction<any, any, TerminateWorkflowResponse>,
20+
WorkflowAction<any, any, RestartWorkflowResponse>,
2121
] = [
2222
{
2323
id: 'cancel',

src/views/workflow-actions/workflow-action-new-run-success-msg/__tests__/workflow-action-new-run-success-msg.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ describe('WorkflowActionNewRunSuccessMsg', () => {
1414
cluster: 'test-cluster',
1515
workflowId: 'test-workflow-id',
1616
runId: 'test-run-id',
17+
submissionData: null,
1718
},
1819
successMessage: 'Workflow has been restarted.',
1920
};
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { type WorkflowActionSuccessMessageProps } from '../workflow-actions.types';
22

3-
export type Props = WorkflowActionSuccessMessageProps<{ runId: string }> & {
3+
export type Props = WorkflowActionSuccessMessageProps<
4+
any,
5+
{ runId: string }
6+
> & {
47
successMessage: string;
58
};

src/views/workflow-actions/workflow-actions-menu/__tests__/workflow-actions-menu.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe(WorkflowActionsMenu.name, () => {
4141
});
4242

4343
const menuButtons = screen.getAllByRole('button');
44-
expect(menuButtons).toHaveLength(2);
44+
expect(menuButtons).toHaveLength(3);
4545

4646
expect(within(menuButtons[0]).getByText('Mock cancel')).toBeInTheDocument();
4747
expect(
@@ -68,7 +68,7 @@ describe(WorkflowActionsMenu.name, () => {
6868
});
6969

7070
const menuButtons = screen.getAllByRole('button');
71-
expect(menuButtons).toHaveLength(2);
71+
expect(menuButtons).toHaveLength(3);
7272

7373
expect(within(menuButtons[0]).getByText('Mock cancel')).toBeInTheDocument();
7474
expect(
@@ -96,7 +96,7 @@ describe(WorkflowActionsMenu.name, () => {
9696
});
9797

9898
const menuButtons = screen.getAllByRole('button');
99-
expect(menuButtons).toHaveLength(2);
99+
expect(menuButtons).toHaveLength(3);
100100

101101
expect(within(menuButtons[0]).getByText('Mock cancel')).toBeInTheDocument();
102102
expect(
@@ -119,7 +119,7 @@ describe(WorkflowActionsMenu.name, () => {
119119
});
120120

121121
const menuButtons = screen.getAllByRole('button');
122-
expect(menuButtons).toHaveLength(2);
122+
expect(menuButtons).toHaveLength(3);
123123

124124
await user.click(menuButtons[0]);
125125
expect(mockOnActionSelect).toHaveBeenCalledWith(

src/views/workflow-actions/workflow-actions-menu/workflow-actions-menu.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ import { type WorkflowAction } from '../workflow-actions.types';
66
export type Props = {
77
workflow: DescribeWorkflowResponse;
88
actionsEnabledConfig?: WorkflowActionsEnabledConfig;
9-
onActionSelect: (action: WorkflowAction<any>) => void;
9+
onActionSelect: (action: WorkflowAction<any, any, any>) => void;
1010
};

src/views/workflow-actions/workflow-actions-modal-content/__tests__/workflow-actions-modal-content.test.tsx

Lines changed: 94 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { HttpResponse } from 'msw';
22

3-
import { render, screen, userEvent } from '@/test-utils/rtl';
3+
import { render, screen, userEvent, waitFor } from '@/test-utils/rtl';
44

55
import { type CancelWorkflowResponse } from '@/route-handlers/cancel-workflow/cancel-workflow.types';
6-
import { type RestartWorkflowResponse } from '@/route-handlers/restart-workflow/restart-workflow.types';
7-
import { type TerminateWorkflowResponse } from '@/route-handlers/terminate-workflow/terminate-workflow.types';
6+
import { type ResetWorkflowResponse } from '@/route-handlers/reset-workflow/reset-workflow.types';
87
import { mockWorkflowDetailsParams } from '@/views/workflow-page/__fixtures__/workflow-details-params';
98

109
import { mockWorkflowActionsConfig } from '../../__fixtures__/workflow-actions-config';
@@ -21,6 +20,8 @@ jest.mock('baseui/snackbar', () => ({
2120
}),
2221
}));
2322

23+
const mockResetAction = mockWorkflowActionsConfig[2];
24+
2425
describe(WorkflowActionsModalContent.name, () => {
2526
beforeEach(() => {
2627
jest.clearAllMocks();
@@ -41,11 +42,11 @@ describe(WorkflowActionsModalContent.name, () => {
4142
expect(docsLink).toHaveAttribute('href', 'https://mock.docs.link');
4243
});
4344

44-
it('calls onCloseModal when the Go Back button is clicked', async () => {
45+
it('calls onCloseModal when the Cancel button is clicked', async () => {
4546
const { user, mockOnClose } = setup({});
4647

47-
const goBackButton = await screen.findByText('Go back');
48-
await user.click(goBackButton);
48+
const cancelButton = await screen.findByText('Cancel');
49+
await user.click(cancelButton);
4950

5051
expect(mockOnClose).toHaveBeenCalled();
5152
});
@@ -58,11 +59,13 @@ describe(WorkflowActionsModalContent.name, () => {
5859
});
5960
await user.click(cancelButton);
6061

61-
expect(mockEnqueue).toHaveBeenCalledWith(
62-
expect.objectContaining({
63-
message: 'Mock cancel notification',
64-
})
65-
);
62+
await waitFor(() => {
63+
expect(mockEnqueue).toHaveBeenCalledWith(
64+
expect.objectContaining({
65+
message: 'Mock cancel notification',
66+
})
67+
);
68+
});
6669
expect(mockOnClose).toHaveBeenCalled();
6770
});
6871

@@ -74,9 +77,9 @@ describe(WorkflowActionsModalContent.name, () => {
7477
});
7578
await user.click(cancelButton);
7679

77-
expect(
78-
await screen.findByText('Failed to cancel workflow')
79-
).toBeInTheDocument();
80+
await waitFor(() => {
81+
expect(screen.getByText('Failed to cancel workflow')).toBeInTheDocument();
82+
});
8083
expect(mockOnClose).not.toHaveBeenCalled();
8184
});
8285

@@ -93,19 +96,74 @@ describe(WorkflowActionsModalContent.name, () => {
9396
expect(screen.getByText('First line of array text')).toBeInTheDocument();
9497
expect(screen.getByText('Second line of array text')).toBeInTheDocument();
9598
});
99+
100+
describe('form handling', () => {
101+
it('renders form when provided in action config', () => {
102+
setup({ actionConfig: mockResetAction });
103+
104+
expect(screen.getByTestId('mock-form')).toBeInTheDocument();
105+
expect(screen.getByTestId('test-input')).toBeInTheDocument();
106+
});
107+
108+
it('disables submit button when form has validation errors', async () => {
109+
const { user } = setup({ actionConfig: mockResetAction });
110+
111+
const submitButton = screen.getByRole('button', {
112+
name: 'Mock reset workflow',
113+
});
114+
await user.click(submitButton);
115+
116+
expect(submitButton).toHaveAttribute('disabled');
117+
});
118+
119+
it('forms recieves validation error message when field is invalid', async () => {
120+
const { user } = setup({ actionConfig: mockResetAction });
121+
122+
const submitButton = screen.getByRole('button', {
123+
name: 'Mock reset workflow',
124+
});
125+
await user.click(submitButton);
126+
127+
expect(screen.getByTestId('test-input')).toHaveAttribute(
128+
'aria-invalid',
129+
'true'
130+
);
131+
});
132+
133+
it('transforms form data before submission', async () => {
134+
const { user, getLatestRequestBody, waitForRequest } = setup({
135+
actionConfig: mockResetAction,
136+
});
137+
138+
const input = screen.getByTestId('test-input');
139+
await user.type(input, 'test value');
140+
141+
const submitButton = screen.getByRole('button', {
142+
name: 'Mock reset workflow',
143+
});
144+
await user.click(submitButton);
145+
146+
await waitForRequest();
147+
148+
expect(getLatestRequestBody()).toEqual({ transformed: 'test value' });
149+
});
150+
});
96151
});
97152

98153
function setup({
99154
error,
100155
actionConfig,
101156
}: {
102157
error?: boolean;
103-
actionConfig?: WorkflowAction<
104-
CancelWorkflowResponse | TerminateWorkflowResponse | RestartWorkflowResponse
105-
>;
158+
actionConfig?: WorkflowAction<any, any, any>;
106159
}) {
107160
const user = userEvent.setup();
108161
const mockOnClose = jest.fn();
162+
let latestRequestBody: any = null;
163+
let requestPromiseResolve = (v: unknown) => v;
164+
const requestPromise = new Promise((resolve) => {
165+
requestPromiseResolve = resolve;
166+
});
109167

110168
render(
111169
<WorkflowActionsModalContent
@@ -116,22 +174,38 @@ function setup({
116174
{
117175
endpointsMocks: [
118176
{
119-
path: '/api/domains/:domain/:cluster/workflows/:workflowId/:runId/cancel',
177+
path: '/api/domains/:domain/:cluster/workflows/:workflowId/:runId/:action',
120178
httpMethod: 'POST',
121179
mockOnce: false,
122-
httpResolver: () => {
180+
httpResolver: async ({ request }) => {
181+
// Capture the request body
182+
const text = await request.text();
183+
latestRequestBody = text ? JSON.parse(text) : null;
184+
requestPromiseResolve(null);
185+
123186
if (error) {
124187
return HttpResponse.json(
125188
{ message: 'Failed to cancel workflow' },
126189
{ status: 500 }
127190
);
128191
}
192+
193+
if (request.url.endsWith('/reset')) {
194+
return HttpResponse.json({
195+
runId: 'new-run-id',
196+
} satisfies ResetWorkflowResponse);
197+
}
129198
return HttpResponse.json({} satisfies CancelWorkflowResponse);
130199
},
131200
},
132201
],
133202
}
134203
);
135204

136-
return { user, mockOnClose };
205+
return {
206+
user,
207+
mockOnClose,
208+
getLatestRequestBody: () => latestRequestBody,
209+
waitForRequest: () => requestPromise,
210+
};
137211
}

0 commit comments

Comments
 (0)