Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@dar/details": "workspace:*",
"@dar/form": "workspace:*",
"@dar/history": "workspace:*",
"@dar/list": "workspace:*",
"@dar/search": "workspace:*",
"@dar/settings": "workspace:*",
"@dar/sidebar": "workspace:*",
Expand Down
112 changes: 2 additions & 110 deletions frontend/apps/web/src/pages/ListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { Settings2 } from 'lucide-react';
import { Link, useHref, useNavigate, useParams, useSearchParams } from 'react-router-dom';

import {
useApiClient,
useList,
type ActionDescriptor,
type DateHierarchy,
type ListRow,
} from '@dar/data';
import { useApiClient, useList, type ActionDescriptor, type ListRow } from '@dar/data';
import {
columnsKey,
columnWidthsKey,
Expand All @@ -28,6 +22,7 @@ import {
} from '@dar/customization';
import { Breadcrumb, Button, Card, EmptyState, Modal, Skeleton, Table } from '@dar/ui';
import { FieldValueView } from '@dar/details';
import { DateHierarchyBar } from '@dar/list';
import { FilterBar } from '@dar/search';

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

const MONTH_NAMES = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];

export function ListPage() {
const params = useParams<{ appLabel: string; modelName: string }>();
const appLabel = params.appLabel ?? '';
Expand Down Expand Up @@ -689,94 +669,6 @@ function ListSkeleton() {
);
}

// date_hierarchy drill-down bar (#304 — Django changelist parity). Reads
// `active` for the current drill path (breadcrumb, each crumb navigates
// up) and `buckets` for the next level's options (drill down). The
// backend caps the level by the field; clicking wires ?year/?month/?day.
function DateHierarchyBar({
dh,
onNavigate,
}: {
dh: DateHierarchy;
onNavigate: (path: { year?: number | null; month?: number | null; day?: number | null }) => void;
}) {
const { active, buckets } = dh;
const level: 'year' | 'month' | 'day' | 'done' =
active.year == null
? 'year'
: active.month == null
? 'month'
: active.day == null
? 'day'
: 'done';

const bucketLabel = (v: number): string =>
level === 'month' ? (MONTH_NAMES[v - 1] ?? String(v)) : String(v);

const nextPath = (v: number) => {
if (level === 'year') return { year: v };
if (level === 'month') return { year: active.year, month: v };
return { year: active.year, month: active.month, day: v };
};

const crumb = 'rounded px-1.5 py-0.5 text-primary hover:bg-blue-50 hover:underline';

return (
<nav aria-label="Date hierarchy" className="flex flex-wrap items-center gap-3 text-sm">
<div className="flex flex-wrap items-center gap-1 text-gray-500">
<button type="button" className={crumb} onClick={() => onNavigate({})}>
All dates
</button>
{active.year != null && (
<>
<span aria-hidden>/</span>
<button
type="button"
className={crumb}
onClick={() => onNavigate({ year: active.year })}
>
{active.year}
</button>
</>
)}
{active.month != null && (
<>
<span aria-hidden>/</span>
<button
type="button"
className={crumb}
onClick={() => onNavigate({ year: active.year, month: active.month })}
>
{MONTH_NAMES[active.month - 1] ?? active.month}
</button>
</>
)}
{active.day != null && (
<>
<span aria-hidden>/</span>
<span className="px-1.5 py-0.5 font-medium text-gray-700">{active.day}</span>
</>
)}
</div>
{level !== 'done' && buckets.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{buckets.map((b) => (
<button
key={b.value}
type="button"
onClick={() => onNavigate(nextPath(b.value))}
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"
>
{bucketLabel(b.value)}
<span className="text-gray-400">{b.count}</span>
</button>
))}
</div>
)}
</nav>
);
}

