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
29 changes: 20 additions & 9 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -771,33 +771,44 @@ export const ListView: React.FC<ListViewProps> = ({
...(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 || {}),
};
}
Comment on lines +774 to +811
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

According to Rule #2 (Documentation Driven Development), any feature implemented or refactored MUST update corresponding documentation. This PR standardizes Gallery/Timeline config integration but does not update:

  1. packages/plugin-list/README.md - Should document the new spec-compliant gallery and timeline fields in the schema examples and add migration guidance from options.gallery/options.timeline to the new nested config pattern.
  2. packages/plugin-timeline/README.md - Should document the new timeline.* nested config support with field examples (startDateField, endDateField, scale, etc.).
  3. content/docs/plugins/plugin-timeline.mdx - Should show examples of using ObjectTimeline with the spec-compliant nested config.

The definition of done requires that documentation reflect the new code/architecture. Users reading the documentation will not discover the new nested config pattern without these updates.

Copilot generated this review using guidance from repository custom instructions.
case 'gantt':
return {
type: 'object-gantt',
Expand Down
203 changes: 203 additions & 0 deletions packages/plugin-list/src/__tests__/GalleryTimelineSpecConfig.test.tsx
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
109 changes: 109 additions & 0 deletions packages/plugin-list/src/__tests__/ObjectGallery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ObjectGallery schema={schema} data={data} />);
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(
<ObjectGallery schema={{ objectName: 'a', gallery: { cardSize: 'small' } }} data={data} />,
);
expect(c1.querySelector('[role="list"]')?.className).toContain('grid-cols-2');

// large cards → fewer columns
const { container: c2 } = render(
<ObjectGallery schema={{ objectName: 'a', gallery: { cardSize: 'large' } }} data={data} />,
);
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(<ObjectGallery schema={schema} data={data} />);
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(<ObjectGallery schema={schema} data={data} />);
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(<ObjectGallery schema={schema} data={data} />);
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(<ObjectGallery schema={schema} data={data} />);
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(<ObjectGallery schema={schema} data={data} />);
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(<ObjectGallery schema={schema} data={data} />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', 'https://spec.com/a.jpg');
});
});
});
Loading