Skip to content

Commit bb63445

Browse files
adhityamamallanAssem-Uber
authored andcommitted
Add config for workflow actions (#805)
* 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
1 parent 2270126 commit bb63445

14 files changed

+257
-16
lines changed

src/config/dynamic/dynamic.config.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'server-only';
22

33
import type {
4+
ConfigAsyncResolverDefinition,
45
ConfigEnvDefinition,
56
ConfigSyncResolverDefinition,
67
} from '../../utils/config/config.types';
@@ -9,6 +10,11 @@ import clusters from './resolvers/clusters';
910
import clustersPublic from './resolvers/clusters-public';
1011
import { type PublicClustersConfigs } from './resolvers/clusters-public.types';
1112
import { type ClustersConfigs } from './resolvers/clusters.types';
13+
import workflowActionsEnabled from './resolvers/workflow-actions-enabled';
14+
import {
15+
type WorkflowActionsEnabledResolverParams,
16+
type WorkflowActionsEnabledConfig,
17+
} from './resolvers/workflow-actions-enabled.types';
1218

1319
const dynamicConfigs: {
1420
CADENCE_WEB_PORT: ConfigEnvDefinition;
@@ -24,10 +30,16 @@ const dynamicConfigs: {
2430
'serverStart',
2531
true
2632
>;
33+
WORKFLOW_ACTIONS_ENABLED: ConfigAsyncResolverDefinition<
34+
WorkflowActionsEnabledResolverParams,
35+
WorkflowActionsEnabledConfig,
36+
'request',
37+
true
38+
>;
2739
} = {
2840
CADENCE_WEB_PORT: {
2941
env: 'CADENCE_WEB_PORT',
30-
//Fallback to nextjs default port if CADENCE_WEB_PORT is not provided
42+
// Fallback to nextjs default port if CADENCE_WEB_PORT is not provided
3143
default: '3000',
3244
},
3345
ADMIN_SECURITY_TOKEN: {
@@ -43,6 +55,11 @@ const dynamicConfigs: {
4355
evaluateOn: 'serverStart',
4456
isPublic: true,
4557
},
58+
WORKFLOW_ACTIONS_ENABLED: {
59+
resolver: workflowActionsEnabled,
60+
evaluateOn: 'request',
61+
isPublic: true,
62+
},
4663
} as const;
4764

4865
export default dynamicConfigs;

src/config/dynamic/resolvers/schemas/resolver-schemas.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ const resolverSchemas: ResolverSchemas = {
2424
})
2525
),
2626
},
27+
WORKFLOW_ACTIONS_ENABLED: {
28+
args: z.object({
29+
cluster: z.string(),
30+
domain: z.string(),
31+
}),
32+
returnType: z.object({
33+
cancel: z.boolean(),
34+
terminate: z.boolean(),
35+
}),
36+
},
2737
};
2838

2939
export default resolverSchemas;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {
2+
type WorkflowActionsEnabledConfig,
3+
type WorkflowActionsEnabledResolverParams,
4+
} from './workflow-actions-enabled.types';
5+
6+
/**
7+
* If you have authentication enabled for users, override this resolver
8+
* to control whether users can access workflow actions in the UI
9+
*/
10+
export default async function workflowActionsEnabled(
11+
_: WorkflowActionsEnabledResolverParams
12+
): Promise<WorkflowActionsEnabledConfig> {
13+
return {
14+
terminate: true,
15+
cancel: true,
16+
};
17+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { type WorkflowActionID } from '@/views/workflow-actions/workflow-actions.types';
2+
3+
export type WorkflowActionsEnabledResolverParams = {
4+
domain: string;
5+
cluster: string;
6+
};
7+
8+
export type WorkflowActionsEnabledConfig = Record<WorkflowActionID, boolean>;

src/utils/config/__fixtures__/resolved-config-values.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,9 @@ const mockResolvedConfigValues: LoadedConfigResolvedValues = {
2727
clusterName: 'mock-cluster2',
2828
},
2929
],
30+
WORKFLOW_ACTIONS_ENABLED: {
31+
terminate: true,
32+
cancel: true,
33+
},
3034
};
3135
export default mockResolvedConfigValues;

