Skip to content

Commit 15fc173

Browse files
feat(schedule-details): SLICE-1 — route shell with breadcrumbs, tabs, redirect
Add the App Router page structure and view for the schedule detail page: - Route group (Schedule) with layout, bare-id redirect, tab page, error + loading - ScheduleDetailPage layout (header + tabs + children) - ScheduleDetailPageHeader breadcrumbs: logo → domain → Schedules list → scheduleId - ScheduleDetailPageTabs client component (overview / backfills / input / runs) - ScheduleDetailPageTabContent: placeholder per tab; notFound() for unknown slugs - schedule-detail-tabs.config.ts with DEFAULT_SCHEDULE_DETAIL_TAB = 'overview' - Browser tests: tab labels, navigation push, breadcrumb hrefs, 404 for bad tab No API calls in this slice; placeholder content until SLICE-4+ wires describe. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 477f81b commit 15fc173

17 files changed

Lines changed: 490 additions & 0 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
'use client';
2+
import ScheduleDetailPageTabsError from '@/views/domain-schedules/schedule-detail-page/schedule-detail-page-tabs-error';
3+
4+
export default ScheduleDetailPageTabsError;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import SectionLoadingIndicator from '@/components/section-loading-indicator/section-loading-indicator';
2+
3+
export default SectionLoadingIndicator;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import ScheduleDetailPageTabContent from '@/views/domain-schedules/schedule-detail-page/schedule-detail-page-tab-content';
2+
3+
export default ScheduleDetailPageTabContent;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import ScheduleDetailPage from '@/views/domain-schedules/schedule-detail-page/schedule-detail-page';
2+
3+
export default ScheduleDetailPage;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { redirect } from 'next/navigation';
2+
3+
import { DEFAULT_SCHEDULE_DETAIL_TAB } from '@/views/domain-schedules/schedule-detail-page/schedule-detail-tabs.config';
4+
5+
type Props = {
6+
params: {
7+
domain: string;
8+
cluster: string;
9+
scheduleId: string;
10+
};
11+
};
12+
13+
export default function ScheduleDetailRedirectPage({ params }: Props) {
14+
redirect(
15+
`/domains/${encodeURIComponent(params.domain)}/${encodeURIComponent(params.cluster)}/schedules/${encodeURIComponent(params.scheduleId)}/${DEFAULT_SCHEDULE_DETAIL_TAB}`
16+
);
17+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import React from 'react';
2+
3+
import { render, screen } from '@/test-utils/rtl';
4+
5+
import ScheduleDetailPageHeader from '../schedule-detail-page-header';
6+
7+
describe(ScheduleDetailPageHeader.name, () => {
8+
it('renders breadcrumb with domain link', () => {
9+
setup();
10+
11+
const domainLink = screen.getByRole('link', { name: 'test-domain' });
12+
expect(domainLink).toBeInTheDocument();
13+
expect(domainLink).toHaveAttribute(
14+
'href',
15+
'/domains/test-domain/test-cluster'
16+
);
17+
});
18+
19+
it('renders breadcrumb schedules list link', () => {
20+
setup();
21+
22+
const schedulesLink = screen.getByRole('link', { name: 'Schedules' });
23+
expect(schedulesLink).toBeInTheDocument();
24+
expect(schedulesLink).toHaveAttribute(
25+
'href',
26+
'/domains/test-domain/test-cluster/schedules'
27+
);
28+
});
29+
30+
it('renders schedule id as last breadcrumb', () => {
31+
setup();
32+
33+
expect(screen.getByText('my-schedule-id')).toBeInTheDocument();
34+
});
35+
36+
it('encodes special characters in domain and cluster links', () => {
37+
setup({ domain: 'my domain', cluster: 'my cluster' });
38+
39+
const domainLink = screen.getByRole('link', { name: 'my domain' });
40+
expect(domainLink).toHaveAttribute(
41+
'href',
42+
'/domains/my%20domain/my%20cluster'
43+
);
44+
});
45+
});
46+
47+
function setup({
48+
domain = 'test-domain',
49+
cluster = 'test-cluster',
50+
scheduleId = 'my-schedule-id',
51+
}: {
52+
domain?: string;
53+
cluster?: string;
54+
scheduleId?: string;
55+
} = {}) {
56+
render(
57+
<ScheduleDetailPageHeader
58+
domain={domain}
59+
cluster={cluster}
60+
scheduleId={scheduleId}
61+
/>
62+
);
63+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import React from 'react';
2+
3+
import { render, screen } from '@/test-utils/rtl';
4+
5+
import { type ScheduleDetailPageParams } from '../schedule-detail-page.types';
6+
import ScheduleDetailPageTabContent from '../schedule-detail-page-tab-content';
7+
8+
jest.mock('next/navigation', () => ({
9+
...jest.requireActual('next/navigation'),
10+
notFound: jest.fn(() => {
11+
throw new Error('NEXT_NOT_FOUND');
12+
}),
13+
}));
14+
15+
describe(ScheduleDetailPageTabContent.name, () => {
16+
it('renders placeholder for overview tab', () => {
17+
setup({ scheduleTab: 'overview' });
18+
19+
expect(screen.getByText(/Overview/i)).toBeInTheDocument();
20+
});
21+
22+
it('renders placeholder for backfills tab', () => {
23+
setup({ scheduleTab: 'backfills' });
24+
25+
expect(screen.getByText(/Backfills/i)).toBeInTheDocument();
26+
});
27+
28+
it('renders placeholder for input tab', () => {
29+
setup({ scheduleTab: 'input' });
30+
31+
expect(screen.getByText(/Input/i)).toBeInTheDocument();
32+
});
33+
34+
it('renders placeholder for runs tab', () => {
35+
setup({ scheduleTab: 'runs' });
36+
37+
expect(screen.getByText(/Runs/i)).toBeInTheDocument();
38+
});
39+
40+
it('calls notFound for unknown tab slug', () => {
41+
const { notFound } = jest.requireMock('next/navigation');
42+
43+
expect(() =>
44+
setup({
45+
scheduleTab: 'unknown-tab' as ScheduleDetailPageParams['scheduleTab'],
46+
})
47+
).toThrow('NEXT_NOT_FOUND');
48+
expect(notFound).toHaveBeenCalled();
49+
});
50+
});
51+
52+
function setup({
53+
scheduleTab = 'overview',
54+
}: {
55+
scheduleTab?: ScheduleDetailPageParams['scheduleTab'];
56+
} = {}) {
57+
render(
58+
<ScheduleDetailPageTabContent
59+
params={{
60+
domain: 'test-domain',
61+
cluster: 'test-cluster',
62+
scheduleId: 'my-schedule',
63+
scheduleTab,
64+
}}
65+
/>
66+
);
67+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import React from 'react';
2+
3+
import { fireEvent, render, screen, act } from '@/test-utils/rtl';
4+
5+
import ScheduleDetailPageTabs from '../schedule-detail-page-tabs';
6+
7+
const mockPushFn = jest.fn();
8+
9+
jest.mock('next/navigation', () => ({
10+
...jest.requireActual('next/navigation'),
11+
useRouter: () => ({
12+
push: mockPushFn,
13+
back: jest.fn(),
14+
replace: jest.fn(),
15+
forward: jest.fn(),
16+
prefetch: jest.fn(),
17+
refresh: jest.fn(),
18+
}),
19+
useParams: () => ({
20+
domain: 'test-domain',
21+
cluster: 'test-cluster',
22+
scheduleId: 'my-schedule',
23+
scheduleTab: 'overview',
24+
}),
25+
}));
26+
27+
describe(ScheduleDetailPageTabs.name, () => {
28+
afterEach(() => {
29+
jest.clearAllMocks();
30+
});
31+
32+
it('renders all tab titles', () => {
33+
setup();
34+
35+
expect(screen.getByText('Overview')).toBeInTheDocument();
36+
expect(screen.getByText('Backfills')).toBeInTheDocument();
37+
expect(screen.getByText('Input')).toBeInTheDocument();
38+
expect(screen.getByText('Runs')).toBeInTheDocument();
39+
});
40+
41+
it('navigates when a tab is clicked', () => {
42+
setup();
43+
44+
act(() => {
45+
fireEvent.click(screen.getByText('Backfills'));
46+
});
47+
48+
expect(mockPushFn).toHaveBeenCalledWith('backfills');
49+
});
50+
});
51+
52+
function setup() {
53+
render(<ScheduleDetailPageTabs />);
54+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { type BreadcrumbsOverrides } from 'baseui/breadcrumbs';
2+
3+
import type {
4+
StyletronCSSObject,
5+
StyletronCSSObjectOf,
6+
} from '@/hooks/use-styletron-classes';
7+
8+
const cssStylesObj = {
9+
breadcrumbItemContainer: () => ({
10+
display: 'flex',
11+
alignItems: 'center',
12+
gap: '8px',
13+
}),
14+
} satisfies StyletronCSSObject;
15+
16+
export const cssStyles: StyletronCSSObjectOf<typeof cssStylesObj> =
17+
cssStylesObj;
18+
19+
export const overrides: { breadcrumbs: BreadcrumbsOverrides } = {
20+
breadcrumbs: {
21+
Root: {
22+
style: () => ({
23+
display: 'flex',
24+
alignItems: 'center',
25+
}),
26+
},
27+
},
28+
};
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
'use client';
2+
import React from 'react';
3+
4+
import { Breadcrumbs } from 'baseui/breadcrumbs';
5+
import { StyledLink } from 'baseui/link';
6+
import Image from 'next/image';
7+
import Link from 'next/link';
8+
9+
import cadenceLogoBlack from '@/assets/cadence-logo-black.svg';
10+
import PageSection from '@/components/page-section/page-section';
11+
import useStyletronClasses from '@/hooks/use-styletron-classes';
12+
import { type DomainPageTabName } from '@/views/domain-page/domain-page-tabs/domain-page-tabs.types';
13+
14+
import { cssStyles, overrides } from './schedule-detail-page-header.styles';
15+
16+
type Props = {
17+
domain: string;
18+
cluster: string;
19+
scheduleId: string;
20+
};
21+
22+
export default function ScheduleDetailPageHeader({
23+
domain,
24+
cluster,
25+
scheduleId,
26+
}: Props) {
27+
const { cls } = useStyletronClasses(cssStyles);
28+
const domainLink = `/domains/${encodeURIComponent(domain)}/${encodeURIComponent(cluster)}`;
29+
const schedulesListLink = `${domainLink}/${'schedules' satisfies DomainPageTabName}`;
30+
31+
return (
32+
<PageSection>
33+
<Breadcrumbs
34+
overrides={overrides.breadcrumbs}
35+
showTrailingSeparator={false}
36+
>
37+
<div className={cls.breadcrumbItemContainer}>
38+
<Image
39+
width={22}
40+
height={22}
41+
alt="Cadence Icon"
42+
src={cadenceLogoBlack}
43+
/>
44+
<StyledLink $as={Link} href={domainLink}>
45+
{domain}
46+
</StyledLink>
47+
</div>
48+
<StyledLink $as={Link} href={schedulesListLink}>
49+
Schedules
50+
</StyledLink>
51+
<div>{scheduleId}</div>
52+
</Breadcrumbs>
53+
</PageSection>
54+
);
55+
}

0 commit comments

Comments
 (0)