-
Notifications
You must be signed in to change notification settings - Fork 1
feat: P0 spec compliance — timeline config, gallery navigation, empty state, navigation.view #538
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| views.push('timeline'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 || {}), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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; | |
| } | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: { | ||
|
|
@@ -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 */ | ||
|
|
@@ -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> = { | ||
|
|
@@ -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
|
||
|
|
||
| // Resolve GalleryConfig with backwards-compatible fallbacks | ||
| const gallery = schema.gallery; | ||
| const coverField = gallery?.coverField ?? schema.imageField ?? 'image'; | ||
|
|
@@ -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
|
||
| > | ||
| <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> | ||
| )} | ||
| </> | ||
| ); | ||
| }; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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.startDateFieldis present. This will show the Timeline view even when no timeline config exists. The Timeline capability check should depend only onschema.options.timelinefields.