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
44 changes: 43 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -798,7 +798,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
**Global Theme & Design Tokens:**
- [x] Hardcoded gray colors in `GridField.tsx`, `ReportRenderer.tsx`, and `ObjectGrid.tsx` replaced with theme tokens (`text-muted-foreground`, `bg-muted`, `border-border`, `border-foreground`)
- [x] Global font-family (`Inter`, ui-sans-serif, system-ui) injected in `index.css` `:root`
- [x] `--config-panel-width: 280px` CSS custom property added for unified config panel sizing
- [x] `--config-panel-width` CSS custom property added for unified config panel sizing (updated to `320px` in P2.8)
- [x] Border radius standardized to `rounded-lg` across report/grid components
- [x] `transition-colors duration-150` added to all interactive elements (toolbar buttons, tab bar, sidebar menu buttons)
- [x] `LayoutRenderer.tsx` outer shell `bg-slate-50/50 dark:bg-zinc-950` replaced with `bg-background` theme token
Expand Down Expand Up @@ -833,6 +833,48 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
- [x] Active tab indicator changed to bottom border (`border-b-2 border-primary font-medium text-foreground`)
- [x] `transition-colors duration-150` added to tab buttons

### P2.8 Airtable Parity: Product View & Global UI Detail Optimization

> Platform-level grid, toolbar, sidebar, and config panel optimizations for Airtable-level experience (Issue #768).
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

The issue number reference is incorrect. This PR fixes issue #847 (as stated in the PR description), but the ROADMAP references issue #768. Update the reference to #847 for consistency.

Suggested change
> Platform-level grid, toolbar, sidebar, and config panel optimizations for Airtable-level experience (Issue #768).
> Platform-level grid, toolbar, sidebar, and config panel optimizations for Airtable-level experience (Issue #847).

Copilot uses AI. Check for mistakes.

**Platform: DataTable & ObjectGrid Enhancements:**
- [x] `rowStyle` callback prop added to `DataTableSchema` type — enables inline CSSProperties per row
- [x] `<TableRow>` in data-table.tsx applies `rowStyle` callback for runtime row styling
- [x] ObjectGrid: `conditionalFormatting` rules wired to `rowStyle` — evaluates both spec-format (`condition`/`style`) and ObjectUI-format (`field`/`operator`/`value`) rules per row using `evaluatePlainCondition` from `@object-ui/core`
- [x] Row number (#) column: hover shows `<Checkbox>` for multi-select (when `selectable` mode is enabled), replacing expand icon

**Platform: ListView Toolbar:**
- [x] Visual `<div>` separators (`h-4 w-px bg-border/60`) between toolbar button groups: Search | Hide Fields | Filter/Sort/Group | Color/Density | Export
- [x] Separators conditionally rendered only when adjacent groups are visible
- [x] Inline search moved to toolbar left end (`w-48`, Airtable-style)
- [x] Density button: activated state highlight (`bg-primary/10 border border-primary/20`) when density is non-default

**Platform: ViewTabBar:**
- [x] Tab "•" dot indicator replaced with descriptive badge (`F`/`S`/`FS`) + tooltip showing "Active filters", "Active sort"

**Platform: Console Sidebar:**
- [x] Recent items section: default collapsed with chevron toggle (saves sidebar space)

**Platform: ViewConfigPanel Advanced Sections:**
- [x] `userActions`, `sharing`, and `accessibility` sections set to `defaultCollapsed: true` — common settings remain expanded, advanced settings folded by default

**Platform: Config Panel Width:**
- [x] `--config-panel-width` CSS variable increased from `280px` to `320px` for wider config panel

**CRM Example: Product Grid Column Configs:**
- [x] All columns upgraded from `string[]` to `ListColumn[]` with explicit `field`, `label`, `width`, `type`, `align`
- [x] `IS ACTIVE`: `type: 'boolean'` for `<Checkbox disabled>` rendering
- [x] Price: `type: 'currency'`, `align: 'right'` for `formatCurrency` formatting
- [x] Default `rowHeight: 'short'` for compact density
- [x] Conditional formatting: stock=0 red, stock<5 yellow warning
- [x] Column widths: NAME 250, SKU 120, CATEGORY 110, PRICE 120, STOCK 80

**Tests:**
- [x] 7 new CRM metadata tests validating column types, widths, rowHeight, conditionalFormatting
- [x] 136 ViewConfigPanel tests updated for defaultCollapsed sections (expand before access)
- [x] 411 ListView + ViewTabBar tests passing
- [x] 11 AppSidebar tests passing

### P2.5 PWA & Offline (Real Sync)

- [ ] Background sync queue → real server sync (replace simulation)
Expand Down
23 changes: 23 additions & 0 deletions apps/console/src/__tests__/ViewConfigPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,8 @@ describe('ViewConfigPanel', () => {
/>
);

// Expand defaultCollapsed userActions section
fireEvent.click(screen.getByTestId('section-userActions'));
expect(screen.getByTestId('toggle-inlineEdit')).toBeInTheDocument();
expect(screen.getByTestId('toggle-addDeleteRecordsInline')).toBeInTheDocument();
expect(screen.getByTestId('select-navigation-mode')).toBeInTheDocument();
Expand All @@ -1327,6 +1329,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-userActions'));
fireEvent.click(screen.getByTestId('toggle-inlineEdit'));
expect(onViewUpdate).toHaveBeenCalledWith('inlineEdit', false);
});
Expand Down Expand Up @@ -1630,6 +1633,8 @@ describe('ViewConfigPanel', () => {
/>
);

// Expand defaultCollapsed userActions section
fireEvent.click(screen.getByTestId('section-userActions'));
// List-level inline actions should be in the User Actions collapsible section
expect(screen.getByTestId('toggle-inlineEdit')).toBeInTheDocument();
expect(screen.getByTestId('toggle-addDeleteRecordsInline')).toBeInTheDocument();
Expand Down Expand Up @@ -2105,6 +2110,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-sharing'));
expect(screen.getByTestId('toggle-sharing-enabled')).toBeInTheDocument();
});

Expand All @@ -2118,6 +2124,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-sharing'));
expect(screen.getByTestId('select-sharing-visibility')).toBeInTheDocument();
expect(screen.getByTestId('select-sharing-visibility')).toHaveValue('team');
});
Expand All @@ -2134,6 +2141,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-sharing'));
fireEvent.click(screen.getByTestId('toggle-sharing-enabled'));
expect(onViewUpdate).toHaveBeenCalledWith('sharing', expect.objectContaining({ enabled: true }));
});
Expand All @@ -2148,6 +2156,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-accessibility'));
expect(screen.getByTestId('input-aria-label')).toBeInTheDocument();
expect(screen.getByTestId('input-aria-describedBy')).toBeInTheDocument();
expect(screen.getByTestId('select-aria-live')).toBeInTheDocument();
Expand Down Expand Up @@ -2178,6 +2187,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-userActions'));
fireEvent.click(screen.getByText('console.objectView.rowActions'));
expect(screen.getByTestId('row-actions-selector')).toBeInTheDocument();
});
Expand All @@ -2192,6 +2202,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-userActions'));
fireEvent.click(screen.getByText('console.objectView.bulkActions'));
expect(screen.getByTestId('bulk-actions-selector')).toBeInTheDocument();
});
Expand Down Expand Up @@ -2285,6 +2296,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-accessibility'));
fireEvent.change(screen.getByTestId('input-aria-label'), { target: { value: 'Contacts table' } });
expect(onViewUpdate).toHaveBeenCalledWith('aria', expect.objectContaining({ label: 'Contacts table' }));
});
Expand All @@ -2301,6 +2313,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-accessibility'));
fireEvent.change(screen.getByTestId('input-aria-describedBy'), { target: { value: 'table-desc' } });
expect(onViewUpdate).toHaveBeenCalledWith('aria', expect.objectContaining({ describedBy: 'table-desc' }));
});
Expand All @@ -2317,6 +2330,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-accessibility'));
fireEvent.change(screen.getByTestId('select-aria-live'), { target: { value: 'polite' } });
expect(onViewUpdate).toHaveBeenCalledWith('aria', expect.objectContaining({ live: 'polite' }));
});
Expand All @@ -2333,6 +2347,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-userActions'));
fireEvent.click(screen.getByText('console.objectView.rowActions'));
fireEvent.change(screen.getByTestId('input-rowActions'), { target: { value: 'edit, delete' } });
expect(onViewUpdate).toHaveBeenCalledWith('rowActions', ['edit', 'delete']);
Expand All @@ -2350,6 +2365,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-userActions'));
fireEvent.click(screen.getByText('console.objectView.bulkActions'));
fireEvent.change(screen.getByTestId('input-bulkActions'), { target: { value: 'delete, export' } });
expect(onViewUpdate).toHaveBeenCalledWith('bulkActions', ['delete', 'export']);
Expand Down Expand Up @@ -2433,6 +2449,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-userActions'));
fireEvent.click(screen.getByTestId('toggle-addDeleteRecordsInline'));
expect(onViewUpdate).toHaveBeenCalledWith('addDeleteRecordsInline', false);
});
Expand All @@ -2449,6 +2466,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-sharing'));
expect(screen.getByTestId('toggle-sharing-enabled')).toBeInTheDocument();
expect(screen.queryByTestId('select-sharing-visibility')).not.toBeInTheDocument();
});
Expand All @@ -2463,6 +2481,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-sharing'));
expect(screen.queryByTestId('select-sharing-visibility')).not.toBeInTheDocument();
});

Expand Down Expand Up @@ -2530,6 +2549,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-userActions'));
fireEvent.click(screen.getByText('console.objectView.bulkActions'));
fireEvent.change(screen.getByTestId('input-bulkActions'), { target: { value: '' } });
expect(onViewUpdate).toHaveBeenCalledWith('bulkActions', []);
Expand All @@ -2547,6 +2567,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-userActions'));
fireEvent.click(screen.getByText('console.objectView.rowActions'));
fireEvent.change(screen.getByTestId('input-rowActions'), { target: { value: '' } });
expect(onViewUpdate).toHaveBeenCalledWith('rowActions', []);
Expand Down Expand Up @@ -2661,6 +2682,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-sharing'));
fireEvent.change(screen.getByTestId('select-sharing-visibility'), { target: { value: 'organization' } });
expect(onViewUpdate).toHaveBeenCalledWith('sharing', expect.objectContaining({
enabled: true,
Expand All @@ -2682,6 +2704,7 @@ describe('ViewConfigPanel', () => {
/>
);

fireEvent.click(screen.getByTestId('section-accessibility'));
const select = screen.getByTestId('select-aria-live');
['polite', 'assertive', 'off'].forEach((mode) => {
fireEvent.change(select, { target: { value: mode } });
Expand Down
14 changes: 12 additions & 2 deletions apps/console/src/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
StarOff,
Search,
Pencil,
ChevronRight,
} from 'lucide-react';
import { NavigationRenderer } from '@object-ui/layout';
import type { NavigationItem } from '@object-ui/types';
Expand Down Expand Up @@ -224,6 +225,9 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
// Search filter state for sidebar navigation
const [navSearchQuery, setNavSearchQuery] = React.useState('');

// Recent section collapsed by default
const [recentExpanded, setRecentExpanded] = React.useState(false);

// Visibility evaluation from Console expression context
const { evaluator } = useExpressionContext();
const evalVis = React.useCallback(
Expand Down Expand Up @@ -414,13 +418,18 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
</SidebarGroup>
)}

{/* Recent Items */}
{/* Recent Items (default collapsed) */}
{recentItems.length > 0 && (
<SidebarGroup>
<SidebarGroupLabel className="flex items-center gap-1.5">
<SidebarGroupLabel
className="flex items-center gap-1.5 cursor-pointer select-none"
onClick={() => setRecentExpanded(prev => !prev)}
>
<ChevronRight className={`h-3 w-3 transition-transform duration-150 ${recentExpanded ? 'rotate-90' : ''}`} />
<Clock className="h-3.5 w-3.5" />
Recent
</SidebarGroupLabel>
{recentExpanded && (
<SidebarGroupContent>
<SidebarMenu>
{recentItems.slice(0, 5).map(item => (
Expand All @@ -437,6 +446,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
))}
</SidebarMenu>
</SidebarGroupContent>
)}
</SidebarGroup>
)}
</SidebarContent>
Expand Down
3 changes: 3 additions & 0 deletions apps/console/src/utils/view-config-schema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,7 @@ function buildUserActionsSection(
key: 'userActions',
title: t('console.objectView.userActions'),
collapsible: true,
defaultCollapsed: true,
fields: [
// spec: NamedListView.inlineEdit (grid-only: only ObjectGrid supports inline editing)
buildSwitchField('inlineEdit', t('console.objectView.inlineEdit'), 'toggle-inlineEdit', true,
Expand Down Expand Up @@ -1290,6 +1291,7 @@ function buildSharingSection(
key: 'sharing',
title: t('console.objectView.sharing'),
collapsible: true,
defaultCollapsed: true,
fields: [
// spec: NamedListView.sharing.enabled
{
Expand Down Expand Up @@ -1349,6 +1351,7 @@ function buildAccessibilitySection(
key: 'accessibility',
title: t('console.objectView.accessibility'),
collapsible: true,
defaultCollapsed: true,
fields: [
// spec: NamedListView.aria.label
{
Expand Down
62 changes: 62 additions & 0 deletions examples/crm/src/__tests__/crm-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,68 @@ describe('CRM Metadata Spec Compliance', () => {
});
});

describe('ProductView Airtable Parity', () => {
const allProducts = ProductView.listViews.all_products;
const activeProducts = ProductView.listViews.active_products;

it('all_products uses detailed column configs with field, width, and type', () => {
const cols = allProducts.columns as Array<Record<string, unknown>>;
expect(cols.length).toBe(6);
for (const col of cols) {
expect(col).toHaveProperty('field');
expect(col).toHaveProperty('width');
}
});

it('is_active column has boolean type for Checkbox rendering', () => {
const cols = allProducts.columns as Array<Record<string, unknown>>;
const isActiveCol = cols.find((c) => c.field === 'is_active');
expect(isActiveCol).toBeDefined();
expect(isActiveCol!.type).toBe('boolean');
});

it('price column has currency type and right alignment', () => {
const cols = allProducts.columns as Array<Record<string, unknown>>;
const priceCol = cols.find((c) => c.field === 'price');
expect(priceCol).toBeDefined();
expect(priceCol!.type).toBe('currency');
expect(priceCol!.align).toBe('right');
});

it('rowHeight is set to short for compact density', () => {
expect(allProducts.rowHeight).toBe('short');
expect(activeProducts.rowHeight).toBe('short');
});

it('SKU and CATEGORY columns are narrower than NAME', () => {
const cols = allProducts.columns as Array<Record<string, unknown>>;
const nameCol = cols.find((c) => c.field === 'name');
const skuCol = cols.find((c) => c.field === 'sku');
const catCol = cols.find((c) => c.field === 'category');
expect((nameCol!.width as number)).toBeGreaterThan(skuCol!.width as number);
expect((nameCol!.width as number)).toBeGreaterThan(catCol!.width as number);
});

it('has conditionalFormatting for stock=0 (red) and stock<5 (warning)', () => {
const rules = allProducts.conditionalFormatting!;
expect(rules.length).toBe(2);
const zeroStock = rules.find((r: any) => r.condition?.includes('=== 0'));
expect(zeroStock).toBeDefined();
expect((zeroStock as any).style.backgroundColor).toBe('#fee2e2');
const lowStock = rules.find((r: any) => r.condition?.includes('< 5'));
expect(lowStock).toBeDefined();
expect((lowStock as any).style.backgroundColor).toBe('#fef9c3');
});

it('active_products also uses detailed column configs', () => {
const cols = activeProducts.columns as Array<Record<string, unknown>>;
expect(cols.length).toBe(6);
const priceCol = cols.find((c) => c.field === 'price');
expect(priceCol!.type).toBe('currency');
expect(priceCol!.align).toBe('right');
});
});

describe('Actions', () => {
it('all actions have name, label, type, and locations', () => {
for (const action of allActions) {
Expand Down
Loading