Skip to content

Commit b2e6590

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(spa): single-row list toolbar + link-style FK editor (UX feedback) (#517)
List toolbar (ListPage + @dar/search FilterBar): - Search now sits on the SAME row as the filter pills (was a separate row above them); leading (bulk-actions) + search + filters + trailing all share one row. - "Clear all" is a real bordered button (was an underlined text link), shown only when >=1 filter is active, positioned as the second-to-last control with "Customize" pinned rightmost — so the row ends in [Clear all] [Customize]. - Rename the column customizer modal title "Columns" -> "Layout". Related-object FK editor (@dar/form AutocompleteInput): - A selected FK now renders read-only as a link button to the object's detail page (built from the registry mount). Being a real <a href>, left-click navigates and right-click / cmd-click "open in new tab" works — it no longer looks like a typeable input. - The pencil switches to an explicit edit/search mode instead of immediately clearing the value; "Cancel" reverts to the previous selection (the prior bug: there was no way to undo). "Clear" still empties an optional FK. FilterBar drops its internal Clear-all (the page composes the trailing cluster so it can order Clear-all before Customize); keeps the leading slot from #512; test updated. Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 39fc0ce commit b2e6590

4 files changed

Lines changed: 153 additions & 104 deletions

File tree

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

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
// the data layer's job.
77

88
import { useEffect, useMemo, useRef, useState } from 'react';
9-
import { GripVertical, Settings2 } from 'lucide-react';
9+
import { GripVertical, Settings2, X } from 'lucide-react';
1010
import { Link, useHref, useNavigate, useParams, useSearchParams } from 'react-router-dom';
1111

1212
import { useApiClient, useList, type ActionDescriptor, type ListRow } from '@dar/data';
@@ -475,7 +475,6 @@ export function ListPage() {
475475
filters={filters}
476476
active={activeFilters}
477477
onFilterChange={setFilter}
478-
onClearAll={() => patchParams((next) => filters.forEach((f) => next.delete(f.name)))}
479478
// Bulk-actions menu sits to the LEFT of the search input, and
480479
// only once at least one row is selected (Django changelist
481480
// parity — the actions selector leads the toolbar).
@@ -515,22 +514,35 @@ export function ListPage() {
515514
) : null
516515
}
517516
trailing={
518-
<button
519-
type="button"
520-
onClick={() => setColsOpen(true)}
521-
aria-haspopup="dialog"
522-
aria-label="Customize columns"
523-
title="Customize columns"
524-
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100"
525-
>
526-
<Settings2 className="h-4 w-4" aria-hidden />
527-
Customize
528-
{hiddenCols.size > 0 && (
529-
<span className="ml-0.5 rounded-full bg-gray-500 px-1.5 py-0.5 text-xs text-white">
530-
{hiddenCols.size} hidden
531-
</span>
517+
<>
518+
{activeFilterCount > 0 && (
519+
<button
520+
type="button"
521+
onClick={() => patchParams((next) => filters.forEach((f) => next.delete(f.name)))}
522+
title="Clear all filters"
523+
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100"
524+
>
525+
<X className="h-4 w-4" aria-hidden />
526+
Clear all
527+
</button>
532528
)}
533-
</button>
529+
<button
530+
type="button"
531+
onClick={() => setColsOpen(true)}
532+
aria-haspopup="dialog"
533+
aria-label="Customize columns"
534+
title="Customize columns"
535+
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100"
536+
>
537+
<Settings2 className="h-4 w-4" aria-hidden />
538+
Customize
539+
{hiddenCols.size > 0 && (
540+
<span className="ml-0.5 rounded-full bg-gray-500 px-1.5 py-0.5 text-xs text-white">
541+
{hiddenCols.size} hidden
542+
</span>
543+
)}
544+
</button>
545+
</>
534546
}
535547
/>
536548

@@ -631,7 +643,7 @@ export function ListPage() {
631643
)}
632644

633645
{colsOpen && (
634-
<Modal title="Columns" onClose={() => setColsOpen(false)}>
646+
<Modal title="Layout" onClose={() => setColsOpen(false)}>
635647
<p className="mb-2 text-xs text-gray-500">Drag to reorder; toggle to show or hide.</p>
636648
<ul className="space-y-1">
637649
{orderedDescriptors.map((c) => {

frontend/packages/form/src/AutocompleteInput.tsx

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,24 @@
22
//
33
// Used by FieldInput for foreignkey fields whose target table is too
44
// large to inline `choices` (the admin's autocomplete_fields case).
5-
// Debounced queries hit the target model's autocomplete endpoint; the
6-
// selected option's pk becomes the form value (wire §5.1), the label
7-
// is shown. Clearing the selection sets the value to null.
5+
//
6+
// Two modes:
7+
// - Display (a value is set, not editing): the related object is shown
8+
// as a link button to its detail page — left-click navigates, and
9+
// because it's a real <a href> the browser's right-click / cmd-click
10+
// "open in new tab" works too. A pencil switches to edit mode.
11+
// - Edit (no value yet, or the pencil was clicked): a debounced search
12+
// against the target's autocomplete endpoint. Picking a result sets
13+
// the value; "Cancel" reverts to the previously-selected object
14+
// (the value is never cleared just by entering edit mode), and
15+
// "Clear" empties an optional FK.
16+
//
17+
// The selected option's pk becomes the form value (wire §5.1).
818

919
import { useEffect, useMemo, useRef, useState } from 'react';
1020
import { Pencil } from 'lucide-react';
1121

12-
import { useApiClient, type AutocompleteResult, type WriteValue } from '@dar/data';
22+
import { useApiClient, useRegistry, type AutocompleteResult, type WriteValue } from '@dar/data';
1323

1424
interface AutocompleteInputProps {
1525
/** Target model the FK points at. */
@@ -30,13 +40,17 @@ export function AutocompleteInput({
3040
onChange,
3141
}: AutocompleteInputProps) {
3242
const client = useApiClient();
43+
const registry = useRegistry();
3344
const [query, setQuery] = useState('');
3445
const [open, setOpen] = useState(false);
46+
const [editing, setEditing] = useState(false);
3547
const [results, setResults] = useState<AutocompleteResult[]>([]);
3648
const [loading, setLoading] = useState(false);
3749
const [selectedLabel, setSelectedLabel] = useState<string | null>(initialLabel ?? null);
3850
const boxRef = useRef<HTMLDivElement>(null);
3951

52+
const hasValue = value != null && value !== '';
53+
4054
// Debounced search against the target autocomplete endpoint.
4155
useEffect(() => {
4256
if (!open) return;
@@ -76,21 +90,30 @@ export function AutocompleteInput({
7690
[invalid],
7791
);
7892

79-
// Selected state: show the chosen label + a clear button.
80-
if (value != null && value !== '') {
93+
// Display mode: a value is set and we're not editing it. Render the
94+
// related object as a link to its detail page (right-clickable / new
95+
// tab) plus a pencil to switch to the search.
96+
if (hasValue && !editing) {
97+
const mount = registry.data?.mount ?? '/';
98+
const href = `${mount}${to.app_label}/${to.model_name}/${encodeURIComponent(String(value))}/`;
8199
return (
82100
<div className="flex items-center gap-2">
83-
<span className="inline-flex items-center gap-2 rounded border border-gray-300 bg-gray-50 px-2 py-1 text-sm">
101+
<a
102+
href={href}
103+
className={`inline-flex items-center rounded border bg-gray-50 px-2 py-1 text-sm text-primary hover:bg-gray-100 hover:underline ${
104+
invalid ? 'border-red-400' : 'border-gray-300'
105+
}`}
106+
>
84107
{selectedLabel ?? String(value)}
85-
</span>
108+
</a>
86109
<button
87110
type="button"
88111
aria-label="Change"
89112
title="Change"
90113
className="inline-flex shrink-0 items-center justify-center rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-800"
91114
onClick={() => {
92-
onChange(null);
93-
setSelectedLabel(null);
115+
setEditing(true);
116+
setOpen(true);
94117
setQuery('');
95118
}}
96119
>
@@ -100,19 +123,51 @@ export function AutocompleteInput({
100123
);
101124
}
102125

126+
const cancelEdit = (): void => {
127+
setEditing(false);
128+
setOpen(false);
129+
setQuery('');
130+
};
131+
103132
return (
104133
<div ref={boxRef} className="relative">
105-
<input
106-
type="text"
107-
value={query}
108-
placeholder="Search…"
109-
className={base}
110-
onFocus={() => setOpen(true)}
111-
onChange={(e) => {
112-
setQuery(e.target.value);
113-
setOpen(true);
114-
}}
115-
/>
134+
<div className="flex items-center gap-2">
135+
<input
136+
type="text"
137+
value={query}
138+
placeholder="Search…"
139+
className={base}
140+
onFocus={() => setOpen(true)}
141+
onChange={(e) => {
142+
setQuery(e.target.value);
143+
setOpen(true);
144+
}}
145+
/>
146+
{hasValue && (
147+
<>
148+
<button
149+
type="button"
150+
className="shrink-0 rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800"
151+
onClick={() => {
152+
// Empty an optional FK; stay in edit mode so the user can
153+
// pick a replacement or leave it blank.
154+
onChange(null);
155+
setSelectedLabel(null);
156+
setQuery('');
157+
}}
158+
>
159+
Clear
160+
</button>
161+
<button
162+
type="button"
163+
className="shrink-0 rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800"
164+
onClick={cancelEdit}
165+
>
166+
Cancel
167+
</button>
168+
</>
169+
)}
170+
</div>
116171
{open && (query.length > 0 || results.length > 0) && (
117172
<div className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded border border-gray-200 bg-white py-1 shadow-lg">
118173
{loading && <div className="px-3 py-2 text-xs text-gray-400">Searching…</div>}
@@ -128,6 +183,7 @@ export function AutocompleteInput({
128183
onChange(r.id);
129184
setSelectedLabel(r.label);
130185
setOpen(false);
186+
setEditing(false);
131187
setQuery('');
132188
}}
133189
>

frontend/packages/search/src/FilterBar.test.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ const filters: FilterDescriptor[] = [
2121

2222
function setup(active: Record<string, string> = {}) {
2323
const onFilterChange = vi.fn();
24-
const onClearAll = vi.fn();
2524
const onSearchChange = vi.fn();
2625
render(
2726
<FilterBar
@@ -30,10 +29,9 @@ function setup(active: Record<string, string> = {}) {
3029
filters={filters}
3130
active={active}
3231
onFilterChange={onFilterChange}
33-
onClearAll={onClearAll}
3432
/>,
3533
);
36-
return { onFilterChange, onClearAll, onSearchChange };
34+
return { onFilterChange, onSearchChange };
3735
}
3836

3937
describe('FilterBar', () => {
@@ -57,20 +55,18 @@ describe('FilterBar', () => {
5755
expect(onFilterChange).toHaveBeenCalledWith('status', '');
5856
});
5957

60-
it('shows Clear all when a filter is active and calls onClearAll', () => {
61-
const onClearAll = vi.fn();
58+
it('renders trailing toolbar controls on the same row', () => {
6259
render(
6360
<FilterBar
6461
searchValue=""
6562
onSearchChange={() => {}}
6663
filters={filters}
6764
active={{ status: 'a' }}
6865
onFilterChange={() => {}}
69-
onClearAll={onClearAll}
66+
trailing={<button type="button">Customize</button>}
7067
/>,
7168
);
72-
fireEvent.click(screen.getByText('Clear all'));
73-
expect(onClearAll).toHaveBeenCalled();
69+
expect(screen.getByRole('button', { name: 'Customize' })).toBeInTheDocument();
7470
});
7571

7672
it('relays search input changes', () => {
@@ -87,7 +83,6 @@ describe('FilterBar', () => {
8783
filters={[]}
8884
active={{}}
8985
onFilterChange={() => {}}
90-
onClearAll={() => {}}
9186
leading={<button type="button">Actions · 2 ▾</button>}
9287
trailing={<button type="button">Customize</button>}
9388
/>,

0 commit comments

Comments
 (0)