Skip to content

Commit 34fe884

Browse files
authored
Merge pull request #780 from objectstack-ai/copilot/standardize-gallery-timeline-config
2 parents 7cbb7a1 + 4841df6 commit 34fe884

File tree

8 files changed

+622
-60
lines changed

8 files changed

+622
-60
lines changed

packages/plugin-list/src/ListView.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -771,33 +771,44 @@ 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
};
788-
case 'timeline':
791+
}
792+
case 'timeline': {
793+
// Merge spec config over legacy options into nested timeline prop
794+
const mergedTimeline = {
795+
...(schema.options?.timeline || {}),
796+
...(schema.timeline || {}),
797+
};
789798
return {
790799
type: 'object-timeline',
791800
...baseProps,
801+
// Nested timeline config (spec-compliant, used by ObjectTimeline)
802+
timeline: Object.keys(mergedTimeline).length > 0 ? mergedTimeline : undefined,
803+
// Deprecated top-level props for backward compat
792804
startDateField: schema.timeline?.startDateField || schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || 'created_at',
793805
titleField: schema.timeline?.titleField || schema.options?.timeline?.titleField || 'name',
794806
...(schema.timeline?.endDateField ? { endDateField: schema.timeline.endDateField } : {}),
795807
...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}),
796808
...(schema.timeline?.colorField ? { colorField: schema.timeline.colorField } : {}),
797809
...(schema.timeline?.scale ? { scale: schema.timeline.scale } : {}),
798-
...(schema.options?.timeline || {}),
799-
...(schema.timeline || {}),
800810
};
811+
}
801812
case 'gantt':
802813
return {
803814
type: 'object-gantt',
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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 all cardSize values', () => {
43+
const sizes = ['small', 'medium', 'large'] as const;
44+
sizes.forEach((cardSize) => {
45+
const schema: ListViewSchema = {
46+
type: 'list-view',
47+
objectName: 'products',
48+
viewType: 'gallery',
49+
fields: ['name'],
50+
gallery: { cardSize },
51+
};
52+
expect(schema.gallery?.cardSize).toBe(cardSize);
53+
});
54+
});
55+
56+
it('accepts all coverFit values', () => {
57+
const fits = ['cover', 'contain', 'fill'] as const;
58+
fits.forEach((coverFit) => {
59+
const schema: ListViewSchema = {
60+
type: 'list-view',
61+
objectName: 'products',
62+
viewType: 'gallery',
63+
fields: ['name'],
64+
gallery: { coverFit },
65+
};
66+
expect(schema.gallery?.coverFit).toBe(coverFit);
67+
});
68+
});
69+
70+
it('accepts legacy imageField and subtitleField alongside spec fields', () => {
71+
const schema: ListViewSchema = {
72+
type: 'list-view',
73+
objectName: 'products',
74+
viewType: 'gallery',
75+
fields: ['name'],
76+
gallery: {
77+
coverField: 'photo',
78+
imageField: 'legacyImg',
79+
subtitleField: 'description',
80+
},
81+
};
82+
83+
expect(schema.gallery?.coverField).toBe('photo');
84+
expect(schema.gallery?.imageField).toBe('legacyImg');
85+
expect(schema.gallery?.subtitleField).toBe('description');
86+
});
87+
88+
it('accepts gallery config from legacy options as fallback', () => {
89+
const schema: ListViewSchema = {
90+
type: 'list-view',
91+
objectName: 'products',
92+
viewType: 'gallery',
93+
fields: ['name'],
94+
options: {
95+
gallery: { imageField: 'oldImg', titleField: 'label' },
96+
},
97+
};
98+
99+
expect(schema.options?.gallery?.imageField).toBe('oldImg');
100+
expect(schema.options?.gallery?.titleField).toBe('label');
101+
});
102+
});
103+
104+
describe('TimelineConfig on ListViewSchema', () => {
105+
it('accepts spec timeline config with all fields', () => {
106+
const schema: ListViewSchema = {
107+
type: 'list-view',
108+
objectName: 'events',
109+
viewType: 'timeline',
110+
fields: ['name', 'date'],
111+
timeline: {
112+
startDateField: 'start_date',
113+
endDateField: 'end_date',
114+
titleField: 'event_name',
115+
groupByField: 'category',
116+
colorField: 'priority_color',
117+
scale: 'month',
118+
},
119+
};
120+
121+
expect(schema.timeline?.startDateField).toBe('start_date');
122+
expect(schema.timeline?.endDateField).toBe('end_date');
123+
expect(schema.timeline?.titleField).toBe('event_name');
124+
expect(schema.timeline?.groupByField).toBe('category');
125+
expect(schema.timeline?.colorField).toBe('priority_color');
126+
expect(schema.timeline?.scale).toBe('month');
127+
});
128+
129+
it('accepts legacy dateField for backward compatibility', () => {
130+
const schema: ListViewSchema = {
131+
type: 'list-view',
132+
objectName: 'events',
133+
viewType: 'timeline',
134+
fields: ['name'],
135+
timeline: {
136+
startDateField: 'created_at',
137+
titleField: 'name',
138+
dateField: 'legacy_date',
139+
},
140+
};
141+
142+
expect(schema.timeline?.startDateField).toBe('created_at');
143+
expect(schema.timeline?.dateField).toBe('legacy_date');
144+
});
145+
146+
it('supports all scale values', () => {
147+
const scales = ['hour', 'day', 'week', 'month', 'quarter', 'year'] as const;
148+
scales.forEach((scale) => {
149+
const schema: ListViewSchema = {
150+
type: 'list-view',
151+
objectName: 'events',
152+
viewType: 'timeline',
153+
fields: ['name'],
154+
timeline: { startDateField: 'date', titleField: 'name', scale },
155+
};
156+
expect(schema.timeline?.scale).toBe(scale);
157+
});
158+
});
159+
160+
it('accepts timeline config from legacy options as fallback', () => {
161+
const schema: ListViewSchema = {
162+
type: 'list-view',
163+
objectName: 'events',
164+
viewType: 'timeline',
165+
fields: ['name'],
166+
options: {
167+
timeline: { dateField: 'created_at', titleField: 'name' },
168+
},
169+
};
170+
171+
expect(schema.options?.timeline?.dateField).toBe('created_at');
172+
});
173+
});
174+
175+
describe('spec config co-existence', () => {
176+
it('gallery and timeline configs can coexist on the same ListViewSchema', () => {
177+
const schema: ListViewSchema = {
178+
type: 'list-view',
179+
objectName: 'projects',
180+
viewType: 'grid',
181+
fields: ['name', 'date', 'photo'],
182+
gallery: {
183+
coverField: 'photo',
184+
cardSize: 'medium',
185+
titleField: 'name',
186+
visibleFields: ['status'],
187+
},
188+
timeline: {
189+
startDateField: 'start_date',
190+
titleField: 'name',
191+
scale: 'quarter',
192+
groupByField: 'team',
193+
},
194+
};
195+
196+
expect(schema.gallery?.coverField).toBe('photo');
197+
expect(schema.gallery?.cardSize).toBe('medium');
198+
expect(schema.timeline?.startDateField).toBe('start_date');
199+
expect(schema.timeline?.scale).toBe('quarter');
200+
expect(schema.timeline?.groupByField).toBe('team');
201+
});
202+
});
203+
});

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
});

0 commit comments

Comments
 (0)