Skip to content

Commit 26884cc

Browse files
authored
Merge pull request #538 from objectstack-ai/copilot/implement-ui-essentials
2 parents 83f991f + 7f368cf commit 26884cc

File tree

11 files changed

+429
-78
lines changed

11 files changed

+429
-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: 31 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, icons, type LucideIcon } 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,34 @@ 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+
(() => {
597+
const iconName = schema.emptyState?.icon;
598+
const ResolvedIcon: LucideIcon = iconName
599+
? ((icons as Record<string, LucideIcon>)[
600+
iconName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')
601+
] ?? Inbox)
602+
: Inbox;
603+
return (
604+
<div className="flex flex-col items-center justify-center h-full min-h-[200px] text-center p-8" data-testid="empty-state">
605+
<ResolvedIcon className="h-12 w-12 text-muted-foreground/50 mb-4" />
606+
<h3 className="text-lg font-medium text-foreground mb-1">
607+
{(typeof schema.emptyState?.title === 'string' ? schema.emptyState.title : undefined) ?? 'No items found'}
608+
</h3>
609+
<p className="text-sm text-muted-foreground max-w-md">
610+
{(typeof schema.emptyState?.message === 'string' ? schema.emptyState.message : undefined) ?? 'There are no records to display. Try adjusting your filters or adding new data.'}
611+
</p>
612+
</div>
613+
);
614+
})()
615+
) : (
616+
<SchemaRenderer
617+
schema={viewComponentSchema}
618+
{...props}
619+
data={data}
620+
loading={loading}
621+
/>
622+
)}
601623
</div>
602624

603625
{/* 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
});

0 commit comments

Comments
 (0)