Skip to content

Commit

Permalink
Add workflow actions impl
Browse files Browse the repository at this point in the history
  • Loading branch information
adhityamamallan committed Jan 15, 2025
1 parent 0dfd1c7 commit d24104c
Show file tree
Hide file tree
Showing 16 changed files with 387 additions and 97 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ export const mockWorkflowActionsConfig = [
subtitle: 'Mock cancel a workflow execution',
icon: MdHighlightOff,
getIsEnabled: () => true,
apiRoute: 'cancel',
onSuccess: (params) => {
params.sendNotification('Mock notification');
},
},
{
id: 'terminate',
label: 'Mock terminate',
subtitle: 'Mock terminate a workflow execution',
icon: MdPowerSettingsNew,
getIsEnabled: () => false,
apiRoute: 'terminate',
onSuccess: (params) => {
params.sendNotification('Mock notification');
},
},
] as const satisfies Array<WorkflowAction>;
] as const satisfies Array<WorkflowAction<NonNullable<unknown>>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const mockWorkflowDetailsParams = {
cluster: 'testCluster',
domain: 'testDomain',
workflowId: 'testWorkflowId',
runId: 'testRunId',
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,12 @@ import { act, render, screen, userEvent } from '@/test-utils/rtl';
import { describeWorkflowResponse } from '@/views/workflow-page/__fixtures__/describe-workflow-response';

import { mockWorkflowActionsConfig } from '../__fixtures__/workflow-actions-config';
import { mockWorkflowDetailsParams } from '../__fixtures__/workflow-details-params';
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
19 changes: 16 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,10 @@ const workflowActionsConfig = [
!getWorkflowIsCompleted(
workflow.workflowExecutionInfo?.closeEvent?.attributes ?? ''
),
apiRoute: 'cancel',
onSuccess: ({ sendNotification }) => {
sendNotification('Workflow cancellation has been requested.');
},
},
{
id: 'terminate',
Expand All @@ -24,7 +33,11 @@ const workflowActionsConfig = [
!getWorkflowIsCompleted(
workflow.workflowExecutionInfo?.closeEvent?.attributes ?? ''
),
apiRoute: 'terminate',
onSuccess: ({ sendNotification }) => {
sendNotification('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,114 @@
import { HttpResponse } from 'msw';

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

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

import { mockWorkflowActionsConfig } from '../../__fixtures__/workflow-actions-config';
import { mockWorkflowDetailsParams } from '../../__fixtures__/workflow-details-params';
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, mockCancelWorkflow } = setup({});

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

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

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

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

expect(mockCancelWorkflow).toHaveBeenCalled();
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();
const mockCancelWorkflow = 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: () => {
mockCancelWorkflow();
if (error) {
return HttpResponse.json(
{ message: 'Failed to cancel workflow' },
{ status: 500 }
);
}
return HttpResponse.json({} satisfies CancelWorkflowResponse);
},
},
],
}
);

return { user, mockOnClose, mockCancelWorkflow };
}
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,121 @@
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 logger from '@/utils/logger';
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 { mutateAsync, 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),
},
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={async () => {
mutateAsync(params).then(
(result) => {
const {
// TODO: input,
...workflowDetailsParams
} = params;

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

onCloseModal();

action.onSuccess({
result,
inputParams: params,
sendNotification: (message, duration) =>
enqueue(
{
message,
startEnhancer: MdCheckCircle,
actionMessage: 'OK',
actionOnClick: () => dequeue(),
},
duration
),
});
},
(e) =>
logger.error(
{ error: e, params },
'Failed to perform workflow action'
)
);
}}
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 d24104c

Please sign in to comment.