Skip to content

Commit 9fe1274

Browse files
refactor(list): extract DateHierarchyBar from ListPage into @dar/list (#428)
First incremental step of decomposing the ListPage god-component: lift the self-contained date_hierarchy drill-down bar (+ its MONTH_NAMES) into @dar/list as a props-driven, individually-testable component, and add the package's first vitest. No behaviour change — ListPage now composes it. ListPage.tsx drops 837 → 729 lines. @dar/list gains react as a peer/dev dep (mirroring @dar/search) and is wired into apps/web. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e1b0412 commit 9fe1274

8 files changed

Lines changed: 208 additions & 113 deletions

File tree

frontend/apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@dar/details": "workspace:*",
1818
"@dar/form": "workspace:*",
1919
"@dar/history": "workspace:*",
20+
"@dar/list": "workspace:*",
2021
"@dar/search": "workspace:*",
2122
"@dar/settings": "workspace:*",
2223
"@dar/sidebar": "workspace:*",

frontend/apps/web/src/pages/ListPage.tsx

Lines changed: 2 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
99
import { Settings2 } from 'lucide-react';
1010
import { Link, useHref, useNavigate, useParams, useSearchParams } from 'react-router-dom';
1111

12-
import {
13-
useApiClient,
14-
useList,
15-
type ActionDescriptor,
16-
type DateHierarchy,
17-
type ListRow,
18-
} from '@dar/data';
12+
import { useApiClient, useList, type ActionDescriptor, type ListRow } from '@dar/data';
1913
import {
2014
columnsKey,
2115
columnWidthsKey,
@@ -28,6 +22,7 @@ import {
2822
} from '@dar/customization';
2923
import { Breadcrumb, Button, Card, EmptyState, Modal, Skeleton, Table } from '@dar/ui';
3024
import { FieldValueView } from '@dar/details';
25+
import { DateHierarchyBar } from '@dar/list';
3126
import { FilterBar } from '@dar/search';
3227

3328
import { useToast } from '../toast';
@@ -44,21 +39,6 @@ const RESERVED_PARAMS = new Set(['q', 'page', 'all', CHANGELIST_FILTERS_PARAM]);
4439
// drill doesn't silently resurrect on a later bare visit.
4540
const DATE_PARAMS = new Set(['year', 'month', 'day']);
4641

47-
const MONTH_NAMES = [
48-
'January',
49-
'February',
50-
'March',
51-
'April',
52-
'May',
53-
'June',
54-
'July',
55-
'August',
56-
'September',
57-
'October',
58-
'November',
59-
'December',
60-
];
61-
6242
export function ListPage() {
6343
const params = useParams<{ appLabel: string; modelName: string }>();
6444
const appLabel = params.appLabel ?? '';
@@ -689,94 +669,6 @@ function ListSkeleton() {
689669
);
690670
}
691671

692-
// date_hierarchy drill-down bar (#304 — Django changelist parity). Reads
693-
// `active` for the current drill path (breadcrumb, each crumb navigates
694-
// up) and `buckets` for the next level's options (drill down). The
695-
// backend caps the level by the field; clicking wires ?year/?month/?day.
696-
function DateHierarchyBar({
697-
dh,
698-
onNavigate,
699-
}: {
700-
dh: DateHierarchy;
701-
onNavigate: (path: { year?: number | null; month?: number | null; day?: number | null }) => void;
702-
}) {
703-
const { active, buckets } = dh;
704-
const level: 'year' | 'month' | 'day' | 'done' =
705-
active.year == null
706-
? 'year'
707-
: active.month == null
708-
? 'month'
709-
: active.day == null
710-
? 'day'
711-
: 'done';
712-
713-
const bucketLabel = (v: number): string =>
714-
level === 'month' ? (MONTH_NAMES[v - 1] ?? String(v)) : String(v);
715-
716-
const nextPath = (v: number) => {
717-
if (level === 'year') return { year: v };
718-
if (level === 'month') return { year: active.year, month: v };
719-
return { year: active.year, month: active.month, day: v };
720-
};
721-
722-
const crumb = 'rounded px-1.5 py-0.5 text-primary hover:bg-blue-50 hover:underline';
723-
724-
return (
725-
<nav aria-label="Date hierarchy" className="flex flex-wrap items-center gap-3 text-sm">
726-
<div className="flex flex-wrap items-center gap-1 text-gray-500">
727-
<button type="button" className={crumb} onClick={() => onNavigate({})}>
728-
All dates
729-
</button>
730-
{active.year != null && (
731-
<>
732-
<span aria-hidden>/</span>
733-
<button
734-
type="button"
735-
className={crumb}
736-
onClick={() => onNavigate({ year: active.year })}
737-
>
738-
{active.year}
739-
</button>
740-
</>
741-
)}
742-
{active.month != null && (
743-
<>
744-
<span aria-hidden>/</span>
745-
<button
746-
type="button"
747-
className={crumb}
748-
onClick={() => onNavigate({ year: active.year, month: active.month })}
749-
>
750-
{MONTH_NAMES[active.month - 1] ?? active.month}
751-
</button>
752-
</>
753-
)}
754-
{active.day != null && (
755-
<>
756-
<span aria-hidden>/</span>
757-
<span className="px-1.5 py-0.5 font-medium text-gray-700">{active.day}</span>
758-
</>
759-
)}
760-
</div>
761-
{level !== 'done' && buckets.length > 0 && (
762-
<div className="flex flex-wrap items-center gap-2">
763-
{buckets.map((b) => (
764-
<button
765-
key={b.value}
766-
type="button"
767-
onClick={() => onNavigate(nextPath(b.value))}
768-
className="inline-flex items-center gap-1.5 rounded-full border border-gray-200 px-2.5 py-0.5 text-xs text-gray-700 hover:bg-gray-50"
769-
>
770-
{bucketLabel(b.value)}
771-
<span className="text-gray-400">{b.count}</span>
772-
</button>
773-
))}
774-
</div>
775-
)}
776-
</nav>
777-
);
778-
}
779-
780672
function capitalize(value: string): string {
781673
if (!value) return value;
782674
return value.charAt(0).toUpperCase() + value.slice(1);

frontend/packages/list/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ Composes `@dar/ui` primitives and `@dar/data` providers into a generic
44
list page that works for **any** model — entirely driven by the
55
metadata returned by `GET /api/v1/{app}/{model}/`.
66

7+
## What lives here
8+
9+
Cohesive, individually-testable building blocks extracted from the
10+
`ListPage` god-component (#428), so the page becomes a thin composition
11+
layer and each unit can be unit-tested in isolation:
12+
13+
- **`DateHierarchyBar`** — the `date_hierarchy` drill-down breadcrumb +
14+
next-level buckets (Django changelist parity). Props-driven
15+
(`dh` + `onNavigate`), no router/business coupling.
16+
17+
More units (filter/columns modals, pagination, the `list_editable`
18+
controller) land here as the decomposition progresses.
19+
720
## Rules
821

922
- **`@dar/data` is the only data source.** Never import `@dar/api`

frontend/packages/list/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,12 @@
1717
"dependencies": {
1818
"@dar/data": "workspace:*",
1919
"@dar/ui": "workspace:*"
20+
},
21+
"peerDependencies": {
22+
"react": "^18.3.0"
23+
},
24+
"devDependencies": {
25+
"@types/react": "^18.3.0",
26+
"react": "^18.3.0"
2027
}
2128
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import '@testing-library/jest-dom/vitest';
2+
3+
import { describe, expect, it, vi } from 'vitest';
4+
import { fireEvent, render, screen } from '@testing-library/react';
5+
6+
import type { DateHierarchy } from '@dar/data';
7+
8+
import { DateHierarchyBar } from './DateHierarchyBar';
9+
10+
function dh(overrides: Partial<DateHierarchy> = {}): DateHierarchy {
11+
return {
12+
field: 'created',
13+
granularity_options: ['year', 'month', 'day'],
14+
active: { year: null, month: null, day: null },
15+
buckets: [],
16+
...overrides,
17+
};
18+
}
19+
20+
describe('DateHierarchyBar', () => {
21+
it('at the year level renders year buckets and drills into the clicked year', () => {
22+
const onNavigate = vi.fn();
23+
render(
24+
<DateHierarchyBar
25+
dh={dh({
26+
buckets: [
27+
{ value: 2025, count: 3 },
28+
{ value: 2026, count: 5 },
29+
],
30+
})}
31+
onNavigate={onNavigate}
32+
/>,
33+
);
34+
// Year buckets render with their counts.
35+
expect(screen.getByText('2026')).toBeInTheDocument();
36+
expect(screen.getByText('5')).toBeInTheDocument();
37+
38+
fireEvent.click(screen.getByText('2026'));
39+
expect(onNavigate).toHaveBeenCalledWith({ year: 2026 });
40+
});
41+
42+
it('at the month level shows month-name buckets and the year breadcrumb', () => {
43+
const onNavigate = vi.fn();
44+
render(
45+
<DateHierarchyBar
46+
dh={dh({
47+
active: { year: 2026, month: null, day: null },
48+
buckets: [{ value: 1, count: 2 }],
49+
})}
50+
onNavigate={onNavigate}
51+
/>,
52+
);
53+
// Month bucket renders as a month name, not the raw number.
54+
expect(screen.getByText('January')).toBeInTheDocument();
55+
56+
fireEvent.click(screen.getByText('January'));
57+
expect(onNavigate).toHaveBeenCalledWith({ year: 2026, month: 1 });
58+
});
59+
60+
it('breadcrumbs navigate up: "All dates" clears the drill path', () => {
61+
const onNavigate = vi.fn();
62+
render(
63+
<DateHierarchyBar
64+
dh={dh({ active: { year: 2026, month: 3, day: null }, buckets: [] })}
65+
onNavigate={onNavigate}
66+
/>,
67+
);
68+
fireEvent.click(screen.getByText('All dates'));
69+
expect(onNavigate).toHaveBeenCalledWith({});
70+
});
71+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { DateHierarchy } from '@dar/data';
2+
3+
const MONTH_NAMES = [
4+
'January',
5+
'February',
6+
'March',
7+
'April',
8+
'May',
9+
'June',
10+
'July',
11+
'August',
12+
'September',
13+
'October',
14+
'November',
15+
'December',
16+
];
17+
18+
export interface DateHierarchyBarProps {
19+
dh: DateHierarchy;
20+
onNavigate: (path: { year?: number | null; month?: number | null; day?: number | null }) => void;
21+
}
22+
23+
// date_hierarchy drill-down bar (#304 — Django changelist parity). Reads
24+
// `active` for the current drill path (breadcrumb, each crumb navigates
25+
// up) and `buckets` for the next level's options (drill down). The
26+
// backend caps the level by the field; clicking wires ?year/?month/?day.
27+
export function DateHierarchyBar({ dh, onNavigate }: DateHierarchyBarProps) {
28+
const { active, buckets } = dh;
29+
const level: 'year' | 'month' | 'day' | 'done' =
30+
active.year == null
31+
? 'year'
32+
: active.month == null
33+
? 'month'
34+
: active.day == null
35+
? 'day'
36+
: 'done';
37+
38+
const bucketLabel = (v: number): string =>
39+
level === 'month' ? (MONTH_NAMES[v - 1] ?? String(v)) : String(v);
40+
41+
const nextPath = (v: number) => {
42+
if (level === 'year') return { year: v };
43+
if (level === 'month') return { year: active.year, month: v };
44+
return { year: active.year, month: active.month, day: v };
45+
};
46+
47+
const crumb = 'rounded px-1.5 py-0.5 text-primary hover:bg-blue-50 hover:underline';
48+
49+
return (
50+
<nav aria-label="Date hierarchy" className="flex flex-wrap items-center gap-3 text-sm">
51+
<div className="flex flex-wrap items-center gap-1 text-gray-500">
52+
<button type="button" className={crumb} onClick={() => onNavigate({})}>
53+
All dates
54+
</button>
55+
{active.year != null && (
56+
<>
57+
<span aria-hidden>/</span>
58+
<button type="button" className={crumb} onClick={() => onNavigate({ year: active.year })}>
59+
{active.year}
60+
</button>
61+
</>
62+
)}
63+
{active.month != null && (
64+
<>
65+
<span aria-hidden>/</span>
66+
<button
67+
type="button"
68+
className={crumb}
69+
onClick={() => onNavigate({ year: active.year, month: active.month })}
70+
>
71+
{MONTH_NAMES[active.month - 1] ?? active.month}
72+
</button>
73+
</>
74+
)}
75+
{active.day != null && (
76+
<>
77+
<span aria-hidden>/</span>
78+
<span className="px-1.5 py-0.5 font-medium text-gray-700">{active.day}</span>
79+
</>
80+
)}
81+
</div>
82+
{level !== 'done' && buckets.length > 0 && (
83+
<div className="flex flex-wrap items-center gap-2">
84+
{buckets.map((b) => (
85+
<button
86+
key={b.value}
87+
type="button"
88+
onClick={() => onNavigate(nextPath(b.value))}
89+
className="inline-flex items-center gap-1.5 rounded-full border border-gray-200 px-2.5 py-0.5 text-xs text-gray-700 hover:bg-gray-50"
90+
>
91+
{bucketLabel(b.value)}
92+
<span className="text-gray-400">{b.count}</span>
93+
</button>
94+
))}
95+
</div>
96+
)}
97+
</nav>
98+
);
99+
}
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
// @dar/list — generic, metadata-driven list page.
2-
// Implementation lands in PR #7.
1+
// @dar/list — generic, metadata-driven list-page building blocks.
2+
// Cohesive units extracted from the ListPage god-component (#428) so they
3+
// are individually testable and reusable. The full page composition lands
4+
// here as the decomposition progresses (#303).
35

4-
export {};
6+
export { DateHierarchyBar, type DateHierarchyBarProps } from './DateHierarchyBar';

frontend/pnpm-lock.yaml

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)