diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 31fa91b804..885c83425b 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -14,6 +14,14 @@ import { ViewDataSchema, HttpRequestSchema, HttpMethodSchema, + ColumnSummarySchema, + RowHeightSchema, + GroupingConfigSchema, + GroupingFieldSchema, + GalleryConfigSchema, + TimelineConfigSchema, + ViewSharingSchema, + RowColorConfigSchema, type View, type ListView, type FormView, @@ -1369,3 +1377,470 @@ describe('Real-World Enhanced View Examples', () => { expect(() => ViewSchema.parse(projectViews)).not.toThrow(); }); }); + +describe('ColumnSummarySchema', () => { + it('should accept all summary functions', () => { + const functions = [ + 'none', 'count', 'count_empty', 'count_filled', 'count_unique', + 'percent_empty', 'percent_filled', 'sum', 'avg', 'min', 'max', + ] as const; + + functions.forEach(fn => { + expect(() => ColumnSummarySchema.parse(fn)).not.toThrow(); + }); + }); + + it('should reject invalid summary function', () => { + expect(() => ColumnSummarySchema.parse('median')).toThrow(); + }); +}); + +describe('RowHeightSchema', () => { + it('should accept all row height options', () => { + const heights = ['compact', 'short', 'medium', 'tall', 'extra_tall'] as const; + + heights.forEach(h => { + expect(() => RowHeightSchema.parse(h)).not.toThrow(); + }); + }); + + it('should reject invalid row height', () => { + expect(() => RowHeightSchema.parse('huge')).toThrow(); + }); +}); + +describe('GroupingConfigSchema', () => { + it('should accept single field grouping', () => { + const grouping = { + fields: [{ field: 'status' }], + }; + + const result = GroupingConfigSchema.parse(grouping); + expect(result.fields[0].order).toBe('asc'); + expect(result.fields[0].collapsed).toBe(false); + }); + + it('should accept multi-level grouping', () => { + const grouping = { + fields: [ + { field: 'department', order: 'asc' as const }, + { field: 'status', order: 'desc' as const, collapsed: true }, + ], + }; + + expect(() => GroupingConfigSchema.parse(grouping)).not.toThrow(); + }); + + it('should reject empty fields array', () => { + const grouping = { fields: [] }; + + expect(() => GroupingConfigSchema.parse(grouping)).toThrow(); + }); +}); + +describe('GroupingFieldSchema', () => { + it('should accept minimal grouping field', () => { + const field = { field: 'category' }; + + const result = GroupingFieldSchema.parse(field); + expect(result.order).toBe('asc'); + expect(result.collapsed).toBe(false); + }); + + it('should accept full grouping field config', () => { + const field = { field: 'priority', order: 'desc' as const, collapsed: true }; + + expect(() => GroupingFieldSchema.parse(field)).not.toThrow(); + }); +}); + +describe('GalleryConfigSchema', () => { + it('should accept minimal gallery config', () => { + const gallery = {}; + + const result = GalleryConfigSchema.parse(gallery); + expect(result.coverFit).toBe('cover'); + expect(result.cardSize).toBe('medium'); + }); + + it('should accept full gallery config', () => { + const gallery = { + coverField: 'photo', + coverFit: 'contain' as const, + cardSize: 'large' as const, + titleField: 'name', + visibleFields: ['status', 'category', 'owner'], + }; + + expect(() => GalleryConfigSchema.parse(gallery)).not.toThrow(); + }); + + it('should accept all card sizes', () => { + const sizes = ['small', 'medium', 'large'] as const; + + sizes.forEach(size => { + expect(() => GalleryConfigSchema.parse({ cardSize: size })).not.toThrow(); + }); + }); + + it('should accept all cover fit modes', () => { + const fits = ['cover', 'contain'] as const; + + fits.forEach(fit => { + expect(() => GalleryConfigSchema.parse({ coverFit: fit })).not.toThrow(); + }); + }); +}); + +describe('TimelineConfigSchema', () => { + it('should accept minimal timeline config', () => { + const timeline = { + startDateField: 'start_date', + titleField: 'name', + }; + + const result = TimelineConfigSchema.parse(timeline); + expect(result.scale).toBe('week'); + }); + + it('should accept full timeline config', () => { + const timeline = { + startDateField: 'start_date', + endDateField: 'end_date', + titleField: 'project_name', + groupByField: 'team', + colorField: 'priority', + scale: 'month' as const, + }; + + expect(() => TimelineConfigSchema.parse(timeline)).not.toThrow(); + }); + + it('should accept all scale options', () => { + const scales = ['hour', 'day', 'week', 'month', 'quarter', 'year'] as const; + + scales.forEach(scale => { + expect(() => TimelineConfigSchema.parse({ + startDateField: 'start_date', + titleField: 'name', + scale, + })).not.toThrow(); + }); + }); + + it('should require startDateField and titleField', () => { + expect(() => TimelineConfigSchema.parse({})).toThrow(); + expect(() => TimelineConfigSchema.parse({ startDateField: 'start' })).toThrow(); + expect(() => TimelineConfigSchema.parse({ titleField: 'name' })).toThrow(); + }); +}); + +describe('ViewSharingSchema', () => { + it('should default to collaborative', () => { + const sharing = {}; + + const result = ViewSharingSchema.parse(sharing); + expect(result.type).toBe('collaborative'); + }); + + it('should accept personal view', () => { + const sharing = { + type: 'personal' as const, + lockedBy: 'user_123', + }; + + expect(() => ViewSharingSchema.parse(sharing)).not.toThrow(); + }); + + it('should accept collaborative view with lock', () => { + const sharing = { + type: 'collaborative' as const, + lockedBy: 'admin_user', + }; + + expect(() => ViewSharingSchema.parse(sharing)).not.toThrow(); + }); +}); + +describe('RowColorConfigSchema', () => { + it('should accept minimal row color config', () => { + const rowColor = { field: 'status' }; + + expect(() => RowColorConfigSchema.parse(rowColor)).not.toThrow(); + }); + + it('should accept row color config with color map', () => { + const rowColor = { + field: 'priority', + colors: { + high: '#ff0000', + medium: '#ffaa00', + low: '#00cc00', + }, + }; + + expect(() => RowColorConfigSchema.parse(rowColor)).not.toThrow(); + }); + + it('should require field', () => { + expect(() => RowColorConfigSchema.parse({})).toThrow(); + }); +}); + +describe('ListColumnSchema pinned and summary', () => { + it('should accept pinned column', () => { + const column: ListColumn = { + field: 'name', + pinned: 'left', + }; + + expect(() => ListColumnSchema.parse(column)).not.toThrow(); + }); + + it('should accept right-pinned column', () => { + const column: ListColumn = { + field: 'actions', + pinned: 'right', + }; + + expect(() => ListColumnSchema.parse(column)).not.toThrow(); + }); + + it('should accept column with summary', () => { + const column: ListColumn = { + field: 'amount', + summary: 'sum', + }; + + expect(() => ListColumnSchema.parse(column)).not.toThrow(); + }); + + it('should accept column with pinned and summary', () => { + const column: ListColumn = { + field: 'revenue', + pinned: 'left', + summary: 'avg', + align: 'right', + type: 'currency', + }; + + expect(() => ListColumnSchema.parse(column)).not.toThrow(); + }); + + it('should reject invalid pinned value', () => { + const column = { + field: 'test_field', + pinned: 'top', + }; + + expect(() => ListColumnSchema.parse(column)).toThrow(); + }); +}); + +describe('Airtable-style ListView enhancements', () => { + it('should accept list view with row height', () => { + const listView: ListView = { + columns: ['name', 'status'], + rowHeight: 'compact', + }; + + expect(() => ListViewSchema.parse(listView)).not.toThrow(); + }); + + it('should accept list view with grouping', () => { + const listView: ListView = { + columns: ['name', 'status', 'department'], + grouping: { + fields: [ + { field: 'department', order: 'asc' }, + { field: 'status', order: 'desc', collapsed: true }, + ], + }, + }; + + expect(() => ListViewSchema.parse(listView)).not.toThrow(); + }); + + it('should accept list view with row color', () => { + const listView: ListView = { + columns: ['name', 'priority'], + rowColor: { + field: 'priority', + colors: { + critical: '#ff0000', + high: '#ff8800', + medium: '#ffcc00', + low: '#00cc00', + }, + }, + }; + + expect(() => ListViewSchema.parse(listView)).not.toThrow(); + }); + + it('should accept list view with hidden fields and field order', () => { + const listView: ListView = { + columns: ['name', 'status', 'owner'], + hiddenFields: ['internal_notes', 'system_id'], + fieldOrder: ['name', 'status', 'owner', 'created_at'], + }; + + expect(() => ListViewSchema.parse(listView)).not.toThrow(); + }); + + it('should accept list view with description and sharing', () => { + const listView: ListView = { + name: 'my_pipeline', + label: 'My Pipeline', + columns: ['name', 'stage'], + description: 'Personal view for tracking deals', + sharing: { + type: 'personal', + }, + }; + + expect(() => ListViewSchema.parse(listView)).not.toThrow(); + }); + + it('should accept gallery view with gallery config', () => { + const galleryView: ListView = { + type: 'gallery', + columns: ['name', 'photo', 'category'], + gallery: { + coverField: 'photo', + coverFit: 'cover', + cardSize: 'large', + titleField: 'name', + visibleFields: ['category', 'price'], + }, + }; + + expect(() => ListViewSchema.parse(galleryView)).not.toThrow(); + }); + + it('should accept timeline view with timeline config', () => { + const timelineView: ListView = { + type: 'timeline', + columns: ['name', 'start_date', 'end_date', 'team'], + timeline: { + startDateField: 'start_date', + endDateField: 'end_date', + titleField: 'name', + groupByField: 'team', + colorField: 'status', + scale: 'month', + }, + }; + + expect(() => ListViewSchema.parse(timelineView)).not.toThrow(); + }); + + it('should accept full Airtable-style grid view', () => { + const airtableView: ListView = { + name: 'project_tracker', + label: 'Project Tracker', + description: 'Main project tracking view with all features', + type: 'grid', + columns: [ + { field: 'project_name', pinned: 'left', sortable: true, width: 250 }, + { field: 'status', width: 120, summary: 'count_unique' }, + { field: 'priority', width: 100 }, + { field: 'budget', align: 'right', type: 'currency', summary: 'sum' }, + { field: 'completion', align: 'right', type: 'percent', summary: 'avg' }, + ], + filter: [{ field: 'archived', operator: 'equals', value: false }], + sort: [{ field: 'priority', order: 'asc' }], + grouping: { + fields: [ + { field: 'department', order: 'asc' }, + ], + }, + rowHeight: 'medium', + rowColor: { + field: 'status', + colors: { + on_track: '#22c55e', + at_risk: '#f59e0b', + blocked: '#ef4444', + }, + }, + hiddenFields: ['internal_id', 'sys_updated_at'], + fieldOrder: ['project_name', 'status', 'priority', 'budget', 'completion', 'department'], + sharing: { + type: 'collaborative', + lockedBy: 'admin', + }, + resizable: true, + selection: { type: 'multiple' }, + pagination: { pageSize: 50, pageSizeOptions: [25, 50, 100] }, + inlineEdit: true, + exportOptions: ['csv', 'xlsx'], + }; + + expect(() => ListViewSchema.parse(airtableView)).not.toThrow(); + }); + + it('should accept complete Airtable-style View container with multiple view types', () => { + const views: View = { + list: { + type: 'grid', + columns: [ + { field: 'name', pinned: 'left', sortable: true }, + { field: 'status', summary: 'count' }, + { field: 'amount', summary: 'sum', align: 'right' }, + ], + rowHeight: 'short', + grouping: { + fields: [{ field: 'category' }], + }, + }, + listViews: { + kanban: { + type: 'kanban', + columns: ['name', 'amount', 'owner'], + kanban: { + groupByField: 'stage', + summarizeField: 'amount', + columns: ['name', 'owner', 'close_date'], + }, + sharing: { type: 'collaborative' }, + }, + gallery: { + type: 'gallery', + columns: ['name', 'photo', 'price'], + gallery: { + coverField: 'photo', + cardSize: 'medium', + titleField: 'name', + visibleFields: ['price', 'category'], + }, + rowHeight: 'tall', + }, + timeline: { + type: 'timeline', + columns: ['name', 'start_date', 'end_date'], + timeline: { + startDateField: 'start_date', + endDateField: 'end_date', + titleField: 'name', + scale: 'week', + }, + }, + calendar: { + type: 'calendar', + columns: ['subject', 'date'], + calendar: { + startDateField: 'date', + titleField: 'subject', + }, + }, + }, + form: { + type: 'simple', + sections: [{ fields: ['name', 'status', 'amount'] }], + }, + }; + + expect(() => ViewSchema.parse(views)).not.toThrow(); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index fd39f883e6..f91de71d4d 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -43,6 +43,24 @@ export const ViewDataSchema = z.discriminatedUnion('provider', [ }), ]); +/** + * Column Summary Function Schema + * Aggregation function for column footer (Airtable-style column summaries) + */ +export const ColumnSummarySchema = z.enum([ + 'none', + 'count', + 'count_empty', + 'count_filled', + 'count_unique', + 'percent_empty', + 'percent_filled', + 'sum', + 'avg', + 'min', + 'max', +]).describe('Aggregation function for column footer summary'); + /** * List Column Configuration Schema * Detailed configuration for individual list view columns @@ -58,6 +76,12 @@ export const ListColumnSchema = z.object({ wrap: z.boolean().optional().describe('Allow text wrapping'), type: z.string().optional().describe('Renderer type override (e.g., "currency", "date")'), + /** Pinning (Airtable-style frozen columns) */ + pinned: z.enum(['left', 'right']).optional().describe('Pin/freeze column to left or right side'), + + /** Column Footer Summary (Airtable-style aggregation) */ + summary: ColumnSummarySchema.optional().describe('Footer aggregation function for this column'), + /** Interaction */ link: z.boolean().optional().describe('Functions as the primary navigation link (triggers View navigation)'), action: z.string().optional().describe('Registered Action ID to execute when clicked'), @@ -78,6 +102,79 @@ export const PaginationConfigSchema = z.object({ pageSizeOptions: z.array(z.number().int().positive()).optional().describe('Available page size options'), }); +/** + * Row Height / Density Schema (Airtable-style) + * Controls the visual density of rows in a list view. + */ +export const RowHeightSchema = z.enum([ + 'compact', // Minimal padding, single line + 'short', // Reduced padding + 'medium', // Default padding + 'tall', // Extra padding, multi-line preview + 'extra_tall', // Maximum padding, rich content preview +]).describe('Row height / density setting for list view'); + +/** + * Grouping Field Configuration + * Defines a single grouping level for record grouping. + */ +export const GroupingFieldSchema = z.object({ + field: z.string().describe('Field name to group by'), + order: z.enum(['asc', 'desc']).default('asc').describe('Group sort order'), + collapsed: z.boolean().default(false).describe('Collapse groups by default'), +}); + +/** + * Grouping Configuration Schema (Airtable-style) + * Supports multi-level grouping for grid/gallery views. + */ +export const GroupingConfigSchema = z.object({ + fields: z.array(GroupingFieldSchema).min(1).describe('Fields to group by (supports up to 3 levels)'), +}).describe('Record grouping configuration'); + +/** + * Gallery View Configuration (Airtable-style) + * Configures card layout for gallery/card views. + */ +export const GalleryConfigSchema = z.object({ + coverField: z.string().optional().describe('Attachment/image field to display as card cover'), + coverFit: z.enum(['cover', 'contain']).default('cover').describe('Image fit mode for card cover'), + cardSize: z.enum(['small', 'medium', 'large']).default('medium').describe('Card size in gallery view'), + titleField: z.string().optional().describe('Field to display as card title'), + visibleFields: z.array(z.string()).optional().describe('Fields to display on card body'), +}).describe('Gallery/card view configuration'); + +/** + * Timeline View Configuration (Airtable-style) + * Configures timeline/chronological views. + */ +export const TimelineConfigSchema = z.object({ + startDateField: z.string().describe('Field for timeline item start date'), + endDateField: z.string().optional().describe('Field for timeline item end date'), + titleField: z.string().describe('Field to display as timeline item title'), + groupByField: z.string().optional().describe('Field to group timeline rows'), + colorField: z.string().optional().describe('Field to determine item color'), + scale: z.enum(['hour', 'day', 'week', 'month', 'quarter', 'year']).default('week').describe('Default timeline scale'), +}).describe('Timeline view configuration'); + +/** + * View Sharing Configuration (Airtable-style) + * Defines who can see and modify a view. + */ +export const ViewSharingSchema = z.object({ + type: z.enum(['personal', 'collaborative']).default('collaborative').describe('View ownership type'), + lockedBy: z.string().optional().describe('User who locked the view configuration'), +}).describe('View sharing and access configuration'); + +/** + * Row Color Configuration (Airtable-style) + * Defines how rows are colored based on field values. + */ +export const RowColorConfigSchema = z.object({ + field: z.string().describe('Field to derive color from (typically a select/status field)'), + colors: z.record(z.string(), z.string()).optional().describe('Map of field value to color (hex/token)'), +}).describe('Row color configuration based on field values'); + /** * Kanban Settings */ @@ -218,6 +315,25 @@ export const ListViewSchema = z.object({ kanban: KanbanConfigSchema.optional(), calendar: CalendarConfigSchema.optional(), gantt: GanttConfigSchema.optional(), + gallery: GalleryConfigSchema.optional(), + timeline: TimelineConfigSchema.optional(), + + /** View Metadata (Airtable-style view management) */ + description: I18nLabelSchema.optional().describe('View description for documentation/tooltips'), + sharing: ViewSharingSchema.optional().describe('View sharing and access configuration'), + + /** Row Height / Density (Airtable-style) */ + rowHeight: RowHeightSchema.optional().describe('Row height / density setting'), + + /** Record Grouping (Airtable-style) */ + grouping: GroupingConfigSchema.optional().describe('Group records by one or more fields'), + + /** Row Color (Airtable-style) */ + rowColor: RowColorConfigSchema.optional().describe('Color rows based on field value'), + + /** Field Visibility & Ordering per View (Airtable-style) */ + hiddenFields: z.array(z.string()).optional().describe('Fields to hide in this specific view'), + fieldOrder: z.array(z.string()).optional().describe('Explicit field display order for this view'), /** Row & Bulk Actions */ rowActions: z.array(z.string()).optional().describe('Actions available for individual row items'), @@ -358,3 +474,10 @@ export type PaginationConfig = z.infer; export type ViewData = z.infer; export type HttpRequest = z.infer; export type HttpMethod = z.infer; +export type ColumnSummary = z.infer; +export type RowHeight = z.infer; +export type GroupingConfig = z.infer; +export type GalleryConfig = z.infer; +export type TimelineConfig = z.infer; +export type ViewSharing = z.infer; +export type RowColorConfig = z.infer;