Skip to content

Commit 19b015c

Browse files
Copilothotlong
andcommitted
feat: Gallery/Timeline spec config standardization with strong types
- Replace inline gallery/timeline types in ListViewSchema with GalleryConfig/TimelineConfig references - Add ListViewGalleryConfig and ListViewTimelineConfig intersection types with legacy field support - Fix ListView to pass gallery as nested object so ObjectGallery reads spec props correctly - Add comprehensive spec config tests for gallery (coverField, cardSize, coverFit, visibleFields) - Add spec config type tests for timeline (startDateField, scale, groupByField, etc.) - Add backward compatibility tests for legacy options.gallery/timeline Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent ea1487f commit 19b015c

5 files changed

Lines changed: 293 additions & 40 deletions

File tree

packages/plugin-list/src/ListView.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -771,20 +771,24 @@ export const ListView: React.FC<ListViewProps> = ({
771771
...(schema.options?.calendar || {}),
772772
...(schema.calendar || {}),
773773
};
774-
case 'gallery':
774+
case 'gallery': {
775+
// Merge spec config over legacy options into nested gallery prop
776+
const mergedGallery = {
777+
...(schema.options?.gallery || {}),
778+
...(schema.gallery || {}),
779+
};
775780
return {
776781
type: 'object-gallery',
777782
...baseProps,
783+
// Nested gallery config (spec-compliant, used by ObjectGallery)
784+
gallery: Object.keys(mergedGallery).length > 0 ? mergedGallery : undefined,
785+
// Deprecated top-level props for backward compat
778786
imageField: schema.gallery?.coverField || schema.gallery?.imageField || schema.options?.gallery?.imageField,
779787
titleField: schema.gallery?.titleField || schema.options?.gallery?.titleField || 'name',
780788
subtitleField: schema.gallery?.subtitleField || schema.options?.gallery?.subtitleField,
781-
...(schema.gallery?.coverFit ? { coverFit: schema.gallery.coverFit } : {}),
782-
...(schema.gallery?.cardSize ? { cardSize: schema.gallery.cardSize } : {}),
783-
...(schema.gallery?.visibleFields ? { visibleFields: schema.gallery.visibleFields } : {}),
784789
...(groupingConfig ? { grouping: groupingConfig } : {}),
785-
...(schema.options?.gallery || {}),
786-
...(schema.gallery || {}),
787790
};
791+
}
788792
case 'timeline':
789793
return {
790794
type: 'object-timeline',
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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 } from 'vitest';
10+
import type { ListViewSchema } from '@object-ui/types';
11+
12+
/**
13+
* Tests for Gallery/Timeline spec config propagation through ListView's
14+
* buildViewSchema. We test the internal logic by checking that the
15+
* ListViewSchema types accept spec config and that the config values are correct.
16+
*/
17+
18+
describe('Gallery/Timeline Spec Config Types', () => {
19+
describe('GalleryConfig on ListViewSchema', () => {
20+
it('accepts spec gallery config with coverField', () => {
21+
const schema: ListViewSchema = {
22+
type: 'list-view',
23+
objectName: 'products',
24+
viewType: 'gallery',
25+
fields: ['name', 'photo'],
26+
gallery: {
27+
coverField: 'photo',
28+
coverFit: 'contain',
29+
cardSize: 'large',
30+
titleField: 'name',
31+
visibleFields: ['status', 'price'],
32+
},
33+
};
34+
35+
expect(schema.gallery?.coverField).toBe('photo');
36+
expect(schema.gallery?.coverFit).toBe('contain');
37+
expect(schema.gallery?.cardSize).toBe('large');
38+
expect(schema.gallery?.titleField).toBe('name');
39+
expect(schema.gallery?.visibleFields).toEqual(['status', 'price']);
40+
});
41+
42+
it('accepts legacy imageField and subtitleField alongside spec fields', () => {
43+
const schema: ListViewSchema = {
44+
type: 'list-view',
45+
objectName: 'products',
46+
viewType: 'gallery',
47+
fields: ['name'],
48+
gallery: {
49+
coverField: 'photo',
50+
imageField: 'legacyImg',
51+
subtitleField: 'description',
52+
},
53+
};
54+
55+
expect(schema.gallery?.coverField).toBe('photo');
56+
expect(schema.gallery?.imageField).toBe('legacyImg');
57+
expect(schema.gallery?.subtitleField).toBe('description');
58+
});
59+
60+
it('accepts gallery config from legacy options as fallback', () => {
61+
const schema: ListViewSchema = {
62+
type: 'list-view',
63+
objectName: 'products',
64+
viewType: 'gallery',
65+
fields: ['name'],
66+
options: {
67+
gallery: { imageField: 'oldImg', titleField: 'label' },
68+
},
69+
};
70+
71+
expect(schema.options?.gallery?.imageField).toBe('oldImg');
72+
expect(schema.options?.gallery?.titleField).toBe('label');
73+
});
74+
});
75+
76+
describe('TimelineConfig on ListViewSchema', () => {
77+
it('accepts spec timeline config with all fields', () => {
78+
const schema: ListViewSchema = {
79+
type: 'list-view',
80+
objectName: 'events',
81+
viewType: 'timeline',
82+
fields: ['name', 'date'],
83+
timeline: {
84+
startDateField: 'start_date',
85+
endDateField: 'end_date',
86+
titleField: 'event_name',
87+
groupByField: 'category',
88+
colorField: 'priority_color',
89+
scale: 'month',
90+
},
91+
};
92+
93+
expect(schema.timeline?.startDateField).toBe('start_date');
94+
expect(schema.timeline?.endDateField).toBe('end_date');
95+
expect(schema.timeline?.titleField).toBe('event_name');
96+
expect(schema.timeline?.groupByField).toBe('category');
97+
expect(schema.timeline?.colorField).toBe('priority_color');
98+
expect(schema.timeline?.scale).toBe('month');
99+
});
100+
101+
it('accepts legacy dateField for backward compatibility', () => {
102+
const schema: ListViewSchema = {
103+
type: 'list-view',
104+
objectName: 'events',
105+
viewType: 'timeline',
106+
fields: ['name'],
107+
timeline: {
108+
startDateField: 'created_at',
109+
titleField: 'name',
110+
dateField: 'legacy_date',
111+
},
112+
};
113+
114+
expect(schema.timeline?.startDateField).toBe('created_at');
115+
expect(schema.timeline?.dateField).toBe('legacy_date');
116+
});
117+
118+
it('supports all scale values', () => {
119+
const scales = ['hour', 'day', 'week', 'month', 'quarter', 'year'] as const;
120+
scales.forEach((scale) => {
121+
const schema: ListViewSchema = {
122+
type: 'list-view',
123+
objectName: 'events',
124+
viewType: 'timeline',
125+
fields: ['name'],
126+
timeline: { startDateField: 'date', titleField: 'name', scale },
127+
};
128+
expect(schema.timeline?.scale).toBe(scale);
129+
});
130+
});
131+
132+
it('accepts timeline config from legacy options as fallback', () => {
133+
const schema: ListViewSchema = {
134+
type: 'list-view',
135+
objectName: 'events',
136+
viewType: 'timeline',
137+
fields: ['name'],
138+
options: {
139+
timeline: { dateField: 'created_at', titleField: 'name' },
140+
},
141+
};
142+
143+
expect(schema.options?.timeline?.dateField).toBe('created_at');
144+
});
145+
});
146+
});

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

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,113 @@ describe('ObjectGallery', () => {
9393

9494
expect(mockHandleClick).toHaveBeenCalled();
9595
});
96+
97+
// ============================
98+
// Spec GalleryConfig integration
99+
// ============================
100+
describe('Spec GalleryConfig', () => {
101+
it('schema.gallery.coverField drives cover image', () => {
102+
const data = [
103+
{ id: '1', name: 'Photo A', photo: 'https://example.com/a.jpg' },
104+
];
105+
const schema = {
106+
objectName: 'albums',
107+
gallery: { coverField: 'photo' },
108+
};
109+
render(<ObjectGallery schema={schema} data={data} />);
110+
const img = screen.getByRole('img');
111+
expect(img).toHaveAttribute('src', 'https://example.com/a.jpg');
112+
});
113+
114+
it('schema.gallery.cardSize controls grid layout class', () => {
115+
const data = [{ id: '1', name: 'Item', image: 'https://example.com/1.jpg' }];
116+
117+
// small cards → more columns
118+
const { container: c1 } = render(
119+
<ObjectGallery schema={{ objectName: 'a', gallery: { cardSize: 'small' } }} data={data} />,
120+
);
121+
expect(c1.querySelector('[role="list"]')?.className).toContain('grid-cols-2');
122+
123+
// large cards → fewer columns
124+
const { container: c2 } = render(
125+
<ObjectGallery schema={{ objectName: 'a', gallery: { cardSize: 'large' } }} data={data} />,
126+
);
127+
expect(c2.querySelector('[role="list"]')?.className).toContain('lg:grid-cols-3');
128+
});
129+
130+
it('schema.gallery.coverFit applies object-contain class', () => {
131+
const data = [{ id: '1', name: 'Item', thumb: 'https://example.com/1.jpg' }];
132+
const schema = {
133+
objectName: 'items',
134+
gallery: { coverField: 'thumb', coverFit: 'contain' as const },
135+
};
136+
render(<ObjectGallery schema={schema} data={data} />);
137+
const img = screen.getByRole('img');
138+
expect(img.className).toContain('object-contain');
139+
});
140+
141+
it('schema.gallery.visibleFields shows additional fields on card', () => {
142+
const data = [
143+
{ id: '1', name: 'Item 1', status: 'active', category: 'books' },
144+
];
145+
const schema = {
146+
objectName: 'items',
147+
gallery: { visibleFields: ['status', 'category'] },
148+
};
149+
render(<ObjectGallery schema={schema} data={data} />);
150+
expect(screen.getByText('active')).toBeInTheDocument();
151+
expect(screen.getByText('books')).toBeInTheDocument();
152+
});
153+
154+
it('schema.gallery.titleField overrides default title', () => {
155+
const data = [
156+
{ id: '1', name: 'Default Name', displayName: 'Custom Title' },
157+
];
158+
const schema = {
159+
objectName: 'items',
160+
gallery: { titleField: 'displayName' },
161+
};
162+
render(<ObjectGallery schema={schema} data={data} />);
163+
expect(screen.getByText('Custom Title')).toBeInTheDocument();
164+
});
165+
166+
it('falls back to legacy imageField when gallery.coverField is not set', () => {
167+
const data = [
168+
{ id: '1', name: 'Item', legacyImg: 'https://example.com/legacy.jpg' },
169+
];
170+
const schema = {
171+
objectName: 'items',
172+
imageField: 'legacyImg',
173+
};
174+
render(<ObjectGallery schema={schema} data={data} />);
175+
const img = screen.getByRole('img');
176+
expect(img).toHaveAttribute('src', 'https://example.com/legacy.jpg');
177+
});
178+
179+
it('falls back to legacy titleField when gallery.titleField is not set', () => {
180+
const data = [
181+
{ id: '1', name: 'Default', label: 'Legacy Title' },
182+
];
183+
const schema = {
184+
objectName: 'items',
185+
titleField: 'label',
186+
};
187+
render(<ObjectGallery schema={schema} data={data} />);
188+
expect(screen.getByText('Legacy Title')).toBeInTheDocument();
189+
});
190+
191+
it('spec gallery.coverField takes priority over legacy imageField', () => {
192+
const data = [
193+
{ id: '1', name: 'Item', photo: 'https://spec.com/a.jpg', oldImg: 'https://old.com/b.jpg' },
194+
];
195+
const schema = {
196+
objectName: 'items',
197+
imageField: 'oldImg',
198+
gallery: { coverField: 'photo' },
199+
};
200+
render(<ObjectGallery schema={schema} data={data} />);
201+
const img = screen.getByRole('img');
202+
expect(img).toHaveAttribute('src', 'https://spec.com/a.jpg');
203+
});
204+
});
96205
});

packages/types/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,8 @@ export type {
297297
KanbanConfig,
298298
CalendarConfig,
299299
GanttConfig,
300+
ListViewGalleryConfig,
301+
ListViewTimelineConfig,
300302
SortConfig,
301303
// Component schemas
302304
ObjectMapSchema,

packages/types/src/objectql.ts

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,32 @@ import type {
7878
PaginationConfig,
7979
GroupingConfig,
8080
RowColorConfig,
81+
GalleryConfig,
82+
TimelineConfig,
8183
} from '@objectstack/spec/ui';
8284

85+
/**
86+
* Gallery configuration extended with legacy fields for backward compatibility.
87+
* Spec fields from GalleryConfigSchema take priority; legacy fields serve as fallbacks.
88+
*/
89+
export type ListViewGalleryConfig = GalleryConfig & {
90+
/** Legacy: image field (deprecated, use coverField) */
91+
imageField?: string;
92+
/** Legacy: subtitle field */
93+
subtitleField?: string;
94+
[key: string]: any;
95+
};
96+
97+
/**
98+
* Timeline configuration extended with legacy fields for backward compatibility.
99+
* Spec fields from TimelineConfigSchema take priority; legacy fields serve as fallbacks.
100+
*/
101+
export type ListViewTimelineConfig = TimelineConfig & {
102+
/** Legacy: date field (deprecated, use startDateField) */
103+
dateField?: string;
104+
[key: string]: any;
105+
};
106+
83107
/**
84108
* Kanban Configuration
85109
* Canonical definition from @objectstack/spec/ui (KanbanConfigSchema).
@@ -1350,42 +1374,10 @@ export interface ListViewSchema extends BaseSchema {
13501374
};
13511375

13521376
/** Gallery-specific configuration. Aligned with @objectstack/spec GalleryConfigSchema. */
1353-
gallery?: {
1354-
/** Field containing cover image URL */
1355-
coverField?: string;
1356-
/** Cover image fit mode */
1357-
coverFit?: 'cover' | 'contain' | 'fill';
1358-
/** Card size preset */
1359-
cardSize?: 'small' | 'medium' | 'large';
1360-
/** Field used as card title */
1361-
titleField?: string;
1362-
/** Fields to display on card */
1363-
visibleFields?: string[];
1364-
/** Legacy: image field (deprecated, use coverField) */
1365-
imageField?: string;
1366-
/** Legacy: subtitle field */
1367-
subtitleField?: string;
1368-
[key: string]: any;
1369-
};
1377+
gallery?: ListViewGalleryConfig;
13701378

13711379
/** Timeline-specific configuration. Aligned with @objectstack/spec TimelineConfigSchema. */
1372-
timeline?: {
1373-
/** Field for start date */
1374-
startDateField?: string;
1375-
/** Field for end date */
1376-
endDateField?: string;
1377-
/** Field used as event title */
1378-
titleField?: string;
1379-
/** Field to group events by */
1380-
groupByField?: string;
1381-
/** Field for event color */
1382-
colorField?: string;
1383-
/** Timeline scale */
1384-
scale?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year';
1385-
/** Legacy: date field (deprecated, use startDateField) */
1386-
dateField?: string;
1387-
[key: string]: any;
1388-
};
1380+
timeline?: ListViewTimelineConfig;
13891381

13901382
/** Visual Component overrides (legacy, prefer typed configs above) */
13911383
options?: Record<string, any>;

0 commit comments

Comments
 (0)