diff --git a/ROADMAP.md b/ROADMAP.md index 28bd760f0b..c0115fa6e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -362,6 +362,13 @@ Each plugin view must work seamlessly from 320px (small phone) to 2560px (ultraw - [x] Mobile card view Stage colored badge (green/red/yellow/blue by pipeline stage) - [x] Mobile card view skeleton loading placeholders during async data fetch - [x] CRM example Opportunity stage field with color options +- [x] Airtable-style record count status bar (`{n} records`) in ListView +- [x] Airtable-style "+ Add record" row (showAddRow / onAddRecord) in data-table +- [x] Airtable-style compound cells with prefix badge configuration (ListColumn.prefix) +- [x] Airtable-style datetime split display (date + muted time for created_at/updated_at fields) +- [x] Airtable-style row refinement (pure white bg, border-border/50, hover:bg-muted/30) +- [x] Airtable-style inline sort arrows (smaller h-3 icons, hidden until hover, colored when active) +- [x] Airtable-style column header type icons (Type/Hash/Calendar/Clock/CheckSquare/User/Tag) ##### ObjectKanban (`plugin-kanban`) - [x] Stack columns vertically on mobile with horizontal swipe navigation between columns diff --git a/packages/components/src/renderers/complex/__tests__/data-table.test.ts b/packages/components/src/renderers/complex/__tests__/data-table.test.ts index 2b91a91aa7..5cf86a0a0e 100644 --- a/packages/components/src/renderers/complex/__tests__/data-table.test.ts +++ b/packages/components/src/renderers/complex/__tests__/data-table.test.ts @@ -57,4 +57,20 @@ describe('Data Table Component', () => { expect(config?.defaultProps?.exportable).toBe(true); expect(config?.defaultProps?.rowActions).toBe(true); }); + + it('should have showAddRow and onAddRecord properties in schema', () => { + const config = ComponentRegistry.getConfig('data-table'); + expect(config).toBeDefined(); + // Verify the DataTableSchema type supports add-record properties + // by checking that the component accepts these props without error + const testSchema: import('@object-ui/types').DataTableSchema = { + type: 'data-table', + columns: [], + data: [], + showAddRow: true, + onAddRecord: () => {}, + }; + expect(testSchema.showAddRow).toBe(true); + expect(typeof testSchema.onAddRecord).toBe('function'); + }); }); diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 4cb04a0400..ed16ac04e6 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -46,6 +46,7 @@ import { GripVertical, Save, X, + Plus, } from 'lucide-react'; type SortDirection = 'asc' | 'desc' | null; @@ -104,6 +105,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { className, frozenColumns = 0, showRowNumbers = false, + showAddRow = false, } = schema; // Normalize columns to support legacy keys (label/name) from existing JSONs @@ -265,12 +267,12 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { const getSortIcon = (columnKey: string) => { if (sortColumn !== columnKey) { - return ; + return ; } if (sortDirection === 'asc') { - return ; + return ; } - return ; + return ; }; // Column resizing handlers @@ -664,6 +666,9 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { {reorderableColumns && ( )} + {col.headerIcon && ( + {col.headerIcon} + )} {col.header} {sortable && col.sortable !== false && getSortIcon(col.accessorKey)} @@ -711,6 +716,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { key={rowId} data-state={isSelected ? 'selected' : undefined} className={cn( + "bg-background border-b border-border/50 hover:bg-muted/30", schema.onRowClick && "cursor-pointer", rowHasChanges && "bg-amber-50 dark:bg-amber-950/20", rowClassName && rowClassName(row, rowIndex) @@ -845,6 +851,24 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { ); })} + {/* Add record row (Airtable-style) */} + {showAddRow && ( + schema.onAddRecord?.()} + > + + + + Add record + + + + )} {/* Filler rows to maintain height consistency */} {paginatedData.length > 0 && Array.from({ length: Math.max(0, pageSize - paginatedData.length) }).map((_, i) => ( diff --git a/packages/fields/src/__tests__/datetime-cell.test.tsx b/packages/fields/src/__tests__/datetime-cell.test.tsx new file mode 100644 index 0000000000..7fbc52c054 --- /dev/null +++ b/packages/fields/src/__tests__/datetime-cell.test.tsx @@ -0,0 +1,46 @@ +/** + * DateTimeCellRenderer Tests + * + * Tests for the Airtable-style split date/time cell renderer. + */ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { DateTimeCellRenderer } from '../index'; + +describe('DateTimeCellRenderer', () => { + it('should render date and time separately', () => { + render( + + ); + // Date part should be visible + expect(screen.getByText('2/18/2026')).toBeInTheDocument(); + // Time part should be in a muted span + const container = screen.getByText('2/18/2026').closest('span'); + expect(container).toBeInTheDocument(); + }); + + it('should show dash for null value', () => { + const { container } = render( + + ); + expect(container.textContent).toBe('-'); + }); + + it('should show dash for invalid date', () => { + const { container } = render( + + ); + expect(container.textContent).toBe('-'); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 5d4a2066b6..ba73706f13 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -207,12 +207,30 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea } /** - * DateTime field cell renderer + * DateTime field cell renderer (Airtable-style with date and time visually separated) */ export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement { - const formatted = formatDateTime(value); - - return {formatted}; + if (!value) return -; + const date = typeof value === 'string' ? new Date(value) : value; + if (isNaN(date.getTime())) return -; + + const datePart = date.toLocaleDateString('en-US', { + month: 'numeric', + day: 'numeric', + year: 'numeric', + }); + const timePart = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }).toLowerCase(); + + return ( + + {datePart} + {timePart} + + ); } /** diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 5f974e3a59..40efca0565 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -31,7 +31,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@object-ui/components'; import { usePullToRefresh } from '@object-ui/mobile'; -import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown, Download, Rows3, Rows4, AlignJustify } from 'lucide-react'; +import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown, Download, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock } from 'lucide-react'; import { useRowColor } from './useRowColor'; import { useGroupedData } from './useGroupedData'; @@ -47,6 +47,7 @@ export interface ObjectGridProps { onRowSave?: (rowIndex: number, changes: Record, row: any) => void | Promise; onBatchSave?: (changes: Array<{ rowIndex: number; changes: Record; row: any }>) => void | Promise; onRowSelect?: (selectedRows: any[]) => void; + onAddRecord?: () => void; } /** @@ -115,6 +116,7 @@ export const ObjectGrid: React.FC = ({ onCellChange, onRowSave, onBatchSave, + onAddRecord, ...rest }) => { const [data, setData] = useState([]); @@ -342,6 +344,23 @@ export const ObjectGrid: React.FC = ({ const { groups, isGrouped, toggleGroup } = useGroupedData(schema.grouping, data); const generateColumns = useCallback(() => { + // Map field type to column header icon (Airtable-style) + const getTypeIcon = (fieldType: string | null): React.ReactNode => { + if (!fieldType) return ; + const iconMap: Record = { + text: , + number: , + currency: , + percent: , + date: , + datetime: , + boolean: , + user: , + select: , + }; + return iconMap[fieldType] || ; + }; + // Auto-infer column type from field name and data values (Airtable-style) const inferColumnType = (col: ListColumn): string | null => { if (col.type) return col.type; // Explicit type takes priority @@ -354,6 +373,12 @@ export const ObjectGrid: React.FC = ({ return 'boolean'; } + // Infer datetime fields (fields with time component: created_time, modified_time, *_at patterns) + const datetimePatterns = ['created_time', 'modified_time', 'updated_time', 'created_at', 'updated_at', 'modified_at', 'last_login', 'logged_at']; + if (datetimePatterns.some(p => fieldLower === p || fieldLower.endsWith(`_${p}`))) { + return 'datetime'; + } + // Infer date fields from name patterns const datePatterns = ['date', 'due', 'created', 'updated', 'deadline', 'start', 'end', 'expires']; if (datePatterns.some(p => fieldLower.includes(p))) { @@ -489,6 +514,27 @@ export const ObjectGrid: React.FC = ({ ); } + // Wrap with prefix compound cell renderer (Airtable-style: [Badge] Text in same cell) + const prefixConfig = (col as any).prefix; + if (prefixConfig?.field) { + const baseCellRenderer = cellRenderer; + const PrefixRenderer = prefixConfig.type === 'badge' ? getCellRenderer('select') : null; + cellRenderer = (value: any, row: any) => { + const prefixValue = row[prefixConfig.field]; + const prefixEl = prefixValue != null && prefixValue !== '' + ? PrefixRenderer + ? + : {String(prefixValue)} + : null; + return ( + + {prefixEl} + {baseCellRenderer(value, row)} + + ); + }; + } + // Auto-infer alignment from field type if not explicitly set const numericTypes = ['number', 'currency', 'percent']; const effectiveType = inferredType || col.type; @@ -500,6 +546,7 @@ export const ObjectGrid: React.FC = ({ return { header, accessorKey: col.field, + headerIcon: getTypeIcon(inferredType), ...(!isEssential && { className: 'hidden sm:table-cell' }), ...(col.width && { width: col.width }), ...(inferredAlign && { align: inferredAlign }), @@ -743,6 +790,8 @@ export const ObjectGrid: React.FC = ({ ? 'px-3 py-2.5 text-sm' : 'px-3 py-1.5 text-[13px] leading-normal', showRowNumbers: true, + showAddRow: !!operations?.create, + onAddRecord: onAddRecord, rowClassName: schema.rowColor ? (row: any, _idx: number) => getRowClassName(row) : undefined, frozenColumns: schema.frozenColumns ?? 1, onSelectionChange: onRowSelect, diff --git a/packages/plugin-grid/src/__tests__/airtable-style.test.tsx b/packages/plugin-grid/src/__tests__/airtable-style.test.tsx index 80020c6a22..8a0e6a126e 100644 --- a/packages/plugin-grid/src/__tests__/airtable-style.test.tsx +++ b/packages/plugin-grid/src/__tests__/airtable-style.test.tsx @@ -88,7 +88,7 @@ describe('Auto-type inference: Date fields', () => { expect(screen.getByText('Feb 20, 2026')).toBeInTheDocument(); }); - it('should format created_at fields as dates', async () => { + it('should format created_at fields as datetime', async () => { renderGrid([ { field: 'subject', label: 'Subject' }, { field: 'created_at', label: 'Created At' }, @@ -98,9 +98,10 @@ describe('Auto-type inference: Date fields', () => { expect(screen.getByText('Subject')).toBeInTheDocument(); }); - // Should show human-readable date, not ISO string + // Should show human-readable datetime, not ISO string expect(screen.queryByText('2026-01-15T10:00:00.000Z')).not.toBeInTheDocument(); - expect(screen.getByText('Jan 15, 2026')).toBeInTheDocument(); + // created_at now renders as datetime with split date/time display + expect(screen.getByText('1/15/2026')).toBeInTheDocument(); }); it('should not override explicit type for date-like fields', async () => { @@ -249,3 +250,93 @@ describe('Frozen first column', () => { expect(subjectHeader).not.toHaveClass('sticky'); }); }); + +// ========================================================================= +// 7. Column header type icons +// ========================================================================= +describe('Column header type icons', () => { + it('should render type icons in column headers', async () => { + renderGrid([ + { field: 'subject', label: 'Subject' }, + { field: 'status', label: 'Status' }, + { field: 'due_date', label: 'Due Date' }, + ]); + await waitFor(() => { + expect(screen.getByText('Subject')).toBeInTheDocument(); + }); + // Each column header should have an SVG icon + const headers = screen.getAllByRole('columnheader'); + // Filter out utility headers (row numbers, checkboxes, actions) + const dataHeaders = headers.filter(h => h.querySelector('svg')); + expect(dataHeaders.length).toBeGreaterThanOrEqual(3); + }); +}); + +// ========================================================================= +// 8. Datetime type inference +// ========================================================================= +describe('Datetime type inference', () => { + it('should infer datetime type for created_at fields', async () => { + renderGrid([ + { field: 'subject', label: 'Subject' }, + { field: 'created_at', label: 'Created At' }, + ]); + await waitFor(() => { + expect(screen.getByText('Subject')).toBeInTheDocument(); + }); + // Should show split datetime format (date and time separately) + // The time part should be rendered in a separate muted span + const dateEl = screen.getByText('1/15/2026'); + expect(dateEl).toBeInTheDocument(); + }); +}); + +// ========================================================================= +// 9. Compound cell with prefix badge +// ========================================================================= +describe('Compound cell with prefix badge', () => { + it('should render prefix badge before the main value', async () => { + renderGrid([ + { field: 'subject', label: 'Subject', prefix: { field: 'category', type: 'badge' } }, + { field: 'status', label: 'Status' }, + ]); + await waitFor(() => { + expect(screen.getByText('Subject')).toBeInTheDocument(); + }); + // The category values should appear as badges alongside subject + const engineeringBadges = screen.getAllByText('Engineering'); + expect(engineeringBadges.length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Task Alpha')).toBeInTheDocument(); + }); +}); + +// ========================================================================= +// 10. Add record row +// ========================================================================= +describe('Add record row', () => { + it('should show add record row when operations.create is true', async () => { + renderGrid( + [ + { field: 'subject', label: 'Subject' }, + { field: 'status', label: 'Status' }, + ], + { operations: { create: true } } + ); + await waitFor(() => { + expect(screen.getByText('Subject')).toBeInTheDocument(); + }); + expect(screen.getByTestId('add-record-row')).toBeInTheDocument(); + expect(screen.getByText('Add record')).toBeInTheDocument(); + }); + + it('should not show add record row by default', async () => { + renderGrid([ + { field: 'subject', label: 'Subject' }, + { field: 'status', label: 'Status' }, + ]); + await waitFor(() => { + expect(screen.getByText('Subject')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('add-record-row')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index df6214342f..fdbd918f6a 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -948,6 +948,16 @@ export const ListView: React.FC = ({ )} + {/* Record count status bar (Airtable-style) */} + {!loading && data.length > 0 && ( +
+ {data.length} {data.length === 1 ? 'record' : 'records'} +
+ )} + {/* Navigation Overlay (drawer/modal/popover) */} {navigation.isOverlay && ( { renderWithProvider(); expect(screen.queryByTestId('share-button')).not.toBeInTheDocument(); }); + + it('should show record count bar when data is loaded', async () => { + const mockItems = [ + { _id: '1', name: 'Alice', email: 'alice@test.com' }, + { _id: '2', name: 'Bob', email: 'bob@test.com' }, + { _id: '3', name: 'Charlie', email: 'charlie@test.com' }, + ]; + mockDataSource.find.mockResolvedValue(mockItems); + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'email'], + }; + + renderWithProvider(); + + await vi.waitFor(() => { + expect(screen.getByTestId('record-count-bar')).toBeInTheDocument(); + }); + expect(screen.getByText('3 records')).toBeInTheDocument(); + }); + + it('should not show record count bar when no data', async () => { + mockDataSource.find.mockResolvedValue([]); + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'email'], + }; + + renderWithProvider(); + + await vi.waitFor(() => { + expect(screen.getByTestId('empty-state')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('record-count-bar')).not.toBeInTheDocument(); + }); }); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 63fdd74193..1e5c331509 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -424,6 +424,15 @@ export interface DataTableSchema extends BaseSchema { * @default false */ showRowNumbers?: boolean; + /** + * Show "+ Add record" row at the bottom of the table (Airtable-style) + * @default false + */ + showAddRow?: boolean; + /** + * Callback when the "+ Add record" row is clicked + */ + onAddRecord?: () => void; /** * Column resize handler * Called when a column is resized diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index a2bd63c669..835b1cae2d 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -73,6 +73,10 @@ export const ListColumnSchema = z.object({ type: z.string().optional().describe('Renderer type override'), 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'), + prefix: z.object({ + field: z.string().describe('Field name to render as prefix'), + type: z.enum(['badge', 'text']).optional().describe('Renderer type for the prefix'), + }).optional().describe('Prefix configuration for compound cell rendering (Airtable-style)'), }); /**