Skip to content

Commit 96cf72f

Browse files
Copilothotlong
andcommitted
feat: implement Phase H roadmap — SchemaRenderer integration, bridge components, navigation, pagination, filtering
- H.1.1: Replace RecordTable with SchemaRenderer view=grid in object-list - H.1.2: Replace field detail rendering with SchemaRenderer view=detail in object-record - H.1.3: Add record creation page with SchemaRenderer view=form - H.1.4: Add record editing page with SchemaRenderer view=form recordId - H.1.5: Wire KanbanBoard to SchemaRenderer view=kanban - H.1.6: Wire calendar to SchemaRenderer view=calendar - H.2.1: Dynamic sidebar from app metadata - H.2.2: Object navigation from app metadata - H.2.3: Breadcrumb generation from route context - H.2.4: Recent items tracking with localStorage - H.3.2: Server-side pagination in object list - H.3.3: Client-side sorting and filtering - H.3.4: QueryErrorBoundary component - H.4.1: ObjectPage bridge component with permissions check - H.4.2: ObjectToolbar with view switcher, new record, bulk actions - H.4.3: RelatedList for child/lookup records on detail pages - H.4.4: FilterPanel metadata-aware filter builder Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent c41dcaa commit 96cf72f

File tree

13 files changed

+1162
-105
lines changed

13 files changed

+1162
-105
lines changed

apps/web/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ const ObjectUIDemoPage = lazy(() => import('./pages/settings/objectui-demo'));
3737
const BusinessAppPage = lazy(() => import('./pages/apps/app'));
3838
const ObjectListPage = lazy(() => import('./pages/apps/object-list'));
3939
const ObjectRecordPage = lazy(() => import('./pages/apps/object-record'));
40+
const RecordCreatePage = lazy(() => import('./pages/apps/record-create'));
41+
const RecordEditPage = lazy(() => import('./pages/apps/record-edit'));
4042