src/views/workflow-actions/__fixtures__/workflow-actions-config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const mockWorkflowActionsConfig: [
2121
},
2222
},
2323
icon: MdHighlightOff,
24-
getIsEnabled: () => true,
24+
getIsRunnable: () => true,
2525
apiRoute: 'cancel',
2626
getSuccessMessage: () => 'Mock cancel notification',
2727
},
@@ -37,7 +37,7 @@ export const mockWorkflowActionsConfig: [
3737
},
3838
},
3939
icon: MdPowerSettingsNew,
40-
getIsEnabled: () => false,
40+
getIsRunnable: () => false,
4141
apiRoute: 'terminate',
4242
getSuccessMessage: () => 'Mock terminate notification',
4343
},

src/views/workflow-actions/__tests__/workflow-actions.test.tsx

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { Suspense } from 'react';
22

33
import { HttpResponse } from 'msw';
44

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

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

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

2626
jest.mock('../workflow-actions-menu/workflow-actions-menu', () =>
2727
jest.fn((props) => {
28+
const areAllActionsDisabled = props.actionsEnabledConfig
29+
? Object.entries(props.actionsEnabledConfig).every(
30+
([_, value]) => value === false
31+
)
32+
: true;
33+
2834
return (
2935
<div
3036
onClick={() => props.onActionSelect(mockWorkflowActionsConfig[0])}
3137
data-testid="actions-menu"
3238
>
33-
Actions Menu{props.disabled ? ' (disabled)' : ''}
39+
Actions Menu{areAllActionsDisabled ? ' (disabled)' : ''}
3440
</div>
3541
);
3642
})
@@ -43,22 +49,48 @@ describe(WorkflowActions.name, () => {
4349

4450
it('renders the button with the correct text', async () => {
4551
await setup({});
52+
4653
const actionsButton = await screen.findByRole('button');
54+
expect(actionsButton).toHaveAttribute(
55+
'aria-label',
56+
expect.stringContaining('loading')
57+
);
58+
59+
await waitFor(() => {
60+
expect(actionsButton).not.toHaveAttribute(
61+
'aria-label',
62+
expect.stringContaining('loading')
63+
);
64+
});
65+
4766
expect(actionsButton).toHaveTextContent('Workflow Actions');
4867
});
4968

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

53-
await user.click(await screen.findByText('Workflow Actions'));
72+
const actionsButton = await screen.findByRole('button');
73+
await user.click(actionsButton);
5474

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

78+
it('renders the button with disabled configs if config fetching fails', async () => {
79+
const { user } = await setup({ isConfigError: true });
80+
81+
const actionsButton = await screen.findByRole('button');
82+
await user.click(actionsButton);
83+
84+
const actionsMenu = await screen.findByTestId('actions-menu');
85+
expect(actionsMenu).toBeInTheDocument();
86+
expect(actionsMenu).toHaveTextContent('Actions Menu (disabled)');
87+
});
88+
5889
it('shows the modal when a menu option is clicked', async () => {
5990
const { user } = await setup({});
6091

61-
await user.click(await screen.findByText('Workflow Actions'));
92+
const actionsButton = await screen.findByRole('button');
93+
await user.click(actionsButton);
6294
await user.click(await screen.findByTestId('actions-menu'));
6395

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

83-
async function setup({ isError }: { isError?: boolean }) {
115+
async function setup({
116+
isError,
117+
isConfigError,
118+
}: {
119+
isError?: boolean;
120+
isConfigError?: boolean;
121+
}) {
84122
const user = userEvent.setup();
85123

86124
const renderResult = render(
@@ -105,6 +143,28 @@ async function setup({ isError }: { isError?: boolean }) {
105143
}
106144
},
107145
},
146+
{
147+
path: '/api/config',
148+
httpMethod: 'GET',
149+
httpResolver: () => {
150+
if (isConfigError) {
151+
return HttpResponse.json(
152+
{ message: 'Failed to fetch config' },
153+
{ status: 500 }
154+
);
155+
} else {
156+
return HttpResponse.json(
157+
{
158+
terminate: true,
159+
cancel: true,
160+
},
161+
{
162+
status: 200,
163+
}
164+
);
165+
}
166+
},
167+
},
108168
],
109169
}
110170
);

src/views/workflow-actions/config/workflow-actions.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const workflowActionsConfig: [
2222
},
2323
},
2424
icon: MdHighlightOff,
25-
getIsEnabled: (workflow) =>
25+
getIsRunnable: (workflow) =>
2626
!getWorkflowIsCompleted(
2727
workflow.workflowExecutionInfo?.closeEvent?.attributes ?? ''
2828
),
@@ -41,7 +41,7 @@ const workflowActionsConfig: [
4141
},
4242
},
4343
icon: MdPowerSettingsNew,
44-
getIsEnabled: (workflow) =>
44+
getIsRunnable: (workflow) =>
4545
!getWorkflowIsCompleted(
4646
workflow.workflowExecutionInfo?.closeEvent?.attributes ?? ''
4747
),

src/views/workflow-actions/workflow-actions-menu/__tests__/workflow-actions-menu.test.tsx

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22

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

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

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

2021
it('renders the menu items correctly', () => {
21-
setup();
22+
setup({
23+
actionsEnabledConfig: { terminate: true, cancel: true },
24+
});
2225

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

44+
it('disables menu items if they are disabled from config', () => {
45+
setup({
46+
actionsEnabledConfig: { terminate: true, cancel: false },
47+
});
48+
49+
const menuButtons = screen.getAllByRole('button');
50+
expect(menuButtons).toHaveLength(2);
51+
52+
expect(within(menuButtons[0]).getByText('Mock cancel')).toBeInTheDocument();
53+
expect(
54+
within(menuButtons[0]).getByText('Mock cancel a workflow execution')
55+
).toBeInTheDocument();
56+
expect(menuButtons[0]).toBeDisabled();
57+
58+
expect(
59+
within(menuButtons[1]).getByText('Mock terminate')
60+
).toBeInTheDocument();
61+
expect(
62+
within(menuButtons[1]).getByText('Mock terminate a workflow execution')
63+
).toBeInTheDocument();
64+
expect(menuButtons[1]).toBeDisabled();
65+
});
66+
67+
it('disables menu items if no config is passed', () => {
68+
setup({
69+
actionsEnabledConfig: undefined,
70+
});
71+
72+
const menuButtons = screen.getAllByRole('button');
73+
expect(menuButtons).toHaveLength(2);
74+
75+
expect(within(menuButtons[0]).getByText('Mock cancel')).toBeInTheDocument();
76+
expect(
77+
within(menuButtons[0]).getByText('Mock cancel a workflow execution')
78+
).toBeInTheDocument();
79+
expect(menuButtons[0]).toBeDisabled();
80+
81+
expect(
82+
within(menuButtons[1]).getByText('Mock terminate')
83+
).toBeInTheDocument();
84+
expect(
85+
within(menuButtons[1]).getByText('Mock terminate a workflow execution')
86+
).toBeInTheDocument();
87+
expect(menuButtons[1]).toBeDisabled();
88+
});
89+
4190
it('calls onActionSelect when the action button is clicked', async () => {
42-
const { user, mockOnActionSelect } = setup();
91+
const { user, mockOnActionSelect } = setup({
92+
actionsEnabledConfig: { terminate: true, cancel: true },
93+
});
4394

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

54-
function setup() {
105+
function setup({
106+
actionsEnabledConfig,
107+
}: {
108+
actionsEnabledConfig?: WorkflowActionsEnabledConfig;
109+
}) {
55110
const user = userEvent.setup();
56111
const mockOnActionSelect = jest.fn();
57112

58113
const renderResult = render(
59114
<WorkflowActionsMenu
60115
workflow={describeWorkflowResponse}
116+
{...(actionsEnabledConfig && { actionsEnabledConfig })}
61117
onActionSelect={mockOnActionSelect}
62118
/>
63119
);

src/views/workflow-actions/workflow-actions-menu/workflow-actions-menu.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { type Props } from './workflow-actions-menu.types';
77

88
export default function WorkflowActionsMenu({
99
workflow,
10+
actionsEnabledConfig,
1011
onActionSelect,
1112
}: Props) {
1213
return (
@@ -17,7 +18,10 @@ export default function WorkflowActionsMenu({
1718
kind={KIND.tertiary}
1819
overrides={overrides.button}
1920
onClick={() => onActionSelect(action)}
20-
disabled={!action.getIsEnabled(workflow)}
21+
disabled={
22+
!actionsEnabledConfig?.[action.id] ||
23+
!action.getIsRunnable(workflow)
24+
}
2125
>
2226
<styled.MenuItemContainer>
2327
<action.icon />

0 commit comments

Comments
 (0)