Skip to content

Commit af970a4

Browse files
Copilothotlong
andcommitted
feat: implement P0 v1.0 UI Essentials spec compliance
- Export TimelineConfig and TimelineConfigSchema from @object-ui/types (#64) - Export NavigationConfig and NavigationConfigSchema from @object-ui/types - Update ObjectTimeline to use spec-compliant startDateField with backward compat (#72) - Add endDateField, groupByField, colorField, scale to ObjectTimeline - Add navigation support to ObjectGallery via useNavigationOverlay (#66) - Implement navigation.view property in useNavigationOverlay hook (#68) - Implement emptyState spec property in ListView (#71) - Add emptyState to ListViewSchema type - Add tests for all new functionality Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 00769cf commit af970a4

File tree

11 files changed

+419
-78
lines changed

11 files changed

+419
-78
lines changed

packages/components/src/custom/navigation-overlay.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ export interface NavigationOverlayProps {
8989
width?: string | number;
9090
/** Whether navigation is an overlay mode */
9191
isOverlay: boolean;
92+
/** Target view/form name from NavigationConfig */
93+
view?: string;
9294
/** Title for the overlay header */
9395
title?: string;
9496
/** Description for the overlay header */

packages/plugin-list/src/ListView.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import * as React from 'react';
1010
import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay } from '@object-ui/components';
1111
import type { SortItem } from '@object-ui/components';
12-
import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Group, Paintbrush, Ruler } from 'lucide-react';
12+
import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Group, Paintbrush, Ruler, Inbox } from 'lucide-react';
1313
import type { FilterGroup } from '@object-ui/components';
1414
import { ViewSwitcher, ViewType } from './ViewSwitcher';
1515
import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react';
@@ -229,7 +229,7 @@ export const ListView: React.FC<ListViewProps> = ({
229229
}
230230

231231
// Check for Timeline capabilities
232-
if (schema.options?.timeline?.dateField || schema.options?.calendar?.startDateField) {
232+
if (schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || schema.options?.calendar?.startDateField) {
233233
views.push('timeline');
234234
}
235235

@@ -351,7 +351,7 @@ export const ListView: React.FC<ListViewProps> = ({
351351
return {
352352
type: 'object-timeline',
353353
...baseProps,
354-
dateField: schema.options?.timeline?.dateField || 'created_at',
354+
startDateField: schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || 'created_at',
355355
titleField: schema.options?.timeline?.titleField || 'name',
356356
...(schema.options?.timeline || {}),
357357
};
@@ -592,12 +592,24 @@ export const ListView: React.FC<ListViewProps> = ({
592592

593593
{/* View Content */}
594594
<div key={currentView} className="flex-1 min-h-0 bg-background relative overflow-hidden animate-in fade-in-0 duration-200">
595-
<SchemaRenderer
596-
schema={viewComponentSchema}
597-
{...props}
598-
data={data}
599-
loading={loading}
600-
/>
595+
{!loading && data.length === 0 ? (
596+
<div className="flex flex-col items-center justify-center h-full min-h-[200px] text-center p-8" data-testid="empty-state">
597+
<Inbox className="h-12 w-12 text-muted-foreground/50 mb-4" />
598+
<h3 className="text-lg font-medium text-foreground mb-1">
599+
{(typeof schema.emptyState?.title === 'string' ? schema.emptyState.title : undefined) ?? 'No items found'}
600+
</h3>
601+
<p className="text-sm text-muted-foreground max-w-md">
602+
{(typeof schema.emptyState?.message === 'string' ? schema.emptyState.message : undefined) ?? 'There are no records to display. Try adjusting your filters or adding new data.'}
603+
</p>
604+
</div>
605+
) : (
606+
<SchemaRenderer
607+
schema={viewComponentSchema}
608+
{...props}
609+
data={data}
610+
loading={loading}
611+
/>
612+
)}
601613
</div>
602614

603615
{/* Navigation Overlay (drawer/modal/popover) */}

packages/plugin-list/src/ObjectGallery.tsx

Lines changed: 89 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
*/
88

99
import React, { useState, useEffect } from 'react';
10-
import { useDataScope, useSchemaContext } from '@object-ui/react';
10+
import { useDataScope, useSchemaContext, useNavigationOverlay } from '@object-ui/react';
1111
import { ComponentRegistry } from '@object-ui/core';
12-
import { cn, Card, CardContent } from '@object-ui/components';
13-
import type { GalleryConfig } from '@object-ui/types';
12+
import { cn, Card, CardContent, NavigationOverlay } from '@object-ui/components';
13+
import type { GalleryConfig, ViewNavigationConfig } from '@object-ui/types';
1414

1515
export interface ObjectGalleryProps {
1616
schema: {
@@ -20,6 +20,8 @@ export interface ObjectGalleryProps {
2020
data?: Record<string, unknown>[];
2121
className?: string;
2222
gallery?: GalleryConfig;
23+
/** Navigation config for item click behavior */
24+
navigation?: ViewNavigationConfig;
2325
/** @deprecated Use gallery.coverField instead */
2426
imageField?: string;
2527
/** @deprecated Use gallery.titleField instead */
@@ -29,6 +31,8 @@ export interface ObjectGalleryProps {
2931
data?: Record<string, unknown>[];
3032
dataSource?: { find: (name: string, query: unknown) => Promise<unknown> };
3133
onCardClick?: (record: Record<string, unknown>) => void;
34+
/** Callback when a row/item is clicked (overrides NavigationConfig) */
35+
onRowClick?: (record: Record<string, unknown>) => void;
3236
}
3337

3438
const GRID_CLASSES: Record<NonNullable<GalleryConfig['cardSize']>, string> = {
@@ -52,6 +56,13 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
5256
const [fetchedData, setFetchedData] = useState<Record<string, unknown>[]>([]);
5357
const [loading, setLoading] = useState(false);
5458

59+
// --- NavigationConfig support ---
60+
const navigation = useNavigationOverlay({
61+
navigation: schema.navigation,
62+
objectName: schema.objectName,
63+
onRowClick: props.onRowClick ?? props.onCardClick,
64+
});
65+
5566
// Resolve GalleryConfig with backwards-compatible fallbacks
5667
const gallery = schema.gallery;
5768
const coverField = gallery?.coverField ?? schema.imageField ?? 'image';
@@ -110,66 +121,84 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
110121
if (!items.length) return <div className="p-4 text-sm text-muted-foreground">No items to display</div>;
111122

112123
return (
113-
<div
114-
className={cn('grid gap-4 p-4', GRID_CLASSES[cardSize], schema.className)}
115-
role="list"
116-
>
117-
{items.map((item, i) => {
118-
const id = (item._id ?? item.id ?? i) as string | number;
119-
const title = String(item[titleField] ?? 'Untitled');
120-
const imageUrl = item[coverField] as string | undefined;
121-
122-
return (
123-
<Card
124-
key={id}
125-
role="listitem"
126-
className={cn(
127-
'group overflow-hidden transition-all hover:shadow-md',
128-
props.onCardClick && 'cursor-pointer',
129-
)}
130-
onClick={props.onCardClick ? () => props.onCardClick!(item) : undefined}
131-
>
132-
<div className={cn('w-full overflow-hidden bg-muted relative', ASPECT_CLASSES[cardSize])}>
133-
{imageUrl ? (
134-
<img
135-
src={imageUrl}
136-
alt={title}
137-
className={cn(
138-
'h-full w-full transition-transform group-hover:scale-105',
139-
coverFit === 'cover' && 'object-cover',
140-
coverFit === 'contain' && 'object-contain',
141-
)}
142-
/>
143-
) : (
144-
<div className="flex h-full w-full items-center justify-center bg-secondary/50 text-muted-foreground">
145-
<span className="text-4xl font-light opacity-20">
146-
{title[0]?.toUpperCase()}
124+
<>
125+
<div
126+
className={cn('grid gap-4 p-4', GRID_CLASSES[cardSize], schema.className)}
127+
role="list"
128+
>
129+
{items.map((item, i) => {
130+
const id = (item._id ?? item.id ?? i) as string | number;
131+
const title = String(item[titleField] ?? 'Untitled');
132+
const imageUrl = item[coverField] as string | undefined;
133+
134+
return (
135+
<Card
136+
key={id}
137+
role="listitem"
138+
className={cn(
139+
'group overflow-hidden transition-all hover:shadow-md',
140+
(props.onCardClick || props.onRowClick || schema.navigation) && 'cursor-pointer',
141+
)}
142+
onClick={() => navigation.handleClick(item)}
143+
>
144+
<div className={cn('w-full overflow-hidden bg-muted relative', ASPECT_CLASSES[cardSize])}>
145+
{imageUrl ? (
146+
<img
147+
src={imageUrl}
148+
alt={title}
149+
className={cn(
150+
'h-full w-full transition-transform group-hover:scale-105',
151+
coverFit === 'cover' && 'object-cover',
152+
coverFit === 'contain' && 'object-contain',
153+
)}
154+
/>
155+
) : (
156+
<div className="flex h-full w-full items-center justify-center bg-secondary/50 text-muted-foreground">
157+
<span className="text-4xl font-light opacity-20">
158+
{title[0]?.toUpperCase()}
159+
</span>
160+
</div>
161+
)}
162+
</div>
163+
<CardContent className="p-3 border-t">
164+
<h3 className="font-medium truncate text-sm" title={title}>
165+
{title}
166+
</h3>
167+
{visibleFields && visibleFields.length > 0 && (
168+
<div className="mt-1 space-y-0.5">
169+
{visibleFields.map((field) => {
170+
const value = item[field];
171+
if (value == null) return null;
172+
return (
173+
<p key={field} className="text-xs text-muted-foreground truncate">
174+
{String(value)}
175+
</p>
176+
);
177+
})}
178+
</div>
179+
)}
180+
</CardContent>
181+
</Card>
182+
);
183+
})}
184+
</div>
185+
{navigation.isOverlay && (
186+
<NavigationOverlay {...navigation} title="Gallery Item">
187+
{(record) => (
188+
<div className="space-y-3">
189+
{Object.entries(record).map(([key, value]) => (
190+
<div key={key} className="flex flex-col">
191+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
192+
{key.replace(/_/g, ' ')}
147193
</span>
194+
<span className="text-sm">{String(value ?? '—')}</span>
148195
</div>
149-
)}
196+
))}
150197
</div>
151-
<CardContent className="p-3 border-t">
152-
<h3 className="font-medium truncate text-sm" title={title}>
153-
{title}
154-
</h3>
155-
{visibleFields && visibleFields.length > 0 && (
156-
<div className="mt-1 space-y-0.5">
157-
{visibleFields.map((field) => {
158-
const value = item[field];
159-
if (value == null) return null;
160-
return (
161-
<p key={field} className="text-xs text-muted-foreground truncate">
162-
{String(value)}
163-
</p>
164-
);
165-
})}
166-
</div>
167-
)}
168-
</CardContent>
169-
</Card>
170-
);
171-
})}
172-
</div>
198+
)}
199+
</NavigationOverlay>
200+
)}
201+
</>
173202
);
174203
};
175204

packages/plugin-list/src/__tests__/ListView.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,4 +221,44 @@ describe('ListView', () => {
221221
fireEvent.click(clearButton);
222222
}
223223
});
224+
225+
it('should show default empty state when no data', async () => {
226+
mockDataSource.find.mockResolvedValue([]);
227+
const schema: ListViewSchema = {
228+
type: 'list-view',
229+
objectName: 'contacts',
230+
viewType: 'grid',
231+
fields: ['name', 'email'],
232+
};
233+
234+
renderWithProvider(<ListView schema={schema} />);
235+
236+
// Wait for data fetch to complete
237+
await vi.waitFor(() => {
238+
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
239+
});
240+
expect(screen.getByText('No items found')).toBeInTheDocument();
241+
});
242+
243+
it('should show custom empty state when configured', async () => {
244+
mockDataSource.find.mockResolvedValue([]);
245+
const schema: ListViewSchema = {
246+
type: 'list-view',
247+
objectName: 'contacts',
248+
viewType: 'grid',
249+
fields: ['name', 'email'],
250+
emptyState: {
251+
title: 'No contacts yet',
252+
message: 'Add your first contact to get started.',
253+
},
254+
};
255+
256+
renderWithProvider(<ListView schema={schema} />);
257+
258+
await vi.waitFor(() => {
259+
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
260+
});
261+
expect(screen.getByText('No contacts yet')).toBeInTheDocument();
262+
expect(screen.getByText('Add your first contact to get started.')).toBeInTheDocument();
263+
});
224264
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect, vi } from 'vitest';
10+
import { render, screen, fireEvent } from '@testing-library/react';
11+
import { ObjectGallery } from '../ObjectGallery';
12+
13+
// Mock useDataScope, useSchemaContext, and useNavigationOverlay
14+
const mockHandleClick = vi.fn();
15+
const mockNavigationOverlay = {
16+
isOverlay: false,
17+
handleClick: mockHandleClick,
18+
selectedRecord: null,
19+
isOpen: false,
20+
close: vi.fn(),
21+
setIsOpen: vi.fn(),
22+
mode: 'page' as const,
23+
width: undefined,
24+
view: undefined,
25+
open: vi.fn(),
26+
};
27+
28+
vi.mock('@object-ui/react', () => ({
29+
useDataScope: () => undefined,
30+
useSchemaContext: () => ({ dataSource: undefined }),
31+
useNavigationOverlay: () => mockNavigationOverlay,
32+
}));
33+
34+
vi.mock('@object-ui/components', () => ({
35+
cn: (...args: any[]) => args.filter(Boolean).join(' '),
36+
Card: ({ children, onClick, ...props }: any) => (
37+
<div data-testid="gallery-card" onClick={onClick} {...props}>{children}</div>
38+
),
39+
CardContent: ({ children, ...props }: any) => <div {...props}>{children}</div>,
40+
NavigationOverlay: ({ children, selectedRecord }: any) => (
41+
selectedRecord ? <div data-testid="navigation-overlay">{children(selectedRecord)}</div> : null
42+
),
43+
}));
44+
45+
vi.mock('@object-ui/core', () => ({
46+
ComponentRegistry: { register: vi.fn() },
47+
}));
48+
49+
const mockItems = [
50+
{ id: '1', name: 'Item 1', image: 'https://example.com/1.jpg' },
51+
{ id: '2', name: 'Item 2', image: 'https://example.com/2.jpg' },
52+
];
53+
54+
describe('ObjectGallery', () => {
55+
it('renders gallery items', () => {
56+
const schema = { objectName: 'products' };
57+
render(<ObjectGallery schema={schema} data={mockItems} />);
58+
expect(screen.getByText('Item 1')).toBeInTheDocument();
59+
expect(screen.getByText('Item 2')).toBeInTheDocument();
60+
});
61+
62+
it('calls navigation.handleClick on card click', () => {
63+
const schema = {
64+
objectName: 'products',
65+
navigation: { mode: 'drawer' as const },
66+
};
67+
render(<ObjectGallery schema={schema} data={mockItems} />);
68+
69+
const cards = screen.getAllByTestId('gallery-card');
70+
fireEvent.click(cards[0]);
71+
72+
expect(mockHandleClick).toHaveBeenCalledWith(mockItems[0]);
73+
});
74+
75+
it('renders with cursor-pointer when navigation is configured', () => {
76+
const schema = {
77+
objectName: 'products',
78+
navigation: { mode: 'drawer' as const },
79+
};
80+
render(<ObjectGallery schema={schema} data={mockItems} />);
81+
82+
const cards = screen.getAllByTestId('gallery-card');
83+
expect(cards.length).toBe(2);
84+
});
85+
86+
it('renders with cursor-pointer when onCardClick is provided', () => {
87+
const onCardClick = vi.fn();
88+
const schema = { objectName: 'products' };
89+
render(<ObjectGallery schema={schema} data={mockItems} onCardClick={onCardClick} />);
90+
91+
const cards = screen.getAllByTestId('gallery-card');
92+
fireEvent.click(cards[0]);
93+
94+
expect(mockHandleClick).toHaveBeenCalled();
95+
});
96+
});

0 commit comments

Comments
 (0)