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
7 changes: 7 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
30 changes: 27 additions & 3 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
GripVertical,
Save,
X,
Plus,
} from 'lucide-react';

type SortDirection = 'asc' | 'desc' | null;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -265,12 +267,12 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {

const getSortIcon = (columnKey: string) => {
if (sortColumn !== columnKey) {
return <ChevronsUpDown className="h-4 w-4 ml-1 opacity-50" />;
return <ChevronsUpDown className="h-3 w-3 ml-0.5 opacity-0 group-hover:opacity-50 transition-opacity" />;
}
if (sortDirection === 'asc') {
return <ChevronUp className="h-4 w-4 ml-1" />;
return <ChevronUp className="h-3 w-3 ml-0.5 text-primary" />;
}
return <ChevronDown className="h-4 w-4 ml-1" />;
return <ChevronDown className="h-3 w-3 ml-0.5 text-primary" />;
};

// Column resizing handlers
Expand Down Expand Up @@ -664,6 +666,9 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
{reorderableColumns && (
<GripVertical className="h-4 w-4 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing flex-shrink-0" />
)}
{col.headerIcon && (
<span className="text-muted-foreground flex-shrink-0">{col.headerIcon}</span>
)}
<span>{col.header}</span>
{sortable && col.sortable !== false && getSortIcon(col.accessorKey)}
</div>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -845,6 +851,24 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
</TableRow>
);
})}
{/* Add record row (Airtable-style) */}
{showAddRow && (
<TableRow
className="hover:bg-muted/30 cursor-pointer border-b border-border/50"
data-testid="add-record-row"
onClick={() => schema.onAddRecord?.()}
>
<TableCell
colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0)}
className="h-9 px-3 py-1.5"
>
<span className="flex items-center gap-1.5 text-muted-foreground text-sm hover:text-foreground transition-colors">
<Plus className="h-3.5 w-3.5" />
Add record
</span>
</TableCell>
</TableRow>
)}
{/* Filler rows to maintain height consistency */}
{paginatedData.length > 0 && Array.from({ length: Math.max(0, pageSize - paginatedData.length) }).map((_, i) => (
<TableRow key={`empty-${i}`} className="hover:bg-transparent">
Expand Down
46 changes: 46 additions & 0 deletions packages/fields/src/__tests__/datetime-cell.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<DateTimeCellRenderer
value="2026-02-18T12:57:00.000Z"
field={{ name: 'created_at', type: 'datetime' } as any}
/>
);
// 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(
<DateTimeCellRenderer
value={null}
field={{ name: 'created_at', type: 'datetime' } as any}
/>
);
expect(container.textContent).toBe('-');
});

it('should show dash for invalid date', () => {
const { container } = render(
<DateTimeCellRenderer
value="not-a-date"
field={{ name: 'created_at', type: 'datetime' } as any}
/>
);
expect(container.textContent).toBe('-');
});
});
26 changes: 22 additions & 4 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span className="tabular-nums text-sm">{formatted}</span>;
if (!value) return <span className="text-muted-foreground">-</span>;
const date = typeof value === 'string' ? new Date(value) : value;
if (isNaN(date.getTime())) return <span className="text-muted-foreground">-</span>;

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 (
<span className="tabular-nums text-sm whitespace-nowrap">
<span>{datePart}</span>
<span className="ml-2 text-muted-foreground">{timePart}</span>
</span>
);
}

/**
Expand Down
51 changes: 50 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -47,6 +47,7 @@ export interface ObjectGridProps {
onRowSave?: (rowIndex: number, changes: Record<string, any>, row: any) => void | Promise<void>;
onBatchSave?: (changes: Array<{ rowIndex: number; changes: Record<string, any>; row: any }>) => void | Promise<void>;
onRowSelect?: (selectedRows: any[]) => void;
onAddRecord?: () => void;
}

/**
Expand Down Expand Up @@ -115,6 +116,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
onCellChange,
onRowSave,
onBatchSave,
onAddRecord,
...rest
}) => {
const [data, setData] = useState<any[]>([]);
Expand Down Expand Up @@ -342,6 +344,23 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
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 <Type className="h-3.5 w-3.5" />;
const iconMap: Record<string, React.ReactNode> = {
text: <Type className="h-3.5 w-3.5" />,
number: <Hash className="h-3.5 w-3.5" />,
currency: <Hash className="h-3.5 w-3.5" />,
percent: <Hash className="h-3.5 w-3.5" />,
date: <Calendar className="h-3.5 w-3.5" />,
datetime: <Clock className="h-3.5 w-3.5" />,
boolean: <CheckSquare className="h-3.5 w-3.5" />,
user: <User className="h-3.5 w-3.5" />,
select: <Tag className="h-3.5 w-3.5" />,
};
return iconMap[fieldType] || <Type className="h-3.5 w-3.5" />;
};

// 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
Expand All @@ -354,6 +373,12 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
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))) {
Expand Down Expand Up @@ -489,6 +514,27 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
);
}

// 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
? <PrefixRenderer value={prefixValue} field={{ name: prefixConfig.field, type: 'select' } as any} />
: <span className="text-muted-foreground text-xs mr-1.5">{String(prefixValue)}</span>
: null;
return (
<span className="flex items-center gap-1.5">
{prefixEl}
{baseCellRenderer(value, row)}
</span>
);
};
}

// Auto-infer alignment from field type if not explicitly set
const numericTypes = ['number', 'currency', 'percent'];
const effectiveType = inferredType || col.type;
Expand All @@ -500,6 +546,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
return {
header,
accessorKey: col.field,
headerIcon: getTypeIcon(inferredType),
...(!isEssential && { className: 'hidden sm:table-cell' }),
...(col.width && { width: col.width }),
...(inferredAlign && { align: inferredAlign }),
Expand Down Expand Up @@ -743,6 +790,8 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
? '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,
Expand Down
Loading