Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement workflow actions #790

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading