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
2 changes: 2 additions & 0 deletions packages/components/src/custom/navigation-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export interface NavigationOverlayProps {
width?: string | number;
/** Whether navigation is an overlay mode */
isOverlay: boolean;
/** Target view/form name from NavigationConfig */
view?: string;
/** Title for the overlay header */
title?: string;
/** Description for the overlay header */
Expand Down
40 changes: 31 additions & 9 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import * as React from 'react';
import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay } from '@object-ui/components';
import type { SortItem } from '@object-ui/components';
import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Group, Paintbrush, Ruler } from 'lucide-react';
import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Group, Paintbrush, Ruler, Inbox, icons, type LucideIcon } from 'lucide-react';
import type { FilterGroup } from '@object-ui/components';
import { ViewSwitcher, ViewType } from './ViewSwitcher';
import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react';
Expand Down Expand Up @@ -229,7 +229,7 @@ export const ListView: React.FC<ListViewProps> = ({
}

// Check for Timeline capabilities
if (schema.options?.timeline?.dateField || schema.options?.calendar?.startDateField) {
if (schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || schema.options?.calendar?.startDateField) {
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timeline view availability is currently enabled when schema.options.calendar.startDateField is present. This will show the Timeline view even when no timeline config exists. The Timeline capability check should depend only on schema.options.timeline fields.

Suggested change
if (schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || schema.options?.calendar?.startDateField) {
if (schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField) {

Copilot uses AI. Check for mistakes.
views.push('timeline');
}

Expand Down Expand Up @@ -351,7 +351,7 @@ export const ListView: React.FC<ListViewProps> = ({
return {
type: 'object-timeline',
...baseProps,
dateField: schema.options?.timeline?.dateField || 'created_at',
startDateField: schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || 'created_at',
titleField: schema.options?.timeline?.titleField || 'name',
...(schema.options?.timeline || {}),
};
Expand Down Expand Up @@ -592,12 +592,34 @@ export const ListView: React.FC<ListViewProps> = ({

{/* View Content */}
<div key={currentView} className="flex-1 min-h-0 bg-background relative overflow-hidden animate-in fade-in-0 duration-200">
<SchemaRenderer
schema={viewComponentSchema}
{...props}
data={data}
loading={loading}
/>
{!loading && data.length === 0 ? (
(() => {
const iconName = schema.emptyState?.icon;
const ResolvedIcon: LucideIcon = iconName
? ((icons as Record<string, LucideIcon>)[
iconName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')
] ?? Inbox)
: Inbox;
Comment on lines +597 to +602
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

schema.emptyState?.icon is used as a string without a runtime type check (iconName.split(...)). If a non-string value is provided, this will throw at render time. Also, this local kebab→Pascal conversion doesn’t apply the existing Lucide rename mapping used elsewhere (e.g. Home→House), so some valid icon names may fail to resolve; consider centralizing this logic or reusing the existing resolver.

Suggested change
const iconName = schema.emptyState?.icon;
const ResolvedIcon: LucideIcon = iconName
? ((icons as Record<string, LucideIcon>)[
iconName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')
] ?? Inbox)
: Inbox;
const rawIcon = schema.emptyState?.icon;
let ResolvedIcon: LucideIcon = Inbox;
if (typeof rawIcon === 'string' && rawIcon.trim().length > 0) {
const iconMap = icons as Record<string, LucideIcon>;
// Try direct key match first (e.g. "Inbox", "home", "House")
const directMatch = iconMap[rawIcon] ?? iconMap[rawIcon.charAt(0).toUpperCase() + rawIcon.slice(1)];
if (directMatch) {
ResolvedIcon = directMatch;
} else {
const pascalName = rawIcon
.split('-')
.map(w => (w ? w.charAt(0).toUpperCase() + w.slice(1) : ''))
.join('');
ResolvedIcon = iconMap[pascalName] ?? Inbox;
}
}

Copilot uses AI. Check for mistakes.
return (
<div className="flex flex-col items-center justify-center h-full min-h-[200px] text-center p-8" data-testid="empty-state">
<ResolvedIcon className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-1">
{(typeof schema.emptyState?.title === 'string' ? schema.emptyState.title : undefined) ?? 'No items found'}
</h3>
<p className="text-sm text-muted-foreground max-w-md">
{(typeof schema.emptyState?.message === 'string' ? schema.emptyState.message : undefined) ?? 'There are no records to display. Try adjusting your filters or adding new data.'}
</p>
</div>
);
})()
) : (
<SchemaRenderer
schema={viewComponentSchema}
{...props}
data={data}
loading={loading}
/>
)}
</div>

{/* Navigation Overlay (drawer/modal/popover) */}
Expand Down
149 changes: 89 additions & 60 deletions packages/plugin-list/src/ObjectGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
*/

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

export interface ObjectGalleryProps {
schema: {
Expand All @@ -20,6 +20,8 @@ export interface ObjectGalleryProps {
data?: Record<string, unknown>[];
className?: string;
gallery?: GalleryConfig;
/** Navigation config for item click behavior */
navigation?: ViewNavigationConfig;
/** @deprecated Use gallery.coverField instead */
imageField?: string;
/** @deprecated Use gallery.titleField instead */
Expand All @@ -29,6 +31,8 @@ export interface ObjectGalleryProps {
data?: Record<string, unknown>[];
dataSource?: { find: (name: string, query: unknown) => Promise<unknown> };
onCardClick?: (record: Record<string, unknown>) => void;
/** Callback when a row/item is clicked (overrides NavigationConfig) */
onRowClick?: (record: Record<string, unknown>) => void;
}

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

// --- NavigationConfig support ---
const navigation = useNavigationOverlay({
navigation: schema.navigation,
objectName: schema.objectName,
onRowClick: props.onRowClick ?? props.onCardClick,
});
Comment on lines +59 to +64
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ObjectGallery wires useNavigationOverlay without providing an onNavigate handler, and schema doesn’t expose one. In page mode (the default), clicks will become a no-op (no overlay, no navigation). Add onNavigate support (in schema/props) and pass it into useNavigationOverlay so page navigation works consistently with other view plugins.

Copilot uses AI. Check for mistakes.

// Resolve GalleryConfig with backwards-compatible fallbacks
const gallery = schema.gallery;
const coverField = gallery?.coverField ?? schema.imageField ?? 'image';
Expand Down Expand Up @@ -110,66 +121,84 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
if (!items.length) return <div className="p-4 text-sm text-muted-foreground">No items to display</div>;

return (
<div
className={cn('grid gap-4 p-4', GRID_CLASSES[cardSize], schema.className)}
role="list"
>
{items.map((item, i) => {
const id = (item._id ?? item.id ?? i) as string | number;
const title = String(item[titleField] ?? 'Untitled');
const imageUrl = item[coverField] as string | undefined;

return (
<Card
key={id}
role="listitem"
className={cn(
'group overflow-hidden transition-all hover:shadow-md',
props.onCardClick && 'cursor-pointer',
)}
onClick={props.onCardClick ? () => props.onCardClick!(item) : undefined}
>
<div className={cn('w-full overflow-hidden bg-muted relative', ASPECT_CLASSES[cardSize])}>
{imageUrl ? (
<img
src={imageUrl}
alt={title}
className={cn(
'h-full w-full transition-transform group-hover:scale-105',
coverFit === 'cover' && 'object-cover',
coverFit === 'contain' && 'object-contain',
)}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-secondary/50 text-muted-foreground">
<span className="text-4xl font-light opacity-20">
{title[0]?.toUpperCase()}
<>
<div
className={cn('grid gap-4 p-4', GRID_CLASSES[cardSize], schema.className)}
role="list"
>
{items.map((item, i) => {
const id = (item._id ?? item.id ?? i) as string | number;
const title = String(item[titleField] ?? 'Untitled');
const imageUrl = item[coverField] as string | undefined;

return (
<Card
key={id}
role="listitem"
className={cn(
'group overflow-hidden transition-all hover:shadow-md',
(props.onCardClick || props.onRowClick || schema.navigation) && 'cursor-pointer',
)}
onClick={() => navigation.handleClick(item)}
Comment on lines +138 to +142
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cursor-pointer styling is enabled whenever schema.navigation is present, even if navigation is effectively disabled (mode: 'none' or preventNavigation: true). This can make cards look clickable while clicks do nothing. Gate the pointer styling on the effective navigation behavior (or on navigation.mode/navigation.preventNavigation).

Copilot uses AI. Check for mistakes.
>
<div className={cn('w-full overflow-hidden bg-muted relative', ASPECT_CLASSES[cardSize])}>
{imageUrl ? (
<img
src={imageUrl}
alt={title}
className={cn(
'h-full w-full transition-transform group-hover:scale-105',
coverFit === 'cover' && 'object-cover',
coverFit === 'contain' && 'object-contain',
)}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-secondary/50 text-muted-foreground">
<span className="text-4xl font-light opacity-20">
{title[0]?.toUpperCase()}
</span>
</div>
)}
</div>
<CardContent className="p-3 border-t">
<h3 className="font-medium truncate text-sm" title={title}>
{title}
</h3>
{visibleFields && visibleFields.length > 0 && (
<div className="mt-1 space-y-0.5">
{visibleFields.map((field) => {
const value = item[field];
if (value == null) return null;
return (
<p key={field} className="text-xs text-muted-foreground truncate">
{String(value)}
</p>
);
})}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
{navigation.isOverlay && (
<NavigationOverlay {...navigation} title="Gallery Item">
{(record) => (
<div className="space-y-3">
{Object.entries(record).map(([key, value]) => (
<div key={key} className="flex flex-col">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{key.replace(/_/g, ' ')}
</span>
<span className="text-sm">{String(value ?? '—')}</span>
</div>
)}
))}
</div>
<CardContent className="p-3 border-t">
<h3 className="font-medium truncate text-sm" title={title}>
{title}
</h3>
{visibleFields && visibleFields.length > 0 && (
<div className="mt-1 space-y-0.5">
{visibleFields.map((field) => {
const value = item[field];
if (value == null) return null;
return (
<p key={field} className="text-xs text-muted-foreground truncate">
{String(value)}
</p>
);
})}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
</NavigationOverlay>
)}
</>
);
};

Expand Down
40 changes: 40 additions & 0 deletions packages/plugin-list/src/__tests__/ListView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,4 +221,44 @@ describe('ListView', () => {
fireEvent.click(clearButton);
}
});

it('should show default empty state when no data', async () => {
mockDataSource.find.mockResolvedValue([]);
const schema: ListViewSchema = {
type: 'list-view',
objectName: 'contacts',
viewType: 'grid',
fields: ['name', 'email'],
};

renderWithProvider(<ListView schema={schema} />);

// Wait for data fetch to complete
await vi.waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
expect(screen.getByText('No items found')).toBeInTheDocument();
});

it('should show custom empty state when configured', async () => {
mockDataSource.find.mockResolvedValue([]);
const schema: ListViewSchema = {
type: 'list-view',
objectName: 'contacts',
viewType: 'grid',
fields: ['name', 'email'],
emptyState: {
title: 'No contacts yet',
message: 'Add your first contact to get started.',
},
};

renderWithProvider(<ListView schema={schema} />);

await vi.waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
expect(screen.getByText('No contacts yet')).toBeInTheDocument();
expect(screen.getByText('Add your first contact to get started.')).toBeInTheDocument();
});
});
Loading