Skip to content

Commit

Permalink
Add config for workflow actions (#805)
Browse files Browse the repository at this point in the history
* Add actions config

* Add workflow actions config

* Add workflow actions loading

* Add unit tests and change getIsEnabled to getIsRunnable

* Fix fixture

* Remove default config

* fix test
  • Loading branch information
adhityamamallan authored Feb 4, 2025
1 parent 6befc76 commit 5f0a7f3
Show file tree
Hide file tree
Showing 14 changed files with 257 additions and 16 deletions.
19 changes: 18 additions & 1 deletion src/config/dynamic/dynamic.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'server-only';

import type {
ConfigAsyncResolverDefinition,
ConfigEnvDefinition,
ConfigSyncResolverDefinition,
} from '../../utils/config/config.types';
Expand All @@ -9,6 +10,11 @@ import clusters from './resolvers/clusters';
import clustersPublic from './resolvers/clusters-public';
import { type PublicClustersConfigs } from './resolvers/clusters-public.types';
import { type ClustersConfigs } from './resolvers/clusters.types';
import workflowActionsEnabled from './resolvers/workflow-actions-enabled';
import {
type WorkflowActionsEnabledResolverParams,
type WorkflowActionsEnabledConfig,
} from './resolvers/workflow-actions-enabled.types';

const dynamicConfigs: {
CADENCE_WEB_PORT: ConfigEnvDefinition;
Expand All @@ -24,10 +30,16 @@ const dynamicConfigs: {
'serverStart',
true
>;
WORKFLOW_ACTIONS_ENABLED: ConfigAsyncResolverDefinition<
WorkflowActionsEnabledResolverParams,
WorkflowActionsEnabledConfig,
'request',
true
>;
} = {
CADENCE_WEB_PORT: {
env: 'CADENCE_WEB_PORT',
//Fallback to nextjs default port if CADENCE_WEB_PORT is not provided
// Fallback to nextjs default port if CADENCE_WEB_PORT is not provided
default: '3000',
},
ADMIN_SECURITY_TOKEN: {
Expand All @@ -43,6 +55,11 @@ const dynamicConfigs: {
evaluateOn: 'serverStart',
isPublic: true,
},
WORKFLOW_ACTIONS_ENABLED: {
resolver: workflowActionsEnabled,
evaluateOn: 'request',
isPublic: true,
},
} as const;

export default dynamicConfigs;
10 changes: 10 additions & 0 deletions src/config/dynamic/resolvers/schemas/resolver-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ const resolverSchemas: ResolverSchemas = {
})
),
},
WORKFLOW_ACTIONS_ENABLED: {
args: z.object({
cluster: z.string(),
domain: z.string(),
}),
returnType: z.object({
cancel: z.boolean(),
terminate: z.boolean(),
}),
},
};

export default resolverSchemas;
17 changes: 17 additions & 0 deletions src/config/dynamic/resolvers/workflow-actions-enabled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {
type WorkflowActionsEnabledConfig,
type WorkflowActionsEnabledResolverParams,
} from './workflow-actions-enabled.types';

/**
* If you have authentication enabled for users, override this resolver
* to control whether users can access workflow actions in the UI
*/
export default async function workflowActionsEnabled(
_: WorkflowActionsEnabledResolverParams
): Promise<WorkflowActionsEnabledConfig> {
return {
terminate: true,
cancel: true,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { type WorkflowActionID } from '@/views/workflow-actions/workflow-actions.types';

export type WorkflowActionsEnabledResolverParams = {
domain: string;
cluster: string;
};

export type WorkflowActionsEnabledConfig = Record<WorkflowActionID, boolean>;
4 changes: 4 additions & 0 deletions src/utils/config/__fixtures__/resolved-config-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,9 @@ const mockResolvedConfigValues: LoadedConfigResolvedValues = {
clusterName: 'mock-cluster2',
},
],
WORKFLOW_ACTIONS_ENABLED: {
terminate: true,
cancel: true,
},
};
export default mockResolvedConfigValues;
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const mockWorkflowActionsConfig: [
},
},
icon: MdHighlightOff,
getIsEnabled: () => true,
getIsRunnable: () => true,
apiRoute: 'cancel',
getSuccessMessage: () => 'Mock cancel notification',
},
Expand All @@ -37,7 +37,7 @@ export const mockWorkflowActionsConfig: [
},
},
icon: MdPowerSettingsNew,
getIsEnabled: () => false,
getIsRunnable: () => false,
apiRoute: 'terminate',
getSuccessMessage: () => 'Mock terminate notification',
},
Expand Down
70 changes: 65 additions & 5 deletions src/views/workflow-actions/__tests__/workflow-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { Suspense } from 'react';