4143
export function App() {
4244
const fallback = (
@@ -91,7 +93,9 @@ export function App() {
9193
<Route path="/apps/:appId" element={<AppLayout />}>
9294
<Route index element={<BusinessAppPage />} />
9395
<Route path=":objectName" element={<ObjectListPage />} />
96+
<Route path=":objectName/new" element={<RecordCreatePage />} />
9497
<Route path=":objectName/:recordId" element={<ObjectRecordPage />} />
98+
<Route path=":objectName/:recordId/edit" element={<RecordEditPage />} />
9599
</Route>
96100

97101
</Route>

apps/web/src/components/layouts/AppLayout.tsx

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Link, useLocation, useParams, Outlet } from 'react-router-dom';
2-
import { Blocks, Database, LayoutDashboard } from 'lucide-react';
2+
import { Database, LayoutDashboard, ChevronRight, Star } from 'lucide-react';
33
import {
44
Sidebar,
55
SidebarContent,
@@ -20,21 +20,73 @@ import { Separator } from '@/components/ui/separator';
2020
import { AppSwitcher } from '@/components/dashboard/AppSwitcher';
2121
import { NavUser } from '@/components/dashboard/NavUser';
2222
import { useAppDefinition, useObjectDefinition } from '@/hooks/use-metadata';
23+
import { useRecentItems } from '@/hooks/use-recent-items';
2324

2425
/** Helper: resolve an object name to its plural label for the sidebar. */
2526
function ObjectNavLabel({ objectName }: { objectName: string }) {
2627
const { data: objectDef } = useObjectDefinition(objectName);
2728
return <span>{objectDef?.pluralLabel ?? objectDef?.label ?? objectName}</span>;
2829
}
2930

31+
/** Breadcrumb component generated from current route context — H.2.3 */
32+
function Breadcrumbs({
33+
appName,
34+
appId,
35+
objectName,
36+
recordTitle,
37+
}: {
38+
appName: string;
39+
appId: string;
40+
objectName?: string;
41+
recordTitle?: string;
42+
}) {
43+
const items: { label: string; href?: string }[] = [
44+
{ label: appName, href: `/apps/${appId}` },
45+
];
46+
47+
if (objectName) {
48+
items.push({ label: objectName, href: `/apps/${appId}/${objectName}` });
49+
}
50+
51+
if (recordTitle) {
52+
items.push({ label: recordTitle });
53+
}
54+
55+
return (
56+
<nav className="flex items-center gap-1 text-sm" aria-label="Breadcrumb">
57+
{items.map((item, idx) => (
58+
<span key={idx} className="flex items-center gap-1">
59+
{idx > 0 && <ChevronRight className="size-3 text-muted-foreground" />}
60+
{item.href && idx < items.length - 1 ? (
61+
<Link to={item.href} className="text-muted-foreground hover:text-foreground">
62+
{item.label}
63+
</Link>
64+
) : (
65+
<span className="font-medium">{item.label}</span>
66+
)}
67+
</span>
68+
))}
69+
</nav>
70+
);
71+
}
72+
3073
export function AppLayout() {
3174
const { pathname } = useLocation();
32-
const { appId } = useParams();
75+
const { appId, objectName, recordId } = useParams();
3376

3477
const { data: appDef } = useAppDefinition(appId);
3578
const appName = appDef?.label ?? appId ?? 'App';
79+
// Dynamic sidebar from metadata — H.2.1, H.2.2
3680
const objectNames = appDef?.objects ?? [];
3781

82+
// Recent items — H.2.4
83+
const { recentItems } = useRecentItems();
84+
const appRecentItems = recentItems.filter((item) => item.appId === appId).slice(0, 5);
85+
86+
// Resolve breadcrumb path segments
87+
const breadcrumbObjectName = objectName;
88+
const breadcrumbRecordTitle = recordId && recordId !== 'new' ? recordId : undefined;
89+
3890
return (
3991
<SidebarProvider>
4092
<Sidebar>
@@ -43,6 +95,7 @@ export function AppLayout() {
4395
</SidebarHeader>
4496

4597
<SidebarContent>
98+
{/* Main navigation from app metadata — H.2.1 */}
4699
<SidebarGroup>
47100
<SidebarGroupLabel>{appName}</SidebarGroupLabel>
48101
<SidebarGroupContent>
@@ -61,16 +114,16 @@ export function AppLayout() {
61114
</SidebarMenuButton>
62115
</SidebarMenuItem>
63116

64-
{/* Object links from metadata */}
65-
{objectNames.map((objectName) => {
66-
const href = `/apps/${appId}/${objectName}`;
117+
{/* Object links derived from app metadata — H.2.2 */}
118+
{objectNames.map((objName) => {
119+
const href = `/apps/${appId}/${objName}`;
67120
const isActive = pathname.startsWith(href);
68121
return (
69-
<SidebarMenuItem key={objectName}>
70-
<SidebarMenuButton asChild isActive={isActive} tooltip={objectName}>
122+
<SidebarMenuItem key={objName}>
123+
<SidebarMenuButton asChild isActive={isActive} tooltip={objName}>
71124
<Link to={href}>
72125
<Database />
73-
<ObjectNavLabel objectName={objectName} />
126+
<ObjectNavLabel objectName={objName} />
74127
</Link>
75128
</SidebarMenuButton>
76129
</SidebarMenuItem>
@@ -79,6 +132,27 @@ export function AppLayout() {
79132
</SidebarMenu>
80133
</SidebarGroupContent>
81134
</SidebarGroup>
135+
136+
{/* Recent items — H.2.4 */}
137+
{appRecentItems.length > 0 && (
138+
<SidebarGroup>
139+
<SidebarGroupLabel>Recent</SidebarGroupLabel>
140+
<SidebarGroupContent>
141+
<SidebarMenu>
142+
{appRecentItems.map((item) => (
143+
<SidebarMenuItem key={item.id}>
144+
<SidebarMenuButton asChild isActive={pathname === item.href} tooltip={item.title}>
145+
<Link to={item.href}>
146+
<Star className="size-3.5" />
147+
<span className="truncate">{item.title}</span>
148+
</Link>
149+
</SidebarMenuButton>
150+
</SidebarMenuItem>
151+
))}
152+
</SidebarMenu>
153+
</SidebarGroupContent>
154+
</SidebarGroup>
155+
)}
82156
</SidebarContent>
83157

84158
<SidebarFooter>
@@ -91,10 +165,13 @@ export function AppLayout() {
91165
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
92166
<SidebarTrigger className="-ml-1" />
93167
<Separator orientation="vertical" className="mr-2 h-4" />
94-
<div className="flex items-center gap-2">
95-
<Blocks className="size-5 text-primary" />
96-
<span className="font-semibold">{appName}</span>
97-
</div>
168+
{/* Breadcrumb navigation — H.2.3 */}
169+
<Breadcrumbs
170+
appName={appName}
171+
appId={appId ?? ''}
172+
objectName={breadcrumbObjectName}
173+
recordTitle={breadcrumbRecordTitle}
174+
/>
98175
</header>
99176
<div className="flex flex-1 flex-col gap-4 p-4">
100177
<Outlet />
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* FilterPanel — metadata-aware filter builder for list views.
3+
*
4+
* Generates filter controls dynamically from an ObjectDefinition's fields.
5+
* Supports text search, select/dropdown filters, date ranges, and number ranges.
6+
*
7+
* Task H.4.4
8+
*/
9+
10+
import { useState, useCallback } from 'react';
11+
import { Search, X, Filter } from 'lucide-react';
12+
import { Button } from '@/components/ui/button';
13+
import { Input } from '@/components/ui/input';
14+
import { Badge } from '@/components/ui/badge';
15+
import type { ObjectDefinition } from '@/types/metadata';
16+
import { resolveFields } from '@/types/metadata';
17+
18+
export interface FilterValue {
19+
field: string;
20+
operator: 'equals' | 'contains' | 'gt' | 'lt' | 'between';
21+
value: string;
22+
}
23+
24+
interface FilterPanelProps {
25+
objectDef: ObjectDefinition;
26+
filters: FilterValue[];
27+
onFiltersChange: (filters: FilterValue[]) => void;
28+
/** Search term for global text search */
29+
searchTerm?: string;
30+
onSearchChange?: (term: string) => void;
31+
}
32+
33+
export function FilterPanel({
34+
objectDef,
35+
filters,
36+
onFiltersChange,
37+
searchTerm = '',
38+
onSearchChange,
39+
}: FilterPanelProps) {
40+
const [isExpanded, setIsExpanded] = useState(false);
41+
const [pendingField, setPendingField] = useState('');
42+
const [pendingValue, setPendingValue] = useState('');
43+
44+
const allFields = resolveFields(objectDef.fields, ['id']);
45+
const filterableFields = allFields.filter(
46+
(f) =>
47+
!f.readonly &&
48+
['text', 'email', 'select', 'radio', 'number', 'currency', 'date', 'datetime'].includes(f.type),
49+
);
50+
51+
const addFilter = useCallback(() => {
52+
if (!pendingField || !pendingValue) return;
53+
const field = filterableFields.find((f) => f.name === pendingField);
54+
if (!field) return;
55+
56+
const operator =
57+
field.type === 'select' || field.type === 'radio' ? 'equals' : 'contains';
58+
59+
onFiltersChange([
60+
...filters,
61+
{ field: pendingField, operator, value: pendingValue },
62+
]);
63+
setPendingField('');
64+
setPendingValue('');
65+
}, [pendingField, pendingValue, filters, filterableFields, onFiltersChange]);
66+
67+
const removeFilter = useCallback(
68+
(index: number) => {
69+
onFiltersChange(filters.filter((_, i) => i !== index));
70+
},
71+
[filters, onFiltersChange],
72+
);
73+
74+
const clearAll = useCallback(() => {
75+
onFiltersChange([]);
76+
onSearchChange?.('');
77+
}, [onFiltersChange, onSearchChange]);
78+
79+
const selectedFieldDef = filterableFields.find((f) => f.name === pendingField);
80+
81+
return (
82+
<div className="space-y-3" data-testid="filter-panel">
83+
{/* Search + filter toggle */}
84+
<div className="flex items-center gap-2">
85+
{onSearchChange && (
86+
<div className="relative flex-1">
87+
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
88+
<Input
89+
placeholder={`Search ${(objectDef.pluralLabel ?? objectDef.label ?? 'records').toLowerCase()}...`}
90+
value={searchTerm}
91+
onChange={(e) => onSearchChange(e.target.value)}
92+
className="pl-9"
93+
aria-label="Search records"
94+
/>
95+
</div>
96+
)}
97+
<Button
98+
variant={isExpanded ? 'secondary' : 'outline'}
99+
size="sm"
100+
className="gap-1.5"
101+
onClick={() => setIsExpanded(!isExpanded)}
102+
>
103+
<Filter className="size-4" />
104+
Filters
105+
{filters.length > 0 && (
106+
<Badge variant="default" className="ml-1 size-5 justify-center rounded-full p-0 text-xs">
107+
{filters.length}
108+
</Badge>
109+
)}
110+
</Button>
111+
{(filters.length > 0 || searchTerm) && (
112+
<Button variant="ghost" size="sm" onClick={clearAll}>
113+
Clear all
114+
</Button>
115+
)}
116+
</div>
117+
118+
{/* Active filters */}
119+
{filters.length > 0 && (
120+
<div className="flex flex-wrap gap-1.5">
121+
{filters.map((filter, index) => {
122+
const fieldDef = allFields.find((f) => f.name === filter.field);
123+
return (
124+
<Badge key={index} variant="secondary" className="gap-1 pr-1">
125+
<span className="font-medium">{fieldDef?.label ?? filter.field}:</span>
126+
<span>{filter.value}</span>
127+
<button
128+
onClick={() => removeFilter(index)}
129+
className="ml-1 rounded-full p-0.5 hover:bg-muted"
130+
aria-label={`Remove ${fieldDef?.label ?? filter.field} filter`}
131+
>
132+
<X className="size-3" />
133+
</button>
134+
</Badge>
135+
);
136+
})}
137+
</div>
138+
)}
139+
140+
{/* Filter builder */}
141+
{isExpanded && (
142+
<div className="flex items-end gap-2 rounded-lg border bg-muted/30 p-3">
143+
<div className="flex-1 space-y-1">
144+
<label className="text-xs font-medium text-muted-foreground">Field</label>
145+
<select
146+
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
147+
value={pendingField}
148+
onChange={(e) => {
149+
setPendingField(e.target.value);
150+
setPendingValue('');
151+
}}
152+
aria-label="Filter field"
153+
>
154+
<option value="">Select field...</option>
155+
{filterableFields.map((field) => (
156+
<option key={field.name} value={field.name}>
157+
{field.label}
158+
</option>
159+
))}
160+
</select>
161+
</div>
162+
163+
<div className="flex-1 space-y-1">
164+
<label className="text-xs font-medium text-muted-foreground">Value</label>
165+
{selectedFieldDef?.options ? (
166+
<select
167+
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
168+
value={pendingValue}
169+
onChange={(e) => setPendingValue(e.target.value)}
170+
aria-label="Filter value"
171+
>
172+
<option value="">Select value...</option>
173+
{selectedFieldDef.options.map((opt) => (
174+
<option key={opt.value} value={opt.value}>
175+
{opt.label}
176+
</option>
177+
))}
178+
</select>
179+
) : (
180+
<Input
181+
placeholder="Filter value..."
182+
value={pendingValue}
183+
onChange={(e) => setPendingValue(e.target.value)}
184+
className="h-9"
185+
aria-label="Filter value"
186+
/>
187+
)}
188+
</div>
189+
190+
<Button size="sm" onClick={addFilter} disabled={!pendingField || !pendingValue}>
191+
Add
192+
</Button>
193+
</div>
194+
)}
195+
</div>
196+
);
197+
}

0 commit comments

Comments
 (0)