diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 6df43ef1e..6a0fd7083 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -771,33 +771,44 @@ export const ListView: React.FC = ({ ...(schema.options?.calendar || {}), ...(schema.calendar || {}), }; - case 'gallery': + case 'gallery': { + // Merge spec config over legacy options into nested gallery prop + const mergedGallery = { + ...(schema.options?.gallery || {}), + ...(schema.gallery || {}), + }; return { type: 'object-gallery', ...baseProps, + // Nested gallery config (spec-compliant, used by ObjectGallery) + gallery: Object.keys(mergedGallery).length > 0 ? mergedGallery : undefined, + // Deprecated top-level props for backward compat imageField: schema.gallery?.coverField || schema.gallery?.imageField || schema.options?.gallery?.imageField, titleField: schema.gallery?.titleField || schema.options?.gallery?.titleField || 'name', subtitleField: schema.gallery?.subtitleField || schema.options?.gallery?.subtitleField, - ...(schema.gallery?.coverFit ? { coverFit: schema.gallery.coverFit } : {}), - ...(schema.gallery?.cardSize ? { cardSize: schema.gallery.cardSize } : {}), - ...(schema.gallery?.visibleFields ? { visibleFields: schema.gallery.visibleFields } : {}), ...(groupingConfig ? { grouping: groupingConfig } : {}), - ...(schema.options?.gallery || {}), - ...(schema.gallery || {}), }; - case 'timeline': + } + case 'timeline': { + // Merge spec config over legacy options into nested timeline prop + const mergedTimeline = { + ...(schema.options?.timeline || {}), + ...(schema.timeline || {}), + }; return { type: 'object-timeline', ...baseProps, + // Nested timeline config (spec-compliant, used by ObjectTimeline) + timeline: Object.keys(mergedTimeline).length > 0 ? mergedTimeline : undefined, + // Deprecated top-level props for backward compat startDateField: schema.timeline?.startDateField || schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || 'created_at', titleField: schema.timeline?.titleField || schema.options?.timeline?.titleField || 'name', ...(schema.timeline?.endDateField ? { endDateField: schema.timeline.endDateField } : {}), ...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}), ...(schema.timeline?.colorField ? { colorField: schema.timeline.colorField } : {}), ...(schema.timeline?.scale ? { scale: schema.timeline.scale } : {}), - ...(schema.options?.timeline || {}), - ...(schema.timeline || {}), }; + } case 'gantt': return { type: 'object-gantt', diff --git a/packages/plugin-list/src/__tests__/GalleryTimelineSpecConfig.test.tsx b/packages/plugin-list/src/__tests__/GalleryTimelineSpecConfig.test.tsx new file mode 100644 index 000000000..9ca3f6dc6 --- /dev/null +++ b/packages/plugin-list/src/__tests__/GalleryTimelineSpecConfig.test.tsx @@ -0,0 +1,203 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import type { ListViewSchema } from '@object-ui/types'; + +/** + * Tests for Gallery/Timeline spec config propagation through ListView's + * buildViewSchema. We test the internal logic by checking that the + * ListViewSchema types accept spec config and that the config values are correct. + */ + +describe('Gallery/Timeline Spec Config Types', () => { + describe('GalleryConfig on ListViewSchema', () => { + it('accepts spec gallery config with coverField', () => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'products', + viewType: 'gallery', + fields: ['name', 'photo'], + gallery: { + coverField: 'photo', + coverFit: 'contain', + cardSize: 'large', + titleField: 'name', + visibleFields: ['status', 'price'], + }, + }; + + expect(schema.gallery?.coverField).toBe('photo'); + expect(schema.gallery?.coverFit).toBe('contain'); + expect(schema.gallery?.cardSize).toBe('large'); + expect(schema.gallery?.titleField).toBe('name'); + expect(schema.gallery?.visibleFields).toEqual(['status', 'price']); + }); + + it('accepts all cardSize values', () => { + const sizes = ['small', 'medium', 'large'] as const; + sizes.forEach((cardSize) => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'products', + viewType: 'gallery', + fields: ['name'], + gallery: { cardSize }, + }; + expect(schema.gallery?.cardSize).toBe(cardSize); + }); + }); + + it('accepts all coverFit values', () => { + const fits = ['cover', 'contain', 'fill'] as const; + fits.forEach((coverFit) => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'products', + viewType: 'gallery', + fields: ['name'], + gallery: { coverFit }, + }; + expect(schema.gallery?.coverFit).toBe(coverFit); + }); + }); + + it('accepts legacy imageField and subtitleField alongside spec fields', () => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'products', + viewType: 'gallery', + fields: ['name'], + gallery: { + coverField: 'photo', + imageField: 'legacyImg', + subtitleField: 'description', + }, + }; + + expect(schema.gallery?.coverField).toBe('photo'); + expect(schema.gallery?.imageField).toBe('legacyImg'); + expect(schema.gallery?.subtitleField).toBe('description'); + }); + + it('accepts gallery config from legacy options as fallback', () => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'products', + viewType: 'gallery', + fields: ['name'], + options: { + gallery: { imageField: 'oldImg', titleField: 'label' }, + }, + }; + + expect(schema.options?.gallery?.imageField).toBe('oldImg'); + expect(schema.options?.gallery?.titleField).toBe('label'); + }); + }); + + describe('TimelineConfig on ListViewSchema', () => { + it('accepts spec timeline config with all fields', () => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'events', + viewType: 'timeline', + fields: ['name', 'date'], + timeline: { + startDateField: 'start_date', + endDateField: 'end_date', + titleField: 'event_name', + groupByField: 'category', + colorField: 'priority_color', + scale: 'month', + }, + }; + + expect(schema.timeline?.startDateField).toBe('start_date'); + expect(schema.timeline?.endDateField).toBe('end_date'); + expect(schema.timeline?.titleField).toBe('event_name'); + expect(schema.timeline?.groupByField).toBe('category'); + expect(schema.timeline?.colorField).toBe('priority_color'); + expect(schema.timeline?.scale).toBe('month'); + }); + + it('accepts legacy dateField for backward compatibility', () => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'events', + viewType: 'timeline', + fields: ['name'], + timeline: { + startDateField: 'created_at', + titleField: 'name', + dateField: 'legacy_date', + }, + }; + + expect(schema.timeline?.startDateField).toBe('created_at'); + expect(schema.timeline?.dateField).toBe('legacy_date'); + }); + + it('supports all scale values', () => { + const scales = ['hour', 'day', 'week', 'month', 'quarter', 'year'] as const; + scales.forEach((scale) => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'events', + viewType: 'timeline', + fields: ['name'], + timeline: { startDateField: 'date', titleField: 'name', scale }, + }; + expect(schema.timeline?.scale).toBe(scale); + }); + }); + + it('accepts timeline config from legacy options as fallback', () => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'events', + viewType: 'timeline', + fields: ['name'], + options: { + timeline: { dateField: 'created_at', titleField: 'name' }, + }, + }; + + expect(schema.options?.timeline?.dateField).toBe('created_at'); + }); + }); + + describe('spec config co-existence', () => { + it('gallery and timeline configs can coexist on the same ListViewSchema', () => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'projects', + viewType: 'grid', + fields: ['name', 'date', 'photo'], + gallery: { + coverField: 'photo', + cardSize: 'medium', + titleField: 'name', + visibleFields: ['status'], + }, + timeline: { + startDateField: 'start_date', + titleField: 'name', + scale: 'quarter', + groupByField: 'team', + }, + }; + + expect(schema.gallery?.coverField).toBe('photo'); + expect(schema.gallery?.cardSize).toBe('medium'); + expect(schema.timeline?.startDateField).toBe('start_date'); + expect(schema.timeline?.scale).toBe('quarter'); + expect(schema.timeline?.groupByField).toBe('team'); + }); + }); +}); diff --git a/packages/plugin-list/src/__tests__/ObjectGallery.test.tsx b/packages/plugin-list/src/__tests__/ObjectGallery.test.tsx index 5e57b184c..01e436726 100644 --- a/packages/plugin-list/src/__tests__/ObjectGallery.test.tsx +++ b/packages/plugin-list/src/__tests__/ObjectGallery.test.tsx @@ -93,4 +93,113 @@ describe('ObjectGallery', () => { expect(mockHandleClick).toHaveBeenCalled(); }); + + // ============================ + // Spec GalleryConfig integration + // ============================ + describe('Spec GalleryConfig', () => { + it('schema.gallery.coverField drives cover image', () => { + const data = [ + { id: '1', name: 'Photo A', photo: 'https://example.com/a.jpg' }, + ]; + const schema = { + objectName: 'albums', + gallery: { coverField: 'photo' }, + }; + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', 'https://example.com/a.jpg'); + }); + + it('schema.gallery.cardSize controls grid layout class', () => { + const data = [{ id: '1', name: 'Item', image: 'https://example.com/1.jpg' }]; + + // small cards → more columns + const { container: c1 } = render( + , + ); + expect(c1.querySelector('[role="list"]')?.className).toContain('grid-cols-2'); + + // large cards → fewer columns + const { container: c2 } = render( + , + ); + expect(c2.querySelector('[role="list"]')?.className).toContain('lg:grid-cols-3'); + }); + + it('schema.gallery.coverFit applies object-contain class', () => { + const data = [{ id: '1', name: 'Item', thumb: 'https://example.com/1.jpg' }]; + const schema = { + objectName: 'items', + gallery: { coverField: 'thumb', coverFit: 'contain' as const }, + }; + render(); + const img = screen.getByRole('img'); + expect(img.className).toContain('object-contain'); + }); + + it('schema.gallery.visibleFields shows additional fields on card', () => { + const data = [ + { id: '1', name: 'Item 1', status: 'active', category: 'books' }, + ]; + const schema = { + objectName: 'items', + gallery: { visibleFields: ['status', 'category'] }, + }; + render(); + expect(screen.getByText('active')).toBeInTheDocument(); + expect(screen.getByText('books')).toBeInTheDocument(); + }); + + it('schema.gallery.titleField overrides default title', () => { + const data = [ + { id: '1', name: 'Default Name', displayName: 'Custom Title' }, + ]; + const schema = { + objectName: 'items', + gallery: { titleField: 'displayName' }, + }; + render(); + expect(screen.getByText('Custom Title')).toBeInTheDocument(); + }); + + it('falls back to legacy imageField when gallery.coverField is not set', () => { + const data = [ + { id: '1', name: 'Item', legacyImg: 'https://example.com/legacy.jpg' }, + ]; + const schema = { + objectName: 'items', + imageField: 'legacyImg', + }; + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', 'https://example.com/legacy.jpg'); + }); + + it('falls back to legacy titleField when gallery.titleField is not set', () => { + const data = [ + { id: '1', name: 'Default', label: 'Legacy Title' }, + ]; + const schema = { + objectName: 'items', + titleField: 'label', + }; + render(); + expect(screen.getByText('Legacy Title')).toBeInTheDocument(); + }); + + it('spec gallery.coverField takes priority over legacy imageField', () => { + const data = [ + { id: '1', name: 'Item', photo: 'https://spec.com/a.jpg', oldImg: 'https://old.com/b.jpg' }, + ]; + const schema = { + objectName: 'items', + imageField: 'oldImg', + gallery: { coverField: 'photo' }, + }; + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', 'https://spec.com/a.jpg'); + }); + }); }); diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index f672cc0fe..710de74a1 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -7,7 +7,7 @@ */ import React, { useEffect, useState, useCallback } from 'react'; -import type { DataSource, TimelineSchema } from '@object-ui/types'; +import type { DataSource, TimelineSchema, TimelineConfig } from '@object-ui/types'; import { useDataScope, useNavigationOverlay } from '@object-ui/react'; import { NavigationOverlay } from '@object-ui/components'; import { usePullToRefresh } from '@object-ui/mobile'; @@ -38,19 +38,22 @@ const TimelineExtensionSchema = z.object({ export interface ObjectTimelineProps { schema: TimelineSchema & { objectName?: string; + /** Spec-compliant nested timeline config */ + timeline?: TimelineConfig; + /** @deprecated Use timeline.titleField instead */ titleField?: string; - /** @deprecated Use startDateField instead */ + /** @deprecated Use timeline.startDateField instead */ dateField?: string; - /** Spec-compliant: field name for the start date */ + /** @deprecated Use timeline.startDateField instead */ startDateField?: string; - /** Spec-compliant: field name for the end date */ + /** @deprecated Use timeline.endDateField instead */ endDateField?: string; descriptionField?: string; - /** Spec-compliant: field name for grouping timeline items */ + /** @deprecated Use timeline.groupByField instead */ groupByField?: string; - /** Spec-compliant: field name for timeline item color */ + /** @deprecated Use timeline.colorField instead */ colorField?: string; - /** Spec-compliant: time scale for the timeline display */ + /** @deprecated Use timeline.scale instead */ scale?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'; // Map data fields to timeline item properties mapping?: { @@ -79,6 +82,9 @@ export const ObjectTimeline: React.FC = ({ const [error, setError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); + // Resolve nested TimelineConfig (spec-compliant) + const timelineConfig = schema.timeline; + useEffect(() => { const result = TimelineExtensionSchema.safeParse(schema); if (!result.success) { @@ -125,14 +131,15 @@ export const ObjectTimeline: React.FC = ({ let effectiveItems = schema.items; if (!effectiveItems && rawData && Array.isArray(rawData)) { - const titleField = schema.mapping?.title || schema.titleField || 'name'; - // Spec-compliant: prefer startDateField, fallback to dateField for backward compat - const startDateField = schema.mapping?.date || schema.startDateField || schema.dateField || 'date'; - const endDateField = schema.endDateField || startDateField; - const descField = schema.mapping?.description || schema.descriptionField || 'description'; - const variantField = schema.mapping?.variant || 'variant'; - const groupByField = schema.groupByField; - const colorField = schema.colorField; + // Resolve TimelineConfig with backwards-compatible fallbacks + const titleField = timelineConfig?.titleField ?? schema.mapping?.title ?? schema.titleField ?? 'name'; + // Spec-compliant: prefer timeline.startDateField, fallback to flat props + const startDateField = timelineConfig?.startDateField ?? schema.mapping?.date ?? schema.startDateField ?? schema.dateField ?? 'date'; + const endDateField = timelineConfig?.endDateField ?? schema.endDateField ?? startDateField; + const descField = schema.mapping?.description ?? schema.descriptionField ?? 'description'; + const variantField = schema.mapping?.variant ?? 'variant'; + const groupByField = timelineConfig?.groupByField ?? schema.groupByField; + const colorField = timelineConfig?.colorField ?? schema.colorField; effectiveItems = rawData.map(item => ({ title: item[titleField], @@ -165,10 +172,15 @@ export const ObjectTimeline: React.FC = ({ onRowClick: onRowClick ?? onItemClick, }); + // Resolve scale: spec timeline.scale takes priority over flat schema.scale + const resolvedScale = timelineConfig?.scale ?? schema.scale; + const effectiveSchema = { ...schema, items: effectiveItems || [], className: className || schema.className, + // Map spec 'scale' to renderer 'timeScale' (used by gantt variant) + ...(resolvedScale ? { timeScale: resolvedScale } : {}), onItemClick: (item: any) => { const record = item._data || item; navigation.handleClick(record); diff --git a/packages/plugin-timeline/src/__tests__/ObjectTimeline.spec-config.test.tsx b/packages/plugin-timeline/src/__tests__/ObjectTimeline.spec-config.test.tsx new file mode 100644 index 000000000..ec592595a --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/ObjectTimeline.spec-config.test.tsx @@ -0,0 +1,232 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ObjectTimeline } from '../ObjectTimeline'; + +// Mock dependencies +vi.mock('@object-ui/react', () => ({ + useDataScope: () => undefined, + useNavigationOverlay: () => ({ + isOverlay: false, + handleClick: vi.fn(), + selectedRecord: null, + isOpen: false, + close: vi.fn(), + setIsOpen: vi.fn(), + mode: 'page' as const, + width: undefined, + view: undefined, + open: vi.fn(), + }), +})); + +vi.mock('@object-ui/components', () => ({ + NavigationOverlay: () => null, +})); + +vi.mock('@object-ui/mobile', () => ({ + usePullToRefresh: () => ({ + ref: { current: null }, + isRefreshing: false, + pullDistance: 0, + }), +})); + +// Mock the TimelineRenderer to inspect the schema it receives +const mockTimelineRenderer = vi.fn(() =>
); +vi.mock('../renderer', () => ({ + TimelineRenderer: (props: any) => mockTimelineRenderer(props), +})); + +describe('ObjectTimeline Spec Config', () => { + beforeEach(() => { + mockTimelineRenderer.mockClear(); + }); + + const mockData = [ + { id: '1', name: 'Event A', start_date: '2024-01-01', end_date: '2024-01-15', category: 'work', priority_color: 'red' }, + { id: '2', name: 'Event B', start_date: '2024-02-01', end_date: '2024-02-28', category: 'personal', priority_color: 'blue' }, + ]; + + describe('nested timeline config', () => { + it('uses timeline.startDateField to resolve start dates', () => { + const schema: any = { + type: 'timeline' as const, + objectName: 'events', + timeline: { startDateField: 'start_date', titleField: 'name' }, + }; + render(); + expect(mockTimelineRenderer).toHaveBeenCalled(); + const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema; + expect(renderedSchema.items[0].time).toBe('2024-01-01'); + expect(renderedSchema.items[0].startDate).toBe('2024-01-01'); + }); + + it('uses timeline.titleField to resolve titles', () => { + const schema: any = { + type: 'timeline' as const, + objectName: 'events', + timeline: { startDateField: 'start_date', titleField: 'name' }, + }; + render(); + const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema; + expect(renderedSchema.items[0].title).toBe('Event A'); + expect(renderedSchema.items[1].title).toBe('Event B'); + }); + + it('uses timeline.endDateField to resolve end dates', () => { + const schema: any = { + type: 'timeline' as const, + objectName: 'events', + timeline: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name' }, + }; + render(); + const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema; + expect(renderedSchema.items[0].endDate).toBe('2024-01-15'); + }); + + it('uses timeline.groupByField to add group property', () => { + const schema: any = { + type: 'timeline' as const, + objectName: 'events', + timeline: { startDateField: 'start_date', titleField: 'name', groupByField: 'category' }, + }; + render(); + const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema; + expect(renderedSchema.items[0].group).toBe('work'); + expect(renderedSchema.items[1].group).toBe('personal'); + }); + + it('uses timeline.colorField to add color property', () => { + const schema: any = { + type: 'timeline' as const, + objectName: 'events', + timeline: { startDateField: 'start_date', titleField: 'name', colorField: 'priority_color' }, + }; + render(); + const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema; + expect(renderedSchema.items[0].color).toBe('red'); + expect(renderedSchema.items[1].color).toBe('blue'); + }); + + it('uses timeline.scale to set timeScale on rendered schema', () => { + const schema: any = { + type: 'timeline' as const, + objectName: 'events', + timeline: { startDateField: 'start_date', titleField: 'name', scale: 'month' }, + }; + render(); + const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema; + expect(renderedSchema.timeScale).toBe('month'); + }); + + it('timeline.scale supports all valid values', () => { + const scales = ['hour', 'day', 'week', 'month', 'quarter', 'year'] as const; + scales.forEach((scale) => { + mockTimelineRenderer.mockClear(); + const schema: any = { + type: 'timeline' as const, + objectName: 'events', + timeline: { startDateField: 'start_date', titleField: 'name', scale }, + }; + render(); + const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema; + expect(renderedSchema.timeScale).toBe(scale); + }); + }); + }); + + describe('backward compatibility', () => { + it('falls back to flat startDateField when timeline config is not set', () => { + const schema: any = { + type: 'timeline' as const, + objectName: 'events', + startDateField: 'start_date', + titleField: 'name', + }; + render(); + const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema; + expect(renderedSchema.items[0].time).toBe('2024-01-01'); + }); + + it('falls back to legacy dateField when startDateField is not set', () => { + const data = [{ id: '1', name: 'Event', legacy_date: '2024-03-15' }]; + const schema: any = { + type: 'timeline' as const, + objectName: 'events', + dateField: 'legacy_date', + titleField: 'name', + }; + render(); + const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema; + expect(renderedSchema.items[0].time).toBe('2024-03-15'); + }); + + it('falls back to flat groupByField when timeline.groupByField is not set', () => { + const schema: any = { + type: 'timeline' as const, + objectName: 'events', + startDateField: 'start_date', + titleField: 'name', + groupByField: 'category', + }; + render(); + const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema; + expect(renderedSchema.items[0].group).toBe('work'); + }); + + it('falls back to flat colorField when timeline.colorField is not set', () => { + const schema: any = { + type: 'timeline' as const, + objectName: 'events', + startDateField: 'start_date', + titleField: 'name', + colorField: 'priority_color', + }; + render(); + const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema; + expect(renderedSchema.items[0].color).toBe('red'); + }); + + it('falls back to flat scale when timeline.scale is not set', () => { + const schema: any = { + type: 'timeline' as const, + objectName: 'events', + startDateField: 'start_date', + titleField: 'name', + scale: 'week', + }; + render(); + const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema; + expect(renderedSchema.timeScale).toBe('week'); + }); + + it('spec timeline config takes priority over flat props', () => { + const schema: any = { + type: 'timeline' as const, + objectName: 'events', + startDateField: 'end_date', // flat — should be overridden + titleField: 'id', // flat — should be overridden + groupByField: 'priority_color', // flat — should be overridden + timeline: { + startDateField: 'start_date', // spec — takes priority + titleField: 'name', // spec — takes priority + groupByField: 'category', // spec — takes priority + }, + }; + render(); + const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema; + expect(renderedSchema.items[0].time).toBe('2024-01-01'); + expect(renderedSchema.items[0].title).toBe('Event A'); + expect(renderedSchema.items[0].group).toBe('work'); + }); + }); +}); diff --git a/packages/react/src/spec-bridge/bridges/list-view.ts b/packages/react/src/spec-bridge/bridges/list-view.ts index 17e1f4ea5..0d6f9498f 100644 --- a/packages/react/src/spec-bridge/bridges/list-view.ts +++ b/packages/react/src/spec-bridge/bridges/list-view.ts @@ -8,6 +8,7 @@ import type { SchemaNode } from '@object-ui/core'; import type { BridgeContext, BridgeFn } from '../types'; +import type { ListViewGalleryConfig, ListViewTimelineConfig } from '@object-ui/types'; interface ListColumn { field: string; @@ -48,8 +49,8 @@ interface ListViewSpec { kanban?: any; calendar?: any; gantt?: any; - gallery?: any; - timeline?: any; + gallery?: ListViewGalleryConfig; + timeline?: ListViewTimelineConfig; // P1.1 additions rowActions?: string[]; bulkActions?: string[]; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c68e6562c..819ac4b33 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -297,6 +297,8 @@ export type { KanbanConfig, CalendarConfig, GanttConfig, + ListViewGalleryConfig, + ListViewTimelineConfig, SortConfig, // Component schemas ObjectMapSchema, diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 2733f601e..dcfb2a65e 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -78,8 +78,32 @@ import type { PaginationConfig, GroupingConfig, RowColorConfig, + GalleryConfig, + TimelineConfig, } from '@objectstack/spec/ui'; +/** + * Gallery configuration extended with legacy fields for backward compatibility. + * Spec fields from GalleryConfigSchema take priority; legacy fields serve as fallbacks. + */ +export type ListViewGalleryConfig = GalleryConfig & { + /** Legacy: image field (deprecated, use coverField) */ + imageField?: string; + /** Legacy: subtitle field */ + subtitleField?: string; + [key: string]: any; +}; + +/** + * Timeline configuration extended with legacy fields for backward compatibility. + * Spec fields from TimelineConfigSchema take priority; legacy fields serve as fallbacks. + */ +export type ListViewTimelineConfig = TimelineConfig & { + /** Legacy: date field (deprecated, use startDateField) */ + dateField?: string; + [key: string]: any; +}; + /** * Kanban Configuration * Canonical definition from @objectstack/spec/ui (KanbanConfigSchema). @@ -1350,42 +1374,10 @@ export interface ListViewSchema extends BaseSchema { }; /** Gallery-specific configuration. Aligned with @objectstack/spec GalleryConfigSchema. */ - gallery?: { - /** Field containing cover image URL */ - coverField?: string; - /** Cover image fit mode */ - coverFit?: 'cover' | 'contain' | 'fill'; - /** Card size preset */ - cardSize?: 'small' | 'medium' | 'large'; - /** Field used as card title */ - titleField?: string; - /** Fields to display on card */ - visibleFields?: string[]; - /** Legacy: image field (deprecated, use coverField) */ - imageField?: string; - /** Legacy: subtitle field */ - subtitleField?: string; - [key: string]: any; - }; + gallery?: ListViewGalleryConfig; /** Timeline-specific configuration. Aligned with @objectstack/spec TimelineConfigSchema. */ - timeline?: { - /** Field for start date */ - startDateField?: string; - /** Field for end date */ - endDateField?: string; - /** Field used as event title */ - titleField?: string; - /** Field to group events by */ - groupByField?: string; - /** Field for event color */ - colorField?: string; - /** Timeline scale */ - scale?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'; - /** Legacy: date field (deprecated, use startDateField) */ - dateField?: string; - [key: string]: any; - }; + timeline?: ListViewTimelineConfig; /** Visual Component overrides (legacy, prefer typed configs above) */ options?: Record;