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 1 commit
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
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) => {
adhityamamallan marked this conversation as resolved.
Show resolved Hide resolved
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>>>;
adhityamamallan marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const mockWorkflowDetailsParams = {
adhityamamallan marked this conversation as resolved.
Show resolved Hide resolved
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();
adhityamamallan marked this conversation as resolved.
Show resolved Hide resolved
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(
adhityamamallan marked this conversation as resolved.
Show resolved Hide resolved
{ 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
Loading