function capitalize(value: string): string {
if (!value) return value;
return value.charAt(0).toUpperCase() + value.slice(1);
Expand Down
13 changes: 13 additions & 0 deletions frontend/packages/list/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ Composes `@dar/ui` primitives and `@dar/data` providers into a generic
list page that works for **any** model — entirely driven by the
metadata returned by `GET /api/v1/{app}/{model}/`.

## What lives here

Cohesive, individually-testable building blocks extracted from the
`ListPage` god-component (#428), so the page becomes a thin composition
layer and each unit can be unit-tested in isolation:

- **`DateHierarchyBar`** — the `date_hierarchy` drill-down breadcrumb +
next-level buckets (Django changelist parity). Props-driven
(`dh` + `onNavigate`), no router/business coupling.

More units (filter/columns modals, pagination, the `list_editable`
controller) land here as the decomposition progresses.

## Rules

- **`@dar/data` is the only data source.** Never import `@dar/api`
Expand Down
7 changes: 7 additions & 0 deletions frontend/packages/list/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,12 @@
"dependencies": {
"@dar/data": "workspace:*",
"@dar/ui": "workspace:*"
},
"peerDependencies": {
"react": "^18.3.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"react": "^18.3.0"
}
}
71 changes: 71 additions & 0 deletions frontend/packages/list/src/DateHierarchyBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import '@testing-library/jest-dom/vitest';

import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';

import type { DateHierarchy } from '@dar/data';

import { DateHierarchyBar } from './DateHierarchyBar';

function dh(overrides: Partial<DateHierarchy> = {}): DateHierarchy {
return {
field: 'created',
granularity_options: ['year', 'month', 'day'],
active: { year: null, month: null, day: null },
buckets: [],
...overrides,
};
}

describe('DateHierarchyBar', () => {
it('at the year level renders year buckets and drills into the clicked year', () => {
const onNavigate = vi.fn();
render(
<DateHierarchyBar
dh={dh({
buckets: [
{ value: 2025, count: 3 },
{ value: 2026, count: 5 },
],
})}
onNavigate={onNavigate}
/>,
);
// Year buckets render with their counts.
expect(screen.getByText('2026')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();

fireEvent.click(screen.getByText('2026'));
expect(onNavigate).toHaveBeenCalledWith({ year: 2026 });
});

it('at the month level shows month-name buckets and the year breadcrumb', () => {
const onNavigate = vi.fn();
render(
<DateHierarchyBar
dh={dh({
active: { year: 2026, month: null, day: null },
buckets: [{ value: 1, count: 2 }],
})}
onNavigate={onNavigate}
/>,
);
// Month bucket renders as a month name, not the raw number.
expect(screen.getByText('January')).toBeInTheDocument();

fireEvent.click(screen.getByText('January'));
expect(onNavigate).toHaveBeenCalledWith({ year: 2026, month: 1 });
});

it('breadcrumbs navigate up: "All dates" clears the drill path', () => {
const onNavigate = vi.fn();
render(
<DateHierarchyBar
dh={dh({ active: { year: 2026, month: 3, day: null }, buckets: [] })}
onNavigate={onNavigate}
/>,
);
fireEvent.click(screen.getByText('All dates'));
expect(onNavigate).toHaveBeenCalledWith({});
});
});
99 changes: 99 additions & 0 deletions frontend/packages/list/src/DateHierarchyBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { DateHierarchy } from '@dar/data';

const MONTH_NAMES = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];

export interface DateHierarchyBarProps {
dh: DateHierarchy;
onNavigate: (path: { year?: number | null; month?: number | null; day?: number | null }) => void;
}

// date_hierarchy drill-down bar (#304 — Django changelist parity). Reads
// `active` for the current drill path (breadcrumb, each crumb navigates
// up) and `buckets` for the next level's options (drill down). The
// backend caps the level by the field; clicking wires ?year/?month/?day.
export function DateHierarchyBar({ dh, onNavigate }: DateHierarchyBarProps) {
const { active, buckets } = dh;
const level: 'year' | 'month' | 'day' | 'done' =
active.year == null
? 'year'
: active.month == null
? 'month'
: active.day == null
? 'day'
: 'done';

const bucketLabel = (v: number): string =>
level === 'month' ? (MONTH_NAMES[v - 1] ?? String(v)) : String(v);

const nextPath = (v: number) => {
if (level === 'year') return { year: v };
if (level === 'month') return { year: active.year, month: v };
return { year: active.year, month: active.month, day: v };
};

const crumb = 'rounded px-1.5 py-0.5 text-primary hover:bg-blue-50 hover:underline';

return (
<nav aria-label="Date hierarchy" className="flex flex-wrap items-center gap-3 text-sm">
<div className="flex flex-wrap items-center gap-1 text-gray-500">
<button type="button" className={crumb} onClick={() => onNavigate({})}>
All dates
</button>
{active.year != null && (
<>
<span aria-hidden>/</span>
<button type="button" className={crumb} onClick={() => onNavigate({ year: active.year })}>
{active.year}
</button>
</>
)}
{active.month != null && (
<>
<span aria-hidden>/</span>
<button
type="button"
className={crumb}
onClick={() => onNavigate({ year: active.year, month: active.month })}
>
{MONTH_NAMES[active.month - 1] ?? active.month}
</button>
</>
)}
{active.day != null && (
<>
<span aria-hidden>/</span>
<span className="px-1.5 py-0.5 font-medium text-gray-700">{active.day}</span>
</>
)}
</div>
{level !== 'done' && buckets.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{buckets.map((b) => (
<button
key={b.value}
type="button"
onClick={() => onNavigate(nextPath(b.value))}
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"
>
{bucketLabel(b.value)}
<span className="text-gray-400">{b.count}</span>
</button>
))}
</div>
)}
</nav>
);
}
8 changes: 5 additions & 3 deletions frontend/packages/list/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// @dar/list — generic, metadata-driven list page.
// Implementation lands in PR #7.
// @dar/list — generic, metadata-driven list-page building blocks.
// Cohesive units extracted from the ListPage god-component (#428) so they
// are individually testable and reusable. The full page composition lands
// here as the decomposition progresses (#303).

export {};
export { DateHierarchyBar, type DateHierarchyBarProps } from './DateHierarchyBar';
10 changes: 10 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading