Skip to content

Commit 2f464b6

Browse files
authored
feat: Cron List Table (#1084)
* feat: Cron List Table Signed-off-by: Tim Chan <[email protected]> * Added unit tests. Signed-off-by: Tim Chan <[email protected]> * Remove orphaned file. Signed-off-by: Tim Chan <[email protected]> * Fixed lint. Signed-off-by: Tim Chan <[email protected]> * Moved date to useMemo. Signed-off-by: Tim Chan <[email protected]> * Relocated config folder. Signed-off-by: Tim Chan <[email protected]> * New constants file. Signed-off-by: Tim Chan <[email protected]> * Revised test assertion. Signed-off-by: Tim Chan <[email protected]> * Fixed import. Signed-off-by: Tim Chan <[email protected]> --------- Signed-off-by: Tim Chan <[email protected]>
1 parent 3159d00 commit 2f464b6

File tree

6 files changed

+312
-2
lines changed

6 files changed

+312
-2
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { createElement } from 'react';
2+
3+
import FormattedDate from '@/components/formatted-date/formatted-date';
4+
import { type DomainWorkflow } from '@/views/domain-page/domain-page.types';
5+
import WorkflowStatusTag from '@/views/shared/workflow-status-tag/workflow-status-tag';
6+
7+
import { type CronListTableConfig } from '../cron-list-table/cron-list-table.types';
8+
9+
const cronListTableConfig = [
10+
{
11+
name: 'Workflow ID',
12+
id: 'WorkflowID',
13+
renderCell: (row: DomainWorkflow) => row.workflowID,
14+
width: '25.5%',
15+
},
16+
{
17+
name: 'Workflow type',
18+
id: 'WorkflowType',
19+
renderCell: (row: DomainWorkflow) => row.workflowName,
20+
width: '20%',
21+
},
22+
{
23+
name: 'Cron (UTC)',
24+
id: 'CronSchedule',
25+
renderCell: () => 'Coming soon',
26+
width: '10%',
27+
},
28+
{
29+
name: 'Status',
30+
id: 'CloseStatus',
31+
renderCell: (row: DomainWorkflow) =>
32+
createElement(WorkflowStatusTag, { status: row.status }),
33+
width: '7.5%',
34+
},
35+
{
36+
name: 'Started',
37+
id: 'StartTime',
38+
renderCell: (row: DomainWorkflow) =>
39+
createElement(FormattedDate, { timestampMs: row.startTime }),
40+
width: '12.5%',
41+
},
42+
] as const satisfies CronListTableConfig;
43+
44+
export default cronListTableConfig;
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { HttpResponse } from 'msw';
2+
3+
import { render, screen, userEvent, waitFor } from '@/test-utils/rtl';
4+
5+
import { type Props as LoaderProps } from '@/components/table/table-infinite-scroll-loader/table-infinite-scroll-loader.types';
6+
import { type ListWorkflowsResponse } from '@/route-handlers/list-workflows/list-workflows.types';
7+
8+
import type { Props as MSWMocksHandlersProps } from '../../../../test-utils/msw-mock-handlers/msw-mock-handlers.types';
9+
import CronListTable from '../cron-list-table';
10+
11+
const WORKFLOWS_PER_PAGE = 20;
12+
13+
jest.mock(
14+
'@/components/section-loading-indicator/section-loading-indicator',
15+
() => jest.fn(() => <div data-testid="loading-indicator">Loading...</div>)
16+
);
17+
18+
jest.mock(
19+
'@/components/table/table-infinite-scroll-loader/table-infinite-scroll-loader',
20+
() =>
21+
jest.fn((props: LoaderProps) => (
22+
<button data-testid="mock-loader" onClick={props.fetchNextPage}>
23+
Mock end message: {props.error ? 'Error' : 'OK'}
24+
</button>
25+
))
26+
);
27+
28+
describe('CronListTable', () => {
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
});
32+
33+
it('renders loading indicator', async () => {
34+
setup({ isLoading: true });
35+
36+
expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
37+
expect(screen.queryByText('Workflow ID')).not.toBeInTheDocument();
38+
});
39+
40+
it('renders workflows', async () => {
41+
const { user } = setup();
42+
43+
expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument();
44+
45+
Array(WORKFLOWS_PER_PAGE).forEach((_, index) => {
46+
const pageIndex = 0;
47+
expect(
48+
screen.getByText(`mock-workflow-id-${pageIndex}-${index}`)
49+
).toBeInTheDocument();
50+
});
51+
52+
await user.click(screen.getByTestId('mock-loader'));
53+
54+
expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument();
55+
56+
Array(WORKFLOWS_PER_PAGE).forEach((_, index) => {
57+
const pageIndex = 1;
58+
expect(
59+
screen.getByText(`mock-workflow-id-${pageIndex}-${index}`)
60+
).toBeInTheDocument();
61+
});
62+
});
63+
64+
it('renders empty table', async () => {
65+
setup({ errorCase: 'no-workflows' });
66+
67+
await waitFor(() => {
68+
expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
69+
});
70+
71+
expect(screen.getByText('Workflow ID')).toBeInTheDocument();
72+
expect(screen.queryByText('mock-workflow-id')).not.toBeInTheDocument();
73+
});
74+
75+
it('should allow the user to try again if there is an error', async () => {
76+
const { user } = setup({ errorCase: 'subsequent-fetch-error' });
77+
78+
expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument();
79+
80+
await user.click(screen.getByTestId('mock-loader'));
81+
82+
expect(
83+
await screen.findByText('Mock end message: Error')
84+
).toBeInTheDocument();
85+
86+
await user.click(screen.getByTestId('mock-loader'));
87+
88+
expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument();
89+
90+
Array(WORKFLOWS_PER_PAGE).forEach((_, index) => {
91+
const pageIndex = 1;
92+
expect(
93+
screen.getByText(`mock-workflow-id-${pageIndex}-${index}`)
94+
).toBeInTheDocument();
95+
});
96+
});
97+
98+
it('renders error state when initial fetch fails', async () => {
99+
setup({ errorCase: 'initial-fetch-error' });
100+
101+
await waitFor(() => {
102+
expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
103+
});
104+
105+
expect(
106+
await screen.findByText('Mock end message: Error')
107+
).toBeInTheDocument();
108+
});
109+
});
110+
111+
function setup(opts?: {
112+
errorCase?: 'initial-fetch-error' | 'subsequent-fetch-error' | 'no-workflows';
113+
isLoading?: boolean;
114+
}) {
115+
const { errorCase, isLoading } = opts ?? {};
116+
const pages = generateWorkflowPages(2);
117+
let currentEventIndex = 0;
118+
const user = userEvent.setup();
119+
120+
const endpointsMocks = [
121+
{
122+
path: '/api/domains/:domain/:cluster/workflows',
123+
httpMethod: 'GET',
124+
mockOnce: false,
125+
httpResolver: async () => {
126+
if (isLoading) {
127+
// Return a promise that never resolves to simulate loading
128+
return new Promise(() => {});
129+
}
130+
131+
const index = currentEventIndex;
132+
currentEventIndex++;
133+
134+
switch (errorCase) {
135+
case 'no-workflows':
136+
return HttpResponse.json({
137+
workflows: [],
138+
nextPage: undefined,
139+
});
140+
case 'initial-fetch-error':
141+
return HttpResponse.json(
142+
{ message: 'Request failed' },
143+
{ status: 500 }
144+
);
145+
case 'subsequent-fetch-error':
146+
if (index === 0) {
147+
return HttpResponse.json(pages[0]);
148+
} else if (index === 1) {
149+
return HttpResponse.json(
150+
{ message: 'Request failed' },
151+
{ status: 500 }
152+
);
153+
} else {
154+
return HttpResponse.json(pages[1]);
155+
}
156+
default:
157+
if (index === 0) {
158+
return HttpResponse.json(pages[0]);
159+
} else {
160+
return HttpResponse.json(pages[1]);
161+
}
162+
}
163+
},
164+
},
165+
] as MSWMocksHandlersProps['endpointsMocks'];
166+
167+
render(<CronListTable domain="mock-domain" cluster="mock-cluster" />, {
168+
endpointsMocks,
169+
});
170+
171+
return { user };
172+
}
173+
174+
function generateWorkflowPages(count: number): Array<ListWorkflowsResponse> {
175+
const pages = Array.from(
176+
{ length: count },
177+
(_, pageIndex): ListWorkflowsResponse => ({
178+
workflows: Array.from({ length: WORKFLOWS_PER_PAGE }, (_, index) => ({
179+
workflowID: `mock-workflow-id-${pageIndex}-${index}`,
180+
runID: `mock-run-id-${pageIndex}-${index}`,
181+
workflowName: `mock-workflow-name-${pageIndex}-${index}`,
182+
status: 'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID',
183+
startTime: 1763498300000,
184+
closeTime: undefined, // Cron List excludes closed workflows.
185+
})),
186+
nextPage: `${pageIndex + 1}`,
187+
})
188+
);
189+
190+
pages[pages.length - 1].nextPage = '';
191+
return pages;
192+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const CRON_LIST_PAGE_SIZE = 20;
2+
export const CRON_LIST_QUERY = 'IsCron = "true" AND CloseTime = missing';
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
'use client';
2+
import React, { useMemo } from 'react';
3+
4+
import SectionLoadingIndicator from '@/components/section-loading-indicator/section-loading-indicator';
5+
import Table from '@/components/table/table';
6+
import useListWorkflows from '@/views/shared/hooks/use-list-workflows';
7+
8+
import cronListTableConfig from '../config/cron-list-table.config';
9+
10+
import {
11+
CRON_LIST_PAGE_SIZE,
12+
CRON_LIST_QUERY,
13+
} from './cron-list-table.constants';
14+
import { type Props } from './cron-list-table.types';
15+
16+
export default function CronListTable({ domain, cluster }: Props) {
17+
const timeRangeEnd = useMemo(() => new Date().toISOString(), []);
18+
19+
const {
20+
workflows,
21+
error,
22+
isLoading,
23+
hasNextPage,
24+
fetchNextPage,
25+
isFetchingNextPage,
26+
} = useListWorkflows({
27+
domain,
28+
cluster,
29+
listType: 'default',
30+
pageSize: CRON_LIST_PAGE_SIZE,
31+
inputType: 'query',
32+
timeRangeEnd,
33+
query: CRON_LIST_QUERY,
34+
});
35+
36+
if (isLoading) {
37+
return <SectionLoadingIndicator />;
38+
}
39+
40+
return (
41+
<Table
42+
data={workflows}
43+
shouldShowResults={!isLoading && workflows.length > 0}
44+
endMessageProps={{
45+
kind: 'infinite-scroll',
46+
hasData: workflows.length > 0,
47+
error,
48+
fetchNextPage,
49+
hasNextPage,
50+
isFetchingNextPage,
51+
}}
52+
columns={cronListTableConfig}
53+
/>
54+
);
55+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { type TableColumn } from '@/components/table/table.types';
2+
import { type DomainWorkflow } from '@/views/domain-page/domain-page.types';
3+
4+
export type Props = {
5+
domain: string;
6+
cluster: string;
7+
};
8+
9+
export type CronListTableConfig = Array<
10+
Omit<TableColumn<DomainWorkflow>, 'sortable'>
11+
>;
Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import React from 'react';
22

3+
import { type DomainPageTabContentProps } from '@/views/domain-page/domain-page-content/domain-page-content.types';
4+
5+
import CronListTable from './cron-list-table/cron-list-table';
36
import { styled } from './domain-cron-list.styles';
47

5-
export default function DomainCronList() {
8+
export default function DomainCronList({
9+
domain,
10+
cluster,
11+
}: DomainPageTabContentProps) {
612
return (
713
<styled.DomainCronListContainer>
8-
Cron List, Coming Soon!
14+
<CronListTable domain={domain} cluster={cluster} />
915
</styled.DomainCronListContainer>
1016
);
1117
}

0 commit comments

Comments
 (0)