Skip to content

Commit e4c0de7

Browse files
authored
feat(schedule-actions): wire actions popover on schedule page (#1427)
## Summary Mounts the schedule-actions popover and confirmation modal on the schedule page tab bar. Completes the stacked schedule-actions UI started in #1419 and #1426.
1 parent bdcdc7c commit e4c0de7

7 files changed

Lines changed: 278 additions & 3 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import React from 'react';
2+
3+
import { HttpResponse } from 'msw';
4+
5+
import { render, screen, userEvent, waitFor } from '@/test-utils/rtl';
6+
7+
import { mockDescribeScheduleResponse } from '@/route-handlers/describe-schedule/__fixtures__/mock-describe-schedule-response';
8+
9+
import { mockScheduleActionsConfig } from '../__fixtures__/schedule-actions-config';
10+
import ScheduleActions from '../schedule-actions';
11+
12+
const mockScheduleParams = {
13+
domain: 'mock-domain',
14+
cluster: 'mock-cluster',
15+
scheduleId: 'mock-schedule-id',
16+
};
17+
18+
jest.mock('next/navigation', () => ({
19+
...jest.requireActual('next/navigation'),
20+
useParams: () => mockScheduleParams,
21+
}));
22+
23+
jest.mock('../schedule-actions-modal/schedule-actions-modal', () =>
24+
jest.fn((props) => {
25+
return props.action ? (
26+
<div data-testid="actions-modal">Actions Modal</div>
27+
) : null;
28+
})
29+
);
30+
31+
jest.mock('../schedule-actions-menu/schedule-actions-menu', () =>
32+
jest.fn((props) => {
33+
const areAllActionsDisabled = props.actionsEnabledConfig
34+
? Object.entries(props.actionsEnabledConfig).every(
35+
([_, value]) => value !== 'ENABLED'
36+
)
37+
: true;
38+
39+
return (
40+
<div
41+
onClick={() => props.onActionSelect(mockScheduleActionsConfig[0])}
42+
data-testid="actions-menu"
43+
>
44+
Actions Menu{areAllActionsDisabled ? ' (disabled)' : ''}
45+
</div>
46+
);
47+
})
48+
);
49+
50+
describe(ScheduleActions.name, () => {
51+
beforeEach(() => {
52+
jest.clearAllMocks();
53+
});
54+
55+
it('renders the button with the correct text', async () => {
56+
setup({});
57+
58+
const actionsButton = await screen.findByRole('button');
59+
expect(actionsButton).toHaveAttribute(
60+
'aria-label',
61+
expect.stringContaining('loading')
62+
);
63+
64+
await waitFor(() => {
65+
expect(actionsButton).not.toHaveAttribute(
66+
'aria-label',
67+
expect.stringContaining('loading')
68+
);
69+
});
70+
71+
expect(actionsButton).toHaveTextContent('Schedule Actions');
72+
});
73+
74+
it('renders the menu when the button is clicked', async () => {
75+
const { user } = setup({});
76+
77+
const actionsButton = await screen.findByRole('button');
78+
await user.click(actionsButton);
79+
80+
expect(await screen.findByTestId('actions-menu')).toBeInTheDocument();
81+
});
82+
83+
it('renders the button with disabled configs if config fetching fails', async () => {
84+
const { user } = setup({ isConfigError: true });
85+
86+
const actionsButton = await screen.findByRole('button');
87+
await user.click(actionsButton);
88+
89+
const actionsMenu = await screen.findByTestId('actions-menu');
90+
expect(actionsMenu).toBeInTheDocument();
91+
expect(actionsMenu).toHaveTextContent('Actions Menu (disabled)');
92+
});
93+
94+
it('shows the modal when a menu option is clicked', async () => {
95+
const { user } = setup({});
96+
97+
const actionsButton = await screen.findByRole('button');
98+
await user.click(actionsButton);
99+
await user.click(await screen.findByTestId('actions-menu'));
100+
101+
expect(await screen.findByTestId('actions-modal')).toBeInTheDocument();
102+
});
103+
104+
it('renders nothing if describeSchedule fails', async () => {
105+
setup({ isError: true });
106+
107+
await waitFor(() => {
108+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
109+
});
110+
});
111+
});
112+
113+
function setup({
114+
isError = false,
115+
isConfigError = false,
116+
}: {
117+
isError?: boolean;
118+
isConfigError?: boolean;
119+
}) {
120+
const user = userEvent.setup();
121+
122+
render(<ScheduleActions />, {
123+
endpointsMocks: [
124+
{
125+
path: '/api/domains/:domain/:cluster/schedules/:scheduleId',
126+
httpMethod: 'GET',
127+
mockOnce: false,
128+
httpResolver: () => {
129+
if (isError) {
130+
return HttpResponse.json(
131+
{ message: 'Schedule not found' },
132+
{ status: 404 }
133+
);
134+
}
135+
136+
return HttpResponse.json(mockDescribeScheduleResponse);
137+
},
138+
},
139+
{
140+
path: '/api/config',
141+
httpMethod: 'GET',
142+
mockOnce: false,
143+
httpResolver: () => {
144+
if (isConfigError) {
145+
return HttpResponse.json(
146+
{ message: 'Failed to fetch config' },
147+
{ status: 500 }
148+
);
149+
}
150+
151+
return HttpResponse.json(
152+
{
153+
pause: 'ENABLED',
154+
resume: 'ENABLED',
155+
},
156+
{ status: 200 }
157+
);
158+
},
159+
},
160+
],
161+
});
162+
163+
return { user };
164+
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ const resumeScheduleActionConfig: ScheduleAction<UnpauseScheduleResponse> = {
6161
const scheduleActionsConfig = [
6262
pauseScheduleActionConfig,
6363
resumeScheduleActionConfig,
64-
] as const satisfies ScheduleAction<any, any, any>[];
64+
] as const;
65+
66+
/** Discriminated union of configured actions; use at menu/selection boundaries. */
67+
export type SelectableScheduleAction = (typeof scheduleActionsConfig)[number];
6568

6669
export default scheduleActionsConfig;
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { type ScheduleActionsEnabledConfig } from '@/config/dynamic/resolvers/schedule-actions-enabled.types';
22
import { type DescribeScheduleResponse } from '@/route-handlers/describe-schedule/describe-schedule.types';
33

4-
import { type ScheduleAction } from '../schedule-actions.types';
4+
import { type SelectableScheduleAction } from '../config/schedule-actions.config';
55

66
export type Props = {
77
schedule: DescribeScheduleResponse | undefined;
88
actionsEnabledConfig?: ScheduleActionsEnabledConfig;
9-
onActionSelect: (action: ScheduleAction<any, any, any>) => void;
9+
onActionSelect: (action: SelectableScheduleAction) => void;
1010
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { type Theme } from 'baseui';
2+
import { type PopoverOverrides } from 'baseui/popover';
3+
import { type StyleObject } from 'styletron-react';
4+
5+
export const overrides = {
6+
popover: {
7+
Inner: {
8+
style: ({ $theme }: { $theme: Theme }): StyleObject => ({
9+
backgroundColor: $theme.colors.backgroundPrimary,
10+
}),
11+
},
12+
} satisfies PopoverOverrides,
13+
};
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use client';
2+
import React, { useState } from 'react';
3+
4+
import {
5+
StatefulPopover,
6+
PLACEMENT as POPOVER_PLACEMENT,
7+
} from 'baseui/popover';
8+
import pick from 'lodash/pick';
9+
import { useParams } from 'next/navigation';
10+
import { MdArrowDropDown } from 'react-icons/md';
11+
12+
import Button from '@/components/button/button';
13+
import useConfigValue from '@/hooks/use-config-value/use-config-value';
14+
import { type SchedulePageLayoutParams } from '@/views/schedule-page/schedule-page.types';
15+
import useDescribeSchedule from '@/views/shared/hooks/use-describe-schedule/use-describe-schedule';
16+
17+
import { type SelectableScheduleAction } from './config/schedule-actions.config';
18+
import ScheduleActionsMenu from './schedule-actions-menu/schedule-actions-menu';
19+
import ScheduleActionsModal from './schedule-actions-modal/schedule-actions-modal';
20+
import { overrides } from './schedule-actions.styles';
21+
import { type ErasedScheduleAction } from './schedule-actions.types';
22+
23+
export default function ScheduleActions() {
24+
const params = useParams<SchedulePageLayoutParams>();
25+
const scheduleDetailsParams = pick(params, 'cluster', 'scheduleId', 'domain');
26+
27+
const {
28+
data: schedule,
29+
isLoading: isScheduleLoading,
30+
error: scheduleError,
31+
} = useDescribeSchedule({
32+
...scheduleDetailsParams,
33+
});
34+
35+
const { data: actionsEnabledConfig, isLoading: isActionsEnabledLoading } =
36+
useConfigValue('SCHEDULE_ACTIONS_ENABLED', {
37+
domain: params.domain,
38+
cluster: params.cluster,
39+
});
40+
41+
const [selectedAction, setSelectedAction] = useState<
42+
SelectableScheduleAction | undefined
43+
>(undefined);
44+
45+
if (scheduleError) {
46+
return null;
47+
}
48+
49+
return (
50+
<>
51+
<StatefulPopover
52+
placement={POPOVER_PLACEMENT.bottomRight}
53+
overrides={overrides.popover}
54+
content={({ close }) => (
55+
<ScheduleActionsMenu
56+
schedule={schedule}
57+
actionsEnabledConfig={actionsEnabledConfig}
58+
onActionSelect={(action) => {
59+
setSelectedAction(action);
60+
close();
61+
}}
62+
/>
63+
)}
64+
returnFocus
65+
autoFocus
66+
>
67+
<Button
68+
size="compact"
69+
kind="secondary"
70+
endEnhancer={<MdArrowDropDown size={20} />}
71+
loadingIndicatorType="skeleton"
72+
isLoading={isScheduleLoading || isActionsEnabledLoading}
73+
>
74+
Schedule Actions
75+
</Button>
76+
</StatefulPopover>
77+
<ScheduleActionsModal
78+
{...scheduleDetailsParams}
79+
schedule={schedule}
80+
action={selectedAction as ErasedScheduleAction | undefined}
81+
onClose={() => setSelectedAction(undefined)}
82+
/>
83+
</>
84+
);
85+
}

src/views/schedule-actions/schedule-actions.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,6 @@ export type ScheduleAction<
112112
router: AppRouterInstance;
113113
}) => void;
114114
};
115+
116+
/** Erases per-action generics for the generic modal boundary. */
117+
export type ErasedScheduleAction = ScheduleAction<any, any, any>;

src/views/schedule-page/schedule-page-tabs/schedule-page-tabs.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import Link from 'next/link';
66
import { useParams, useRouter } from 'next/navigation';
77
import { MdChevronLeft } from 'react-icons/md';
88

9+
import ErrorBoundary from '@/components/error-boundary/error-boundary';
910
import PageTabs from '@/components/page-tabs/page-tabs';
1011
import decodeUrlParams from '@/utils/decode-url-params';
12+
import ScheduleActions from '@/views/schedule-actions/schedule-actions';
1113

1214
import schedulePageTabsConfig from '../config/schedule-page-tabs.config';
1315

@@ -55,6 +57,11 @@ export default function SchedulePageTabs() {
5557
setSelectedTab={(newTab) => {
5658
router.push(encodeURIComponent(newTab.toString()));
5759
}}
60+
endEnhancer={
61+
<ErrorBoundary fallbackRender={() => null}>
62+
<ScheduleActions />
63+
</ErrorBoundary>
64+
}
5865
/>
5966
</styled.TabsSlot>
6067
</styled.TabsRow>

0 commit comments

Comments
 (0)