import { HttpResponse } from 'msw';

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

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

Expand All @@ -25,12 +25,18 @@ jest.mock('../workflow-actions-modal/workflow-actions-modal', () =>

jest.mock('../workflow-actions-menu/workflow-actions-menu', () =>
jest.fn((props) => {
const areAllActionsDisabled = props.actionsEnabledConfig
? Object.entries(props.actionsEnabledConfig).every(
([_, value]) => value === false
)
: true;

return (
<div
onClick={() => props.onActionSelect(mockWorkflowActionsConfig[0])}
data-testid="actions-menu"
>
Actions Menu{props.disabled ? ' (disabled)' : ''}
Actions Menu{areAllActionsDisabled ? ' (disabled)' : ''}
</div>
);
})
Expand All @@ -43,22 +49,48 @@ describe(WorkflowActions.name, () => {

it('renders the button with the correct text', async () => {
await setup({});

const actionsButton = await screen.findByRole('button');
expect(actionsButton).toHaveAttribute(
'aria-label',
expect.stringContaining('loading')
);

await waitFor(() => {
expect(actionsButton).not.toHaveAttribute(
'aria-label',
expect.stringContaining('loading')
);
});

expect(actionsButton).toHaveTextContent('Workflow Actions');
});

it('renders the menu when the button is clicked', async () => {
const { user } = await setup({});

await user.click(await screen.findByText('Workflow Actions'));
const actionsButton = await screen.findByRole('button');
await user.click(actionsButton);

expect(await screen.findByTestId('actions-menu')).toBeInTheDocument();
});

it('renders the button with disabled configs if config fetching fails', async () => {
const { user } = await setup({ isConfigError: true });

const actionsButton = await screen.findByRole('button');
await user.click(actionsButton);

const actionsMenu = await screen.findByTestId('actions-menu');
expect(actionsMenu).toBeInTheDocument();
expect(actionsMenu).toHaveTextContent('Actions Menu (disabled)');
});

it('shows the modal when a menu option is clicked', async () => {
const { user } = await setup({});

await user.click(await screen.findByText('Workflow Actions'));
const actionsButton = await screen.findByRole('button');
await user.click(actionsButton);
await user.click(await screen.findByTestId('actions-menu'));

expect(await screen.findByTestId('actions-modal')).toBeInTheDocument();
Expand All @@ -80,7 +112,13 @@ describe(WorkflowActions.name, () => {
});
});

async function setup({ isError }: { isError?: boolean }) {
async function setup({
isError,
isConfigError,
}: {
isError?: boolean;
isConfigError?: boolean;
}) {
const user = userEvent.setup();

const renderResult = render(
Expand All @@ -105,6 +143,28 @@ async function setup({ isError }: { isError?: boolean }) {
}
},
},
{
path: '/api/config',
httpMethod: 'GET',
httpResolver: () => {
if (isConfigError) {
return HttpResponse.json(
{ message: 'Failed to fetch config' },
{ status: 500 }
);
} else {
return HttpResponse.json(
{
terminate: true,
cancel: true,
},
{
status: 200,
}
);
}
},
},
],
}
);
Expand Down
4 changes: 2 additions & 2 deletions src/views/workflow-actions/config/workflow-actions.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const workflowActionsConfig: [
},
},
icon: MdHighlightOff,
getIsEnabled: (workflow) =>
getIsRunnable: (workflow) =>
!getWorkflowIsCompleted(
workflow.workflowExecutionInfo?.closeEvent?.attributes ?? ''
),
Expand All @@ -41,7 +41,7 @@ const workflowActionsConfig: [
},
},
icon: MdPowerSettingsNew,
getIsEnabled: (workflow) =>
getIsRunnable: (workflow) =>
!getWorkflowIsCompleted(
workflow.workflowExecutionInfo?.closeEvent?.attributes ?? ''
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';

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

import { type WorkflowActionsEnabledConfig } from '@/config/dynamic/resolvers/workflow-actions-enabled.types';
import { describeWorkflowResponse } from '@/views/workflow-page/__fixtures__/describe-workflow-response';

import { mockWorkflowActionsConfig } from '../../__fixtures__/workflow-actions-config';
Expand All @@ -18,7 +19,9 @@ describe(WorkflowActionsMenu.name, () => {
});

it('renders the menu items correctly', () => {
setup();
setup({
actionsEnabledConfig: { terminate: true, cancel: true },
});

const menuButtons = screen.getAllByRole('button');
expect(menuButtons).toHaveLength(2);
Expand All @@ -38,8 +41,56 @@ describe(WorkflowActionsMenu.name, () => {
expect(menuButtons[1]).toBeDisabled();
});

it('disables menu items if they are disabled from config', () => {
setup({
actionsEnabledConfig: { terminate: true, cancel: false },
});

const menuButtons = screen.getAllByRole('button');
expect(menuButtons).toHaveLength(2);

expect(within(menuButtons[0]).getByText('Mock cancel')).toBeInTheDocument();
expect(
within(menuButtons[0]).getByText('Mock cancel a workflow execution')
).toBeInTheDocument();
expect(menuButtons[0]).toBeDisabled();

expect(
within(menuButtons[1]).getByText('Mock terminate')
).toBeInTheDocument();
expect(
within(menuButtons[1]).getByText('Mock terminate a workflow execution')
).toBeInTheDocument();
expect(menuButtons[1]).toBeDisabled();
});

it('disables menu items if no config is passed', () => {
setup({
actionsEnabledConfig: undefined,
});

const menuButtons = screen.getAllByRole('button');
expect(menuButtons).toHaveLength(2);

expect(within(menuButtons[0]).getByText('Mock cancel')).toBeInTheDocument();
expect(
within(menuButtons[0]).getByText('Mock cancel a workflow execution')
).toBeInTheDocument();
expect(menuButtons[0]).toBeDisabled();

expect(
within(menuButtons[1]).getByText('Mock terminate')
).toBeInTheDocument();
expect(
within(menuButtons[1]).getByText('Mock terminate a workflow execution')
).toBeInTheDocument();
expect(menuButtons[1]).toBeDisabled();
});

it('calls onActionSelect when the action button is clicked', async () => {
const { user, mockOnActionSelect } = setup();
const { user, mockOnActionSelect } = setup({
actionsEnabledConfig: { terminate: true, cancel: true },
});

const menuButtons = screen.getAllByRole('button');
expect(menuButtons).toHaveLength(2);
Expand All @@ -51,13 +102,18 @@ describe(WorkflowActionsMenu.name, () => {
});
});

function setup() {
function setup({
actionsEnabledConfig,
}: {
actionsEnabledConfig?: WorkflowActionsEnabledConfig;
}) {
const user = userEvent.setup();
const mockOnActionSelect = jest.fn();

const renderResult = render(
<WorkflowActionsMenu
workflow={describeWorkflowResponse}
{...(actionsEnabledConfig && { actionsEnabledConfig })}
onActionSelect={mockOnActionSelect}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { type Props } from './workflow-actions-menu.types';

export default function WorkflowActionsMenu({
workflow,
actionsEnabledConfig,
onActionSelect,
}: Props) {
return (
Expand All @@ -17,7 +18,10 @@ export default function WorkflowActionsMenu({
kind={KIND.tertiary}
overrides={overrides.button}
onClick={() => onActionSelect(action)}
disabled={!action.getIsEnabled(workflow)}
disabled={
!actionsEnabledConfig?.[action.id] ||
!action.getIsRunnable(workflow)
}
>
<styled.MenuItemContainer>
<action.icon />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { type WorkflowActionsEnabledConfig } from '@/config/dynamic/resolvers/workflow-actions-enabled.types';
import { type DescribeWorkflowResponse } from '@/route-handlers/describe-workflow/describe-workflow.types';

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

export type Props = {
workflow: DescribeWorkflowResponse;
actionsEnabledConfig?: WorkflowActionsEnabledConfig;
onActionSelect: (action: WorkflowAction<any>) => void;
};
Loading

0 comments on commit 5f0a7f3

Please sign in to comment.