Skip to content

Commit 0f7367b

Browse files
feat(schedule-details): add schedule input JSON section (SLICE-8 / PR08)
Show decoded workflow input from describe in a collapsible read-only JSON viewer on the details tab, with copy support and graceful handling of missing or non-JSON payloads. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 650abf3 commit 0f7367b

7 files changed

Lines changed: 278 additions & 0 deletions

File tree

src/views/schedule-details/__tests__/schedule-details.test.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,54 @@ describe(ScheduleDetails.name, () => {
116116
expect(describeResolver).toHaveBeenCalledTimes(1);
117117
});
118118

119+
120+
it('renders schedule input JSON section when workflow input is present', async () => {
121+
setup({
122+
describeResolver: () =>
123+
HttpResponse.json(
124+
getMockRunningDescribeScheduleResponse({
125+
action: {
126+
startWorkflow: {
127+
workflowType: { name: 'ScheduleWorker' },
128+
taskList: {
129+
name: 'schedule-task-list',
130+
kind: 'TASK_LIST_KIND_NORMAL',
131+
baseName: 'schedule-task-list',
132+
},
133+
input: {
134+
data: 'eyJ3b3JrZmxvd0FyZyI6InRlc3QtdmFsdWUifQ==',
135+
},
136+
workflowIdPrefix: 'schedule-prefix',
137+
executionStartToCloseTimeout: null,
138+
taskStartToCloseTimeout: null,
139+
retryPolicy: null,
140+
memo: null,
141+
searchAttributes: null,
142+
},
143+
},
144+
})
145+
),
146+
});
147+
148+
expect(
149+
await screen.findByRole('heading', { name: 'Schedule input' })
150+
).toBeInTheDocument();
151+
});
152+
153+
it('hides schedule input JSON section when workflow input is absent', async () => {
154+
setup({
155+
describeResolver: () =>
156+
HttpResponse.json(getMockRunningDescribeScheduleResponse()),
157+
});
158+
159+
expect(
160+
await screen.findByRole('heading', { name: 'Schedule specifications' })
161+
).toBeInTheDocument();
162+
expect(
163+
screen.queryByRole('heading', { name: 'Schedule input' })
164+
).not.toBeInTheDocument();
165+
});
166+
119167
it('hides optional detail rows when schedule fields are missing', async () => {
120168
setup({
121169
describeResolver: () =>

src/views/schedule-details/schedule-details.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import useDescribeSchedule from '@/views/shared/hooks/use-describe-schedule/use-
1010
import { type ScheduleDetailRowConfig } from '../schedule-page/config/schedule-detail-sections.types';
1111
import scheduleDetailsSectionsConfig from '../schedule-page/config/schedule-details-sections.config';
1212
import SchedulePageBackfillsTable from '../schedule-page/schedule-page-backfills-table/schedule-page-backfills-table';
13+
import SchedulePageInputJson from '../schedule-page/schedule-page-input-json/schedule-page-input-json';
1314
import SchedulePageDetailsSection from '../schedule-page/schedule-page-details-section/schedule-page-details-section';
1415

1516
import { cssStyles } from './schedule-details.styles';
@@ -54,6 +55,7 @@ export default function ScheduleDetails({ params }: Props) {
5455
/>
5556
);
5657
})}
58+
<SchedulePageInputJson input={data.action?.startWorkflow?.input} />
5759
<SchedulePageBackfillsTable
5860
backfills={data.info?.ongoingBackfills ?? []}
5961
domain={params.domain}

src/views/schedule-page/config/schedule-details-formatters.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,9 @@ export function formatScheduleMemo(
5757
if (values.length === 0) return null;
5858
return values.join(', ');
5959
}
60+
61+
export function formatScheduleInput(
62+
input: { data?: string | null } | null | undefined
63+
) {
64+
return formatPayload(input);
65+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React from 'react';
2+
3+
import { render, screen, userEvent } from '@/test-utils/rtl';
4+
5+
import SchedulePageInputJson from '../schedule-page-input-json';
6+
7+
jest.mock('@/components/copy-text-button/copy-text-button', () =>
8+
jest.fn(({ textToCopy }) => <div>Copy Button: {textToCopy}</div>)
9+
);
10+
11+
jest.mock('@/components/pretty-json/pretty-json', () =>
12+
jest.fn(({ json }) => (
13+
<div>PrettyJson Mock: {JSON.stringify(json)}</div>
14+
))
15+
);
16+
17+
const mockInputPayload = {
18+
data: 'eyJ3b3JrZmxvd0FyZyI6InRlc3QtdmFsdWUifQ==',
19+
};
20+
21+
describe(SchedulePageInputJson.name, () => {
22+
it('renders section title when input is present', () => {
23+
setup({});
24+
expect(
25+
screen.getByRole('heading', { name: 'Schedule input' })
26+
).toBeInTheDocument();
27+
});
28+
29+
it('renders parsed JSON via PrettyJson', () => {
30+
setup({});
31+
expect(
32+
screen.getByText(
33+
'PrettyJson Mock: {"workflowArg":"test-value"}'
34+
)
35+
).toBeInTheDocument();
36+
});
37+
38+
it('renders copy button with stringified JSON', () => {
39+
setup({});
40+
const copyButton = screen.getByText(/Copy Button:/);
41+
expect(copyButton).toBeInTheDocument();
42+
expect(copyButton.innerHTML).toMatch(
43+
JSON.stringify({ workflowArg: 'test-value' }, null, '\t')
44+
);
45+
});
46+
47+
it('renders nothing when input is missing', () => {
48+
setup({ input: null });
49+
expect(
50+
screen.queryByRole('heading', { name: 'Schedule input' })
51+
).not.toBeInTheDocument();
52+
});
53+
54+
it('renders nothing when input payload has no data', () => {
55+
setup({ input: { data: null } });
56+
expect(
57+
screen.queryByRole('heading', { name: 'Schedule input' })
58+
).not.toBeInTheDocument();
59+
});
60+
61+
it('renders non-JSON payload as a string value', () => {
62+
setup({
63+
input: { data: Buffer.from('plain-text-input').toString('base64') },
64+
});
65+
expect(
66+
screen.getByText('PrettyJson Mock: "plain-text-input"')
67+
).toBeInTheDocument();
68+
});
69+
70+
it('collapses JSON content when toggle button is clicked', async () => {
71+
const { user } = setup({});
72+
expect(
73+
screen.getByText('PrettyJson Mock: {"workflowArg":"test-value"}')
74+
).toBeInTheDocument();
75+
76+
await user.click(
77+
screen.getByRole('button', { name: /collapse schedule input details/i })
78+
);
79+
80+
expect(
81+
screen.queryByText('PrettyJson Mock: {"workflowArg":"test-value"}')
82+
).not.toBeInTheDocument();
83+
});
84+
85+
it('expands JSON content again after collapsing', async () => {
86+
const { user } = setup({});
87+
88+
await user.click(
89+
screen.getByRole('button', { name: /collapse schedule input details/i })
90+
);
91+
await user.click(
92+
screen.getByRole('button', { name: /expand schedule input details/i })
93+
);
94+
95+
expect(
96+
screen.getByText('PrettyJson Mock: {"workflowArg":"test-value"}')
97+
).toBeInTheDocument();
98+
});
99+
});
100+
101+
function setup({
102+
input = mockInputPayload,
103+
}: {
104+
input?: { data?: string | null } | null;
105+
}) {
106+
const user = userEvent.setup();
107+
render(<SchedulePageInputJson input={input} />);
108+
return { user };
109+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { styled as createStyled, type Theme } from 'baseui';
2+
import { type ButtonOverrides } from 'baseui/button';
3+
4+
import type {
5+
StyletronCSSObject,
6+
StyletronCSSObjectOf,
7+
} from '@/hooks/use-styletron-classes';
8+
9+
const cssStylesObj = {
10+
section: () => ({
11+
display: 'flex',
12+
flexDirection: 'column',
13+
}),
14+
jsonContainer: () => ({
15+
position: 'relative',
16+
width: '100%',
17+
}),
18+
} satisfies StyletronCSSObject;
19+
20+
export const cssStyles: StyletronCSSObjectOf<typeof cssStylesObj> =
21+
cssStylesObj;
22+
23+
export const styled = {
24+
JsonViewContainer: createStyled('div', ({ $theme }: { $theme: Theme }) => ({
25+
padding: $theme.sizing.scale600,
26+
backgroundColor: $theme.colors.backgroundSecondary,
27+
borderRadius: $theme.borders.radius300,
28+
maxHeight: '50vh',
29+
overflow: 'auto',
30+
})),
31+
JsonViewHeader: createStyled('div', ({ $theme }: { $theme: Theme }) => ({
32+
display: 'flex',
33+
position: 'absolute',
34+
right: $theme.sizing.scale400,
35+
top: $theme.sizing.scale400,
36+
})),
37+
};
38+
39+
export const overrides = {
40+
copyButton: {
41+
BaseButton: {
42+
style: {
43+
backgroundColor: 'rgba(0, 0, 0, 0)',
44+
},
45+
},
46+
} satisfies ButtonOverrides,
47+
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use client';
2+
import React, { useMemo } from 'react';
3+
4+
import CopyTextButton from '@/components/copy-text-button/copy-text-button';
5+
import PrettyJson from '@/components/pretty-json/pretty-json';
6+
import useStyletronClasses from '@/hooks/use-styletron-classes';
7+
import losslessJsonStringify from '@/utils/lossless-json-stringify';
8+
9+
import { formatScheduleInput } from '../config/schedule-details-formatters';
10+
import SchedulePageDetailsSectionHeader from '../schedule-page-details-section-header/schedule-page-details-section-header';
11+
12+
import { cssStyles, overrides, styled } from './schedule-page-input-json.styles';
13+
import { type Props } from './schedule-page-input-json.types';
14+
15+
const SECTION_TITLE = 'Schedule input';
16+
17+
export default function SchedulePageInputJson({ input }: Props) {
18+
const { cls } = useStyletronClasses(cssStyles);
19+
const [isCollapsed, setIsCollapsed] = React.useState(false);
20+
21+
const parsedInput = useMemo(() => formatScheduleInput(input), [input]);
22+
23+
const onToggle = React.useCallback(() => {
24+
setIsCollapsed((current) => !current);
25+
}, []);
26+
27+
const textToCopy = useMemo(() => {
28+
if (parsedInput === null || parsedInput === undefined) {
29+
return '';
30+
}
31+
32+
return losslessJsonStringify(parsedInput, null, '\t');
33+
}, [parsedInput]);
34+
35+
if (parsedInput === null || parsedInput === undefined) {
36+
return null;
37+
}
38+
39+
return (
40+
<section className={cls.section}>
41+
<SchedulePageDetailsSectionHeader
42+
title={SECTION_TITLE}
43+
isCollapsed={isCollapsed}
44+
onToggle={onToggle}
45+
/>
46+
{!isCollapsed && (
47+
<div className={cls.jsonContainer}>
48+
<styled.JsonViewContainer>
49+
<styled.JsonViewHeader>
50+
<CopyTextButton
51+
textToCopy={textToCopy}
52+
overrides={overrides.copyButton}
53+
/>
54+
</styled.JsonViewHeader>
55+
<PrettyJson json={parsedInput} />
56+
</styled.JsonViewContainer>
57+
</div>
58+
)}
59+
</section>
60+
);
61+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { type Payload } from '@/__generated__/proto-ts/common/common';
2+
3+
export type Props = {
4+
input: Payload | null | undefined;
5+
};

0 commit comments

Comments
 (0)