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
77 changes: 60 additions & 17 deletions frontend/apps/web/src/pages/ListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,18 @@ import {
usePersistedState,
writeJSON,
} from '@dar/customization';
import { Breadcrumb, Button, Card, Checkbox, EmptyState, Modal, Skeleton, Table } from '@dar/ui';
import {
Breadcrumb,
Button,
Card,
Checkbox,
EmptyState,
Modal,
RecordCardList,
Skeleton,
Table,
useMediaQuery,
} from '@dar/ui';
import { FieldValueView } from '@dar/details';
import { DateHierarchyBar } from '@dar/list';
import { FilterBar } from '@dar/search';
Expand All @@ -47,6 +58,11 @@ export function ListPage() {
// Router basename (the SPA mount) so row anchors carry a full, openable
// href for native open-in-new-tab (#253).
const hrefBase = useHref('/').replace(/\/$/, '');
// Below Tailwind's `md` breakpoint (768px) a wide table is unreadable on
// phones/tablets — render the same rows as stacked record-cards instead
// (#421). Switching in JS (not a CSS `hidden`/`md:block` pair) keeps a
// single layout in the DOM, so there are no duplicate inputs/checkboxes.
const isNarrow = useMediaQuery('(max-width: 767px)');
const client = useApiClient();
const toast = useToast();
const [searchParams, setSearchParams] = useSearchParams();
Expand Down Expand Up @@ -562,19 +578,19 @@ export function ListPage() {
</div>
)}

{/* Table is always full-width now — filters live in the modal.
Row checkboxes appear only when the model has bulk actions
the user can run (#182). An empty list renders a proper
empty-state with a "+ Add" call-to-action (#293) instead of a
bare message, so a fresh model has an obvious next step. */}
<Card>
{/* A foreground refetch (filter / search / sort / page change)
keeps the previous `data` in hand, so without this the stale
rows would just sit there with no sign anything is happening.
Show skeleton rows while `loading` so the reload is visible —
and only fall back to the empty-state when we're genuinely
idle-and-empty, not mid-fetch. */}
{!loading && data.results.length === 0 ? (
{/* The list is a full-width table on desktop and stacked record-cards
on narrow viewports (#421); both read from the same `columns`.
Row checkboxes appear only when the model has bulk actions the
user can run (#182). An empty list renders a proper empty-state
with a "+ Add" call-to-action (#293) instead of a bare message,
so a fresh model has an obvious next step.

A foreground refetch (filter / search / sort / page change) keeps
the previous `data` in hand; showing skeletons while `loading`
makes the reload visible, and we only fall back to the
empty-state when genuinely idle-and-empty, not mid-fetch. */}
{!loading && data.results.length === 0 ? (
<Card>
<EmptyState
title={q || activeFilterCount > 0 ? 'No matches' : 'No objects yet'}
description={emptyLabel(Boolean(q), activeFilterCount)}
Expand All @@ -589,7 +605,34 @@ export function ListPage() {
) : undefined
}
/>
) : (
</Card>
) : isNarrow ? (
// Stacked record-cards: the card list is its own bordered surface,
// so it isn't wrapped in the table's <Card>. Inline list_editable
// cells (the `columns` render functions) still work; sort / resize
// are desktop-only affordances not surfaced on the cards.
<RecordCardList
columns={columns}
rows={data.results}
rowKey={(r) => r.pk}
onRowClick={(row) =>
navigate(
withPreservedFilters(`/${appLabel}/${modelName}/${row.pk}`, searchParams.toString()),
)
}
rowHref={(row) =>
withPreservedFilters(
`${hrefBase}/${appLabel}/${modelName}/${row.pk}`,
searchParams.toString(),
)
}
selectable={canRunActions}
selectedKeys={selected}
onToggleRow={toggleRow}
loading={loading}
/>
) : (
<Card>
<Table
columns={columns}
rows={data.results}
Expand All @@ -616,8 +659,8 @@ export function ListPage() {
columnWidths={colWidths}
onColumnResize={resizeColumn}
/>
)}
</Card>
</Card>
)}
<Pagination page={data.page} totalPages={totalPages} onChange={setPage} />

{pendingAction && (
Expand Down
16 changes: 16 additions & 0 deletions frontend/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
Generic, reusable, props-driven React components. **No business logic
allowed.** No knowledge of Django, the API, or any consumer model.

## Exports

- **Layout / data:** `Card`, `Table`, `RecordCardList`, `Skeleton`,
`EmptyState`, `Breadcrumb`.
- **Controls:** `Button`, `Input`, `Checkbox`, `Modal`, `Popover`,
`Spinner`.
- **Hooks:** `useMediaQuery(query)` — re-renders on a CSS media-query
match change (e.g. switching the list between `Table` and
`RecordCardList` at the `md` breakpoint, #421).

`Table` and `RecordCardList` share the same `TableColumn<Row>[]`
descriptor, so a page defines its columns once and renders the table on
wide viewports or stacked record-cards on narrow ones. `RecordCardList`
treats the first column as the card title and the rest as a label/value
list.

Planned components (PR #6 / #7):

- `Button`, `IconButton`
Expand Down
89 changes: 89 additions & 0 deletions frontend/packages/ui/src/RecordCardList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import '@testing-library/jest-dom/vitest';

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

import { RecordCardList } from './RecordCardList';
import type { TableColumn } from './Table';

interface Row {
id: number;
name: string;
email: string;
}

const columns: TableColumn<Row>[] = [
{ key: 'id', header: 'ID', render: (r) => r.id },
{ key: 'name', header: 'Name', render: (r) => r.name },
{ key: 'email', header: 'Email', render: (r) => r.email },
];

const rows: Row[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];

function renderList(props: Partial<React.ComponentProps<typeof RecordCardList<Row>>> = {}) {
return render(<RecordCardList columns={columns} rows={rows} rowKey={(r) => r.id} {...props} />);
}

describe('RecordCardList', () => {
it('renders one card per row with the non-title columns as labelled values', () => {
renderList();
// First column is the identity/title; the rest become label/value pairs.
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.getAllByText('Email')).toHaveLength(2);
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
// The identity column does not get repeated as a label/value row.
expect(screen.queryByText('ID')).not.toBeInTheDocument();
});

it('navigates on card tap via onRowClick', () => {
const onRowClick = vi.fn();
renderList({ onRowClick });
// Tap a body value (the card surface) — opens the record.
fireEvent.click(screen.getByText('Alice'));
expect(onRowClick).toHaveBeenCalledWith(rows[0]);
});

it('renders the title (first column) as an open-in-new-tab anchor when rowHref is set', () => {
renderList({ rowHref: (r) => `/app/model/${r.id}` });
// The first column (`id`) is the card title and carries the anchor.
const link = screen.getByText('1').closest('a');
expect(link).toHaveAttribute('href', '/app/model/1');
});

it('does not navigate in-app on a modified (new-tab) click of the title', () => {
const onRowClick = vi.fn();
renderList({ onRowClick, rowHref: (r) => `/app/model/${r.id}` });
fireEvent.click(screen.getByText('1'), { metaKey: true });
expect(onRowClick).not.toHaveBeenCalled();
});

it('toggles selection without opening the record', () => {
const onToggleRow = vi.fn();
const onRowClick = vi.fn();
renderList({ selectable: true, onToggleRow, onRowClick, selectedKeys: new Set() });
const [firstBox] = screen.getAllByRole('checkbox');
if (!firstBox) throw new Error('expected a selection checkbox per card');
fireEvent.click(firstBox);
expect(onToggleRow).toHaveBeenCalledWith(1);
// The selection click must not bubble up to the card's navigation.
expect(onRowClick).not.toHaveBeenCalled();
});

it('shows shimmer cards and marks the region busy while loading', () => {
const { container } = renderList({ loading: true });
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
expect(container.querySelector('.animate-pulse')).not.toBeNull();
});

it('renders the empty label when idle and empty', () => {
render(
<RecordCardList columns={columns} rows={[]} rowKey={(r) => r.id} emptyLabel="Nothing here" />,
);
expect(screen.getByText('Nothing here')).toBeInTheDocument();
});
});
152 changes: 152 additions & 0 deletions frontend/packages/ui/src/RecordCardList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// RecordCardList — a stacked card layout for tabular data on narrow
// viewports (#421). It consumes the SAME `TableColumn<Row>[]` descriptors,
// rows, selection, and navigation props as `<Table>`, so a page defines
// its columns once and renders either layout from a single source. The
// first column is the record's identity (the card title); the rest become
// a label/value list. Generic and model-agnostic (CLAUDE.md §7).

import type { ReactNode } from 'react';

import { Checkbox } from './Checkbox';
import { Skeleton } from './Skeleton';
import type { TableColumn } from './Table';

export interface RecordCardListProps<Row> {
/** Same column descriptors as `<Table>`; the first is the card title. */
columns: TableColumn<Row>[];
rows: Row[];
rowKey: (row: Row) => string | number;
/** Tap a card to open the record (in-app navigation). */
onRowClick?: (row: Row) => void;
/**
* When set, the card title becomes a real `<a href>` so the browser's
* native open-in-new-tab works (Cmd/Ctrl/middle-click); a plain tap is
* intercepted for in-app nav via `onRowClick`. Mirrors `<Table>` (#253).
* Should be the full app path including the router basename.
*/
rowHref?: (row: Row) => string;
/** Render a per-card selection checkbox wired to the props below. */
selectable?: boolean;
selectedKeys?: Set<string | number>;
onToggleRow?: (key: string | number) => void;
/** Show shimmer placeholder cards instead of `rows` during a refetch. */
loading?: boolean;
/** Placeholder card count while `loading` (defaults to a stable count). */
skeletonRows?: number;
emptyLabel?: string;
}

export function RecordCardList<Row>({
columns,
rows,
rowKey,
onRowClick,
rowHref,
selectable = false,
selectedKeys,
onToggleRow,
loading = false,
skeletonRows,
emptyLabel = 'No results.',
}: RecordCardListProps<Row>) {
// Only fall back to the empty-state when genuinely idle-and-empty — not
// mid-fetch, where skeleton cards are shown instead (matches `<Table>`).
if (!loading && rows.length === 0) {
return <div className="py-8 text-center text-sm text-gray-500">{emptyLabel}</div>;
}

const selected = selectedKeys ?? new Set<string | number>();
const skeletonCount = skeletonRows ?? Math.min(Math.max(rows.length || 6, 3), 12);
// First column = identity → title; the rest become the label/value body.
const [titleCol, ...detailCols] = columns;

if (loading) {
return (
<ul className="space-y-2" aria-busy="true">
{Array.from({ length: skeletonCount }).map((_, i) => (
<li key={`dar-card-skeleton-${i}`} className="rounded-lg border border-gray-300 bg-white p-4">
<Skeleton className="mb-3 h-5 w-1/3" />
<div className="space-y-2">
{Array.from({ length: Math.max(detailCols.length, 2) }).map((__, j) => (
<Skeleton key={j} className="h-4 w-2/3" />
))}
</div>
</li>
))}
</ul>
);
}

return (
<ul className="space-y-2">
{rows.map((row) => {
const key = rowKey(row);
const isSelected = selected.has(key);
const title: ReactNode = titleCol ? titleCol.render(row) : null;
return (
<li
key={key}
onClick={
onRowClick
? (e) => {
// A modified click (Cmd/Ctrl/Shift/Alt) is the browser's
// open-in-new-tab gesture — let the title anchor handle
// it; don't also navigate in-app.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
onRowClick(row);
}
: undefined
}
className={`rounded-lg border bg-white p-4 ${
isSelected ? 'border-primary' : 'border-gray-300'
} ${onRowClick ? 'cursor-pointer hover:bg-gray-50' : ''}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 font-medium text-gray-900">
{rowHref && titleCol ? (
// Real anchor so native open-in-new-tab works; a plain
// left-click is intercepted for in-app nav (#253).
<a
href={rowHref(row)}
className="text-inherit no-underline hover:underline"
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
e.stopPropagation();
onRowClick?.(row);
}}
>
{title}
</a>
) : (
title
)}
</div>
{selectable && (
// stopPropagation so toggling selection doesn't also open
// the record (the card's onClick navigates).
<span onClick={(e) => e.stopPropagation()} className="shrink-0">
<Checkbox
aria-label="Select row"
checked={isSelected}
onChange={() => onToggleRow?.(key)}
/>
</span>
)}
</div>
{detailCols.length > 0 && (
<dl className="mt-3 space-y-2 text-sm">
{detailCols.map((col) => (
<div key={col.key}>
<dt className="text-xs uppercase tracking-wide text-gray-500">{col.header}</dt>
<dd className="mt-0.5 text-gray-700">{col.render(row)}</dd>
</div>
))}
</dl>
)}
</li>
);
})}
</ul>
);
}
5 changes: 5 additions & 0 deletions frontend/packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export type { EmptyStateProps } from './EmptyState';
export { Table } from './Table';
export type { TableColumn, TableProps } from './Table';

export { RecordCardList } from './RecordCardList';
export type { RecordCardListProps } from './RecordCardList';

export { useMediaQuery } from './useMediaQuery';

export { Input } from './Input';
export type { InputProps } from './Input';

Expand Down
Loading
Loading