Skip to content

Commit

Permalink
Implement workflow actions (#790)
Browse files Browse the repository at this point in the history
* Add workflow actions impl

* Resolve comments

* Remove unnecessary mock fn
  • Loading branch information
adhityamamallan authored Jan 17, 2025
1 parent c7c4b1b commit 944d9a6
Show file tree
Hide file tree
Showing 16 changed files with 359 additions and 98 deletions.
14 changes: 12 additions & 2 deletions src/views/workflow-actions/__fixtures__/workflow-actions-config.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import { MdHighlightOff, MdPowerSettingsNew } from 'react-icons/md';

import { type CancelWorkflowResponse } from '@/route-handlers/cancel-workflow/cancel-workflow.types';
import { type TerminateWorkflowResponse } from '@/route-handlers/terminate-workflow/terminate-workflow.types';

import { type WorkflowAction } from '../workflow-actions.types';

export const mockWorkflowActionsConfig = [
export const mockWorkflowActionsConfig: [
WorkflowAction<CancelWorkflowResponse>,
WorkflowAction<TerminateWorkflowResponse>,
] = [
{
id: 'cancel',
label: 'Mock cancel',
subtitle: 'Mock cancel a workflow execution',
icon: MdHighlightOff,
getIsEnabled: () => true,
apiRoute: 'cancel',
getSuccessMessage: () => 'Mock cancel notification',
},
{
id: 'terminate',
label: 'Mock terminate',
subtitle: 'Mock terminate a workflow execution',
icon: MdPowerSettingsNew,
getIsEnabled: () => false,
apiRoute: 'terminate',
getSuccessMessage: () => 'Mock terminate notification',
},
] as const satisfies Array<WorkflowAction>;
] as const;
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,13 @@ import { act, render, screen, userEvent } from '@/test-utils/rtl';

import { describeWorkflowResponse } from '@/views/workflow-page/__fixtures__/describe-workflow-response';

import { mockWorkflowDetailsParams } from '../../workflow-page/__fixtures__/workflow-details-params';
import { mockWorkflowActionsConfig } from '../__fixtures__/workflow-actions-config';
import WorkflowActions from '../workflow-actions';

jest.mock('next/navigation', () => ({
...jest.requireActual('next/navigation'),
useParams: () => ({
cluster: 'testCluster',
domain: 'testDomain',
workflowId: 'testWorkflowId',
runId: 'testRunId',
}),
useParams: () => mockWorkflowDetailsParams,
}));

jest.mock('../workflow-actions-modal/workflow-actions-modal', () =>
Expand Down
15 changes: 12 additions & 3 deletions src/views/workflow-actions/config/workflow-actions.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { MdHighlightOff, MdPowerSettingsNew } from 'react-icons/md';

import getWorkflowIsCompleted from '@/views/workflow-page/helpers/get-workflow-is-completed';
import { type CancelWorkflowResponse } from '@/route-handlers/cancel-workflow/cancel-workflow.types';
import { type TerminateWorkflowResponse } from '@/route-handlers/terminate-workflow/terminate-workflow.types';

import getWorkflowIsCompleted from '../../workflow-page/helpers/get-workflow-is-completed';
import { type WorkflowAction } from '../workflow-actions.types';

const workflowActionsConfig = [
const workflowActionsConfig: [
WorkflowAction<CancelWorkflowResponse>,
WorkflowAction<TerminateWorkflowResponse>,
] = [
{
id: 'cancel',
label: 'Cancel',
Expand All @@ -14,6 +19,8 @@ const workflowActionsConfig = [
!getWorkflowIsCompleted(
workflow.workflowExecutionInfo?.closeEvent?.attributes ?? ''
),
apiRoute: 'cancel',
getSuccessMessage: () => 'Workflow cancellation has been requested.',
},
{
id: 'terminate',
Expand All @@ -24,7 +31,9 @@ const workflowActionsConfig = [
!getWorkflowIsCompleted(
workflow.workflowExecutionInfo?.closeEvent?.attributes ?? ''
),
apiRoute: 'terminate',
getSuccessMessage: () => 'Workflow has been terminated.',
},
] as const satisfies Array<WorkflowAction>;
] as const;

export default workflowActionsConfig;
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import { type WorkflowAction } from '../workflow-actions.types';

export type Props = {
workflow: DescribeWorkflowResponse;
onActionSelect: (action: WorkflowAction) => void;
onActionSelect: (action: WorkflowAction<any>) => void;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { HttpResponse } from 'msw';

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

import { type CancelWorkflowResponse } from '@/route-handlers/cancel-workflow/cancel-workflow.types';
import { mockWorkflowDetailsParams } from '@/views/workflow-page/__fixtures__/workflow-details-params';

import { mockWorkflowActionsConfig } from '../../__fixtures__/workflow-actions-config';
import WorkflowActionsModalContent from '../workflow-actions-modal-content';

const mockEnqueue = jest.fn();
const mockDequeue = jest.fn();
jest.mock('baseui/snackbar', () => ({
...jest.requireActual('baseui/snackbar'),
useSnackbar: () => ({
enqueue: mockEnqueue,
dequeue: mockDequeue,
}),
}));

describe(WorkflowActionsModalContent.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders the modal content as expected', async () => {
setup({});

expect(await screen.findAllByText('Mock cancel workflow')).toHaveLength(2);
expect(
screen.getByText('Mock cancel a workflow execution')
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Mock cancel workflow' })
).toBeInTheDocument();
});

it('calls onCloseModal when the Go Back button is clicked', async () => {
const { user, mockOnClose } = setup({});

const goBackButton = await screen.findByText('Go back');
await user.click(goBackButton);

expect(mockOnClose).toHaveBeenCalled();
});

it('calls mockCancelWorkflow, sends toast, and closes modal when the action button is clicked', async () => {
const { user, mockOnClose } = setup({});

const cancelButton = await screen.findByRole('button', {
name: 'Mock cancel workflow',
});
await user.click(cancelButton);

expect(mockEnqueue).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Mock cancel notification',
})
);
expect(mockOnClose).toHaveBeenCalled();
});

it('Displays banner when the action button is clicked and action fails', async () => {
const { user, mockOnClose } = setup({ error: true });

const cancelButton = await screen.findByRole('button', {
name: 'Mock cancel workflow',
});
await user.click(cancelButton);

expect(
await screen.findByText('Failed to cancel workflow')
).toBeInTheDocument();
expect(mockOnClose).not.toHaveBeenCalled();
});
});

function setup({ error }: { error?: boolean }) {
const user = userEvent.setup();
const mockOnClose = jest.fn();

render(
<WorkflowActionsModalContent
action={mockWorkflowActionsConfig[0]}
params={{ ...mockWorkflowDetailsParams }}
onCloseModal={mockOnClose}
/>,
{
endpointsMocks: [
{
path: '/api/domains/:domain/:cluster/workflows/:workflowId/:runId/cancel',
httpMethod: 'POST',
mockOnce: false,
httpResolver: () => {
if (error) {
return HttpResponse.json(
{ message: 'Failed to cancel workflow' },
{ status: 500 }
);
}
return HttpResponse.json({} satisfies CancelWorkflowResponse);
},
},
],
}
);

return { user, mockOnClose };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { type Theme, withStyle } from 'baseui';
import { type BannerOverrides } from 'baseui/banner';
import { ModalBody, ModalFooter, ModalHeader } from 'baseui/modal';

export const styled = {
ModalHeader: withStyle(ModalHeader, ({ $theme }: { $theme: Theme }) => ({
marginTop: $theme.sizing.scale850,
})),
ModalBody: withStyle(ModalBody, ({ $theme }: { $theme: Theme }) => ({
marginBottom: $theme.sizing.scale800,
})),
ModalFooter: withStyle(ModalFooter, {
display: 'flex',
justifyContent: 'space-between',
}),
};

export const overrides = {
banner: {
Root: {
style: {
marginLeft: 0,
marginRight: 0,
},
},
} satisfies BannerOverrides,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Banner, HIERARCHY, KIND as BANNER_KIND } from 'baseui/banner';
import { KIND as BUTTON_KIND, SIZE } from 'baseui/button';
import { ModalButton } from 'baseui/modal';
import { useSnackbar } from 'baseui/snackbar';
import { MdCheckCircle, MdErrorOutline } from 'react-icons/md';

import request from '@/utils/request';
import { type RequestError } from '@/utils/request/request-error';

import { type WorkflowActionInputParams } from '../workflow-actions.types';

import { overrides, styled } from './workflow-actions-modal-content.styles';
import { type Props } from './workflow-actions-modal-content.types';

export default function WorkflowActionsModalContent<R>({
action,
params,
onCloseModal,
}: Props<R>) {
const queryClient = useQueryClient();
const { enqueue, dequeue } = useSnackbar();
const { mutate, isPending, error } = useMutation<
R,
RequestError,
WorkflowActionInputParams
>(
{
mutationFn: ({
domain,
cluster,
workflowId,
runId,
}: WorkflowActionInputParams) =>
request(
`/api/domains/${domain}/${cluster}/workflows/${workflowId}/${runId}/${action.apiRoute}`,
{
method: 'POST',
body: JSON.stringify({
// TODO: pass the input here when implementing extended workflow actions
}),
}
).then((res) => res.json() as R),
onSuccess: (result, params) => {
const {
// TODO: input,
...workflowDetailsParams
} = params;

queryClient.invalidateQueries({
queryKey: ['describe_workflow', workflowDetailsParams],
});

onCloseModal();
enqueue({
message: action.getSuccessMessage(result, params),
startEnhancer: MdCheckCircle,
actionMessage: 'OK',
actionOnClick: () => dequeue(),
});
},
},
queryClient
);

return (
<>
<styled.ModalHeader>{action.label} workflow</styled.ModalHeader>
<styled.ModalBody>
{action.subtitle}
{error && (
<Banner
hierarchy={HIERARCHY.low}
kind={BANNER_KIND.negative}
overrides={overrides.banner}
artwork={{
icon: MdErrorOutline,
}}
>
{error.message}
</Banner>
)}
</styled.ModalBody>
<styled.ModalFooter>
<ModalButton
size={SIZE.compact}
kind={BUTTON_KIND.secondary}
onClick={onCloseModal}
>
Go back
</ModalButton>
<ModalButton
size={SIZE.compact}
kind={BUTTON_KIND.primary}
onClick={() => mutate(params)}
isLoading={isPending}
>
{action.label} workflow
</ModalButton>
</styled.ModalFooter>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {
type WorkflowAction,
type WorkflowActionInputParams,
} from '../workflow-actions.types';

export type Props<R> = {
action: WorkflowAction<R>;
params: WorkflowActionInputParams;
onCloseModal: () => void;
};
Loading

0 comments on commit 944d9a6

Please sign in to comment.