Skip to content

Commit ba1352c

Browse files
Show pending tasks count in Workflow History tab (#698)
* Add pending badge * More changes * Add tests * Make the end enhancer a generic component * Fix component type
1 parent 6ffb42d commit ba1352c

14 files changed

+374
-64
lines changed

src/components/page-tabs/__tests__/page-tabs.test.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
import { render, fireEvent } from '@/test-utils/rtl';
22

33
import PageTabs from '../page-tabs';
4+
import { type PageTabsList } from '../page-tabs.types';
45

56
// Mock props
67
const getArtwork = (number: number) =>
78
function Artwork() {
89
return <div>{`Artwork${number}`}</div>;
910
};
1011

11-
const tabList = [
12+
const tabList: PageTabsList = [
1213
{ key: 'tab1', title: 'Tab 1', artwork: getArtwork(1) },
1314
{ key: 'tab2', title: 'Tab 2' },
1415
{ key: 'tab3', title: 'Tab 3', artwork: getArtwork(3) },
16+
{
17+
key: 'tab4',
18+
title: 'Tab 4',
19+
endEnhancer: () => <div data-testid="mock-end-enhancer" />,
20+
},
1521
];
22+
1623
const selectedTab = 'tab1';
1724
const setSelectedTab = jest.fn();
1825

@@ -38,6 +45,18 @@ describe('PageTabs', () => {
3845
});
3946
});
4047

48+
it('renders tabs with correct end enhancer', () => {
49+
const { getByTestId } = render(
50+
<PageTabs
51+
tabList={tabList}
52+
selectedTab={selectedTab}
53+
setSelectedTab={setSelectedTab}
54+
/>
55+
);
56+
57+
expect(getByTestId('mock-end-enhancer')).toBeInTheDocument();
58+
});
59+
4160
it('calls setSelectedTab when a tab is clicked', () => {
4261
const { getByText } = render(
4362
<PageTabs

src/components/page-tabs/page-tabs.styles.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1-
import type { Theme } from 'baseui';
1+
import { styled as createStyled, type Theme } from 'baseui';
22
import { type TabOverrides, type TabsOverrides } from 'baseui/tabs-motion';
33
import type { StyleObject } from 'styletron-react';
44

55
import { getMediaQueryMargins } from '@/utils/media-query/get-media-queries-margins';
66

7+
export const styled = {
8+
TabTitleContainer: createStyled('div', ({ $theme }: { $theme: Theme }) => ({
9+
display: 'flex',
10+
flexDirection: 'row',
11+
alignItems: 'baseline',
12+
gap: $theme.sizing.scale400,
13+
})),
14+
};
15+
716
export const overrides = {
817
tabs: {
918
Root: {

src/components/page-tabs/page-tabs.tsx

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

33
import { Tabs, Tab } from 'baseui/tabs-motion';
44

5-
import { overrides } from './page-tabs.styles';
5+
import { overrides, styled } from './page-tabs.styles';
66
import { type Props } from './page-tabs.types';
77

88
export default function PageTabs({
@@ -18,12 +18,17 @@ export default function PageTabs({
1818
}}
1919
overrides={overrides.tabs}
2020
>
21-
{tabList.map(({ key, title, artwork }) => (
21+
{tabList.map((tab) => (
2222
<Tab
2323
overrides={overrides.tab}
24-
key={key}
25-
title={title}
26-
artwork={artwork}
24+
key={tab.key}
25+
title={
26+
<styled.TabTitleContainer>
27+
{tab.title}
28+
{tab.endEnhancer ? <tab.endEnhancer /> : null}
29+
</styled.TabTitleContainer>
30+
}
31+
artwork={tab.artwork}
2732
/>
2833
))}
2934
</Tabs>

src/components/page-tabs/page-tabs.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type IconProps } from 'baseui/icon';
55
export type PageTab = {
66
key: string;
77
title: string;
8+
endEnhancer?: React.ComponentType<Record<string, never>>;
89
artwork?: React.ComponentType<{
910
size: IconProps['size'];
1011
color: IconProps['color'];
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { type DescribeWorkflowResponse } from '@/route-handlers/describe-workflow/describe-workflow.types';
2+
3+
export const describeWorkflowResponse: DescribeWorkflowResponse = {
4+
pendingActivities: [
5+
{
6+
activityId: 'test-activity-2',
7+
activityType: {
8+
name: 'activity.test.TestActivity',
9+
},
10+
state: 'PENDING_ACTIVITY_STATE_STARTED',
11+
heartbeatDetails: null,
12+
lastHeartbeatTime: {
13+
seconds: '1729168753',
14+
nanos: 969000000,
15+
},
16+
lastStartedTime: {
17+
seconds: '1729168753',
18+
nanos: 969000000,
19+
},
20+
attempt: 0,
21+
maximumAttempts: 5,
22+
scheduledTime: null,
23+
expirationTime: {
24+
seconds: '1729169113',
25+
nanos: 969000000,
26+
},
27+
lastFailure: null,
28+
lastWorkerIdentity: 'test-worker-identity-2',
29+
startedWorkerIdentity: 'test-worker-identity-2',
30+
},
31+
{
32+
activityId: 'test-activity-1',
33+
activityType: {
34+
name: 'activity.test.TestActivity',
35+
},
36+
state: 'PENDING_ACTIVITY_STATE_STARTED',
37+
heartbeatDetails: null,
38+
lastHeartbeatTime: {
39+
seconds: '1729168753',
40+
nanos: 969000000,
41+
},
42+
lastStartedTime: {
43+
seconds: '1729168753',
44+
nanos: 969000000,
45+
},
46+
attempt: 0,
47+
maximumAttempts: 0,
48+
scheduledTime: null,
49+
expirationTime: null,
50+
lastFailure: null,
51+
lastWorkerIdentity: 'test-worker-identity-1',
52+
startedWorkerIdentity: 'test-worker-identity-1',
53+
},
54+
],
55+
pendingChildren: [],
56+
executionConfiguration: {
57+
taskList: {
58+
name: 'test-task-queue',
59+
kind: 'TASK_LIST_KIND_INVALID',
60+
},
61+
executionStartToCloseTimeout: {
62+
seconds: '360',
63+
nanos: 0,
64+
},
65+
taskStartToCloseTimeout: {
66+
seconds: '10',
67+
nanos: 0,
68+
},
69+
},
70+
workflowExecutionInfo: {
71+
partitionConfig: {
72+
'isolation-group': 'test-group',
73+
},
74+
workflowExecution: {
75+
workflowId: 'test-workflow-id',
76+
runId: 'test-run-id',
77+
},
78+
type: {
79+
name: 'workflow.test.base',
80+
},
81+
startTime: {
82+
seconds: '1729168753',
83+
nanos: 919000000,
84+
},
85+
closeTime: {
86+
seconds: '1729168756',
87+
nanos: 76020275,
88+
},
89+
closeStatus: 'WORKFLOW_EXECUTION_CLOSE_STATUS_TERMINATED',
90+
historyLength: '31',
91+
parentExecutionInfo: {
92+
domainId: 'test-domain-id',
93+
domainName: 'test-domain-name',
94+
workflowExecution: {
95+
workflowId: 'test-parent-workflow-id',
96+
runId: 'test-parent-run-id',
97+
},
98+
initiatedId: '7',
99+
},
100+
executionTime: {
101+
seconds: '1729168753',
102+
nanos: 919000000,
103+
},
104+
memo: {
105+
fields: {},
106+
},
107+
searchAttributes: {
108+
indexedFields: {
109+
BinaryChecksums: {
110+
data: 'test-binary-checksum',
111+
},
112+
CadenceChangeVersion: {
113+
data: 'test-change-version',
114+
},
115+
},
116+
},
117+
autoResetPoints: {
118+
points: [
119+
{
120+
binaryChecksum: 'test-binary-checksum',
121+
runId: 'test-run-id',
122+
firstDecisionCompletedId: '4',
123+
createdTime: {
124+
seconds: '1729168753',
125+
nanos: 968806515,
126+
},
127+
expiringTime: null,
128+
resettable: true,
129+
},
130+
],
131+
},
132+
taskList: '',
133+
isCron: false,
134+
updateTime: null,
135+
closeEvent: {
136+
eventId: '31',
137+
eventTime: {
138+
seconds: '1729168756',
139+
nanos: 76020275,
140+
},
141+
version: 'test-version',
142+
taskId: 'test-task-id',
143+
workflowExecutionTerminatedEventAttributes: {
144+
reason: 'test-reason',
145+
details: null,
146+
identity: 'test-identity',
147+
},
148+
attributes: 'workflowExecutionTerminatedEventAttributes',
149+
},
150+
isArchived: false,
151+
},
152+
pendingDecision: null,
153+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const WORKFLOW_PAGE_STATUS_REFETCH_INTERVAL = 10000;
2+
3+
export default WORKFLOW_PAGE_STATUS_REFETCH_INTERVAL;

src/views/workflow-page/config/workflow-page-status-refresh-interval.config.ts

Lines changed: 0 additions & 3 deletions
This file was deleted.

src/views/workflow-page/config/workflow-page-tabs.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
MdOutlineTerminal,
66
} from 'react-icons/md';
77

8+
import WorkflowPagePendingEventsBadge from '../workflow-page-pending-events-badge/workflow-page-pending-events-badge';
89
import type { WorkflowPageTabs } from '../workflow-page-tabs/workflow-page-tabs.types';
910

1011
const workflowPageTabsConfig = [
@@ -16,6 +17,7 @@ const workflowPageTabsConfig = [
1617
{
1718
key: 'history',
1819
title: 'History',
20+
endEnhancer: WorkflowPagePendingEventsBadge,
1921
artwork: MdOutlineHistory,
2022
},
2123
{
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use client';
2+
3+
import { useSuspenseQuery } from '@tanstack/react-query';
4+
5+
import { type DescribeWorkflowResponse } from '@/route-handlers/describe-workflow/describe-workflow.types';
6+
import request from '@/utils/request';
7+
import { type RequestError } from '@/utils/request/request-error';
8+
9+
import WORKFLOW_PAGE_STATUS_REFETCH_INTERVAL from '../config/workflow-page-status-refetch-interval.config';
10+
11+
type Props = {
12+
domain: string;
13+
cluster: string;
14+
workflowId: string;
15+
runId: string;
16+
refetchInterval?: number;
17+
};
18+
19+
export default function useDescribeWorkflow({
20+
refetchInterval = WORKFLOW_PAGE_STATUS_REFETCH_INTERVAL,
21+
...params
22+
}: Props) {
23+
return useSuspenseQuery<
24+
DescribeWorkflowResponse,
25+
RequestError,
26+
DescribeWorkflowResponse,
27+
[string, typeof params]
28+
>({
29+
queryKey: ['describe_workflow', params] as const,
30+
queryFn: ({ queryKey: [_, p] }) =>
31+
request(
32+
`/api/domains/${p.domain}/${p.cluster}/workflows/${p.workflowId}/${p.runId}`
33+
).then((res) => res.json()),
34+
refetchInterval: (query) => {
35+
const { closeStatus } = query.state.data?.workflowExecutionInfo || {};
36+
if (
37+
!closeStatus ||
38+
closeStatus === 'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID'
39+
)
40+
return refetchInterval;
41+
42+
return false;
43+
},
44+
});
45+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import React, { Suspense } from 'react';
2+
3+
import { HttpResponse } from 'msw';
4+
5+
import { render, screen } from '@/test-utils/rtl';
6+
7+
import { describeWorkflowResponse } from '../../__fixtures__/describe-workflow-response';
8+
import WorkflowPagePendingEventsBadge from '../workflow-page-pending-events-badge';
9+
10+
jest.mock('next/navigation', () => ({
11+
...jest.requireActual('next/navigation'),
12+
useParams: () => ({
13+
cluster: 'testCluster',
14+
domain: 'testDomain',
15+
workflowId: 'testWorkflowId',
16+
runId: 'testRunId',
17+
workflowTab: 'summary',
18+
}),
19+
}));
20+
21+
describe(WorkflowPagePendingEventsBadge.name, () => {
22+
beforeEach(() => {
23+
jest.clearAllMocks();
24+
});
25+
26+
it('should render pending activities count', async () => {
27+
setup({});
28+
29+
expect(await screen.findByText('2 pending')).toBeInTheDocument();
30+
});
31+
32+
it('should render nothing if the endpoint errors out', async () => {
33+
const { container } = setup({ isError: true });
34+
35+
expect(container.firstChild).toBeEmptyDOMElement();
36+
});
37+
});
38+
39+
function setup({ isError }: { isError?: boolean }) {
40+
return render(
41+
<Suspense>
42+
<WorkflowPagePendingEventsBadge />
43+
</Suspense>,
44+
{
45+
endpointsMocks: [
46+
{
47+
path: '/api/domains/:domain/:cluster/workflows/:workflowId/:runId',
48+
httpMethod: 'GET',
49+
...(isError
50+
? {
51+
httpResolver: () => {
52+
return HttpResponse.json(
53+
{ message: 'Failed to fetch workflow summary' },
54+
{ status: 500 }
55+
);
56+
},
57+
}
58+
: {
59+
jsonResponse: describeWorkflowResponse,
60+
}),
61+
},
62+
],
63+
}
64+
);
65+
}

0 commit comments

Comments
 (0)