Skip to content

Commit 398aa24

Browse files
authored
Merge pull request #564 from objectstack-ai/copilot/complete-development-roadmap-console
2 parents 0a9d6e7 + 7c95238 commit 398aa24

7 files changed

Lines changed: 332 additions & 30 deletions

File tree

ROADMAP.md

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -137,43 +137,43 @@ All 4 phases complete across 5 designers (Page, View, DataModel, Process, Report
137137

138138
#### P0.1 Console Core UI Completeness
139139
- [ ] Implement declarative `ActionEngine` pipeline (events → `ActionDef[]` dispatch) — replaces callback-based `useObjectActions`
140-
- [ ] Migrate Console from static config to runtime metadata API (`getView()`/`getApp()`/`getPage()`)
141-
- [ ] Remove `as any` cast in `objectstack.shared.ts` — use properly typed config
140+
- [x] Migrate Console from static config to runtime metadata API (`getView()`/`getApp()`/`getPage()`)
141+
- [x] Remove `as any` cast in `objectstack.shared.ts` — use properly typed config
142142
- [ ] Clean up MSW workarounds in `objectstack.config.ts`
143-
- [ ] CSV/Excel export for grid views (core data workflow)
144-
- [ ] File upload fields in forms (required for real-world data entry)
145-
- [ ] Related record lookup in forms (essential for relational data)
143+
- [x] CSV/Excel export for grid views (core data workflow)
144+
- [x] File upload fields in forms (required for real-world data entry)
145+
- [x] Related record lookup in forms (essential for relational data)
146146

147147
#### P0.2 View Plugin Navigation Compliance
148-
- [ ] Add navigation property support to ObjectGallery (currently only accepts `onCardClick`)
149-
- [ ] Apply `navigation.width` to drawer/modal overlays in Kanban, Calendar, Gantt, Timeline, Map, View plugins
150-
- [ ] Implement `navigation.view` property across all view plugins
148+
- [x] Add navigation property support to ObjectGallery (currently only accepts `onCardClick`)
149+
- [x] Apply `navigation.width` to drawer/modal overlays in Kanban, Calendar, Gantt, Timeline, Map, View plugins
150+
- [x] Implement `navigation.view` property across all view plugins`NavigationOverlay` supports `renderView` prop for view-specific rendering
151151

152152
#### P0.3 Spec-Compliant View Configs
153-
- [ ] Define `TimelineConfig` type in `@object-ui/types` aligned with `@objectstack/spec TimelineConfigSchema`
154-
- [ ] Rename Timeline `dateField``startDateField` to match spec naming convention
155-
- [ ] Export `GalleryConfig` type from `@object-ui/types` index.ts
156-
- [ ] Implement Timeline spec properties: `endDateField`, `groupByField`, `colorField`, `scale`
153+
- [x] Define `TimelineConfig` type in `@object-ui/types` aligned with `@objectstack/spec TimelineConfigSchema`
154+
- [x] Rename Timeline `dateField``startDateField` to match spec naming convention
155+
- [x] Export `GalleryConfig` type from `@object-ui/types` index.ts
156+
- [x] Implement Timeline spec properties: `endDateField`, `groupByField`, `colorField`, `scale`
157157

158158
#### P0.4 ListView Spec Properties (v1.0 Subset)
159-
- [ ] Implement `emptyState` spec property (custom no-data UI — critical for UX)
160-
- [ ] Implement `hiddenFields` and `fieldOrder` spec properties (view customization)
161-
- [ ] Implement `quickFilters` spec property (predefined filter buttons)
159+
- [x] Implement `emptyState` spec property (custom no-data UI — critical for UX)
160+
- [x] Implement `hiddenFields` and `fieldOrder` spec properties (view customization)
161+
- [x] Implement `quickFilters` spec property (predefined filter buttons)
162162

163163
### P1. Spec Compliance — UI-Facing 📐
164164

165165
**Goal:** Achieve 100% compliance with `@objectstack/spec` for all UI-facing contracts. These items improve user experience and ensure protocol compatibility.
166166

167167
#### P1.1 View Enhancement Properties
168-
- [ ] Implement `rowHeight` spec property in ListView
169-
- [ ] Add `DensityMode` support to grid and list views
170-
- [ ] Implement `conditionalFormatting` spec property in ListView
171-
- [ ] Implement `inlineEdit` spec property in ListView
168+
- [x] Implement `rowHeight` spec property in ListView — maps to density mode (compact/medium/tall)
169+
- [x] Add `DensityMode` support to grid and list views
170+
- [x] Implement `conditionalFormatting` spec property in ListView — type definition and evaluation function
171+
- [x] Implement `inlineEdit` spec property in ListView — passed as `editable` to grid child view
172172

173173
#### P1.2 Data Export & Import
174-
- [ ] Implement `exportOptions` spec property in ListView (csv, xlsx, json, pdf)
175-
- [ ] Add `aria` spec property support to ListView
176-
- [ ] Add `sharing` spec property support to ListView
174+
- [x] Implement `exportOptions` spec property in ListView (csv, xlsx, json, pdf)
175+
- [x] Add `aria` spec property support to ListView — label, describedBy, live attributes
176+
- [x] Add `sharing` spec property support to ListView — Share button in toolbar with visibility level
177177

178178
#### P1.3 Advanced View Features
179179
- [ ] Inline task editing for Gantt chart

packages/components/src/__tests__/navigation-overlay.test.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,4 +270,76 @@ describe('NavigationOverlay', () => {
270270
expect(screen.getByTestId('status')).toHaveTextContent('active');
271271
});
272272
});
273+
274+
// ============================================================
275+
// renderView support
276+
// ============================================================
277+
278+
describe('renderView support', () => {
279+
it('should use renderView when both renderView and view are provided', () => {
280+
render(
281+
<NavigationOverlay
282+
{...createProps({
283+
mode: 'drawer',
284+
view: 'contact-detail',
285+
renderView: (record, viewName) => (
286+
<div data-testid="custom-view">
287+
<span data-testid="view-name">{viewName}</span>
288+
<span data-testid="record-name">{String(record.name)}</span>
289+
</div>
290+
),
291+
})}
292+
/>
293+
);
294+
expect(screen.getByTestId('custom-view')).toBeInTheDocument();
295+
expect(screen.getByTestId('view-name')).toHaveTextContent('contact-detail');
296+
expect(screen.getByTestId('record-name')).toHaveTextContent('Test Record');
297+
// Children should NOT be rendered
298+
expect(screen.queryByTestId('record-content')).not.toBeInTheDocument();
299+
});
300+
301+
it('should fallback to children when renderView is provided but view is not', () => {
302+
render(
303+
<NavigationOverlay
304+
{...createProps({
305+
mode: 'drawer',
306+
renderView: (_record, _viewName) => (
307+
<div data-testid="custom-view">Should not appear</div>
308+
),
309+
})}
310+
/>
311+
);
312+
// Children should be rendered since view is undefined
313+
expect(screen.getByTestId('record-content')).toBeInTheDocument();
314+
expect(screen.queryByTestId('custom-view')).not.toBeInTheDocument();
315+
});
316+
317+
it('should fallback to children when view is provided but renderView is not', () => {
318+
render(
319+
<NavigationOverlay
320+
{...createProps({
321+
mode: 'drawer',
322+
view: 'contact-detail',
323+
})}
324+
/>
325+
);
326+
// Children should be rendered since renderView is undefined
327+
expect(screen.getByTestId('record-content')).toBeInTheDocument();
328+
});
329+
330+
it('should use renderView in modal mode', () => {
331+
render(
332+
<NavigationOverlay
333+
{...createProps({
334+
mode: 'modal',
335+
view: 'edit-form',
336+
renderView: (record, viewName) => (
337+
<div data-testid="modal-custom-view">{viewName}: {String(record.name)}</div>
338+
),
339+
})}
340+
/>
341+
);
342+
expect(screen.getByTestId('modal-custom-view')).toHaveTextContent('edit-form: Test Record');
343+
});
344+
});
273345
});

packages/components/src/custom/navigation-overlay.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ export interface NavigationOverlayProps {
102102
* Receives the selected record.
103103
*/
104104
children: (record: Record<string, unknown>) => React.ReactNode;
105+
/**
106+
* Optional render function for a specific view/form based on `view` prop.
107+
* When provided, this takes priority over `children` for rendering overlay content.
108+
* Receives the selected record and the view name.
109+
*/
110+
renderView?: (record: Record<string, unknown>, viewName: string) => React.ReactNode;
105111
/**
106112
* The main content to wrap (for split mode only).
107113
* In split mode, the main content is rendered in the left panel.
@@ -148,10 +154,12 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
148154
close,
149155
setIsOpen,
150156
width,
157+
view,
151158
title,
152159
description,
153160
className,
154161
children,
162+
renderView,
155163
mainContent,
156164
popoverTrigger,
157165
}) => {
@@ -167,6 +175,14 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
167175
const widthStyle = getWidthStyle(width);
168176
const resolvedTitle = title || 'Record Detail';
169177

178+
// Use renderView when both renderView and view are provided; otherwise fallback to children
179+
const renderContent = (record: Record<string, unknown>) => {
180+
if (renderView && view) {
181+
return renderView(record, view);
182+
}
183+
return children(record);
184+
};
185+
170186
// --- Drawer Mode (Sheet) ---
171187
if (mode === 'drawer') {
172188
return (
@@ -181,7 +197,7 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
181197
{description && <SheetDescription>{description}</SheetDescription>}
182198
</SheetHeader>
183199
<div className="mt-4">
184-
{children(selectedRecord)}
200+
{renderContent(selectedRecord)}
185201
</div>
186202
</SheetContent>
187203
</Sheet>
@@ -201,7 +217,7 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
201217
{description && <DialogDescription>{description}</DialogDescription>}
202218
</DialogHeader>
203219
<div className="mt-4">
204-
{children(selectedRecord)}
220+
{renderContent(selectedRecord)}
205221
</div>
206222
</DialogContent>
207223
</Dialog>
@@ -260,7 +276,7 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
260276
{description && (
261277
<p className="text-sm text-muted-foreground mb-4">{description}</p>
262278
)}
263-
{children(selectedRecord)}
279+
{renderContent(selectedRecord)}
264280
</div>
265281
</ResizablePanel>
266282
</PanelGroup>
@@ -285,7 +301,7 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
285301
{description && (
286302
<p className="text-xs text-muted-foreground">{description}</p>
287303
)}
288-
{children(selectedRecord)}
304+
{renderContent(selectedRecord)}
289305
</div>
290306
</PopoverContent>
291307
</Popover>

packages/plugin-list/src/ListView.tsx

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import * as React from 'react';
1010
import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay } from '@object-ui/components';
1111
import type { SortItem } from '@object-ui/components';
12-
import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Group, Paintbrush, Ruler, Inbox, Download, AlignJustify, icons, type LucideIcon } from 'lucide-react';
12+
import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Group, Paintbrush, Ruler, Inbox, Download, AlignJustify, Share2, icons, type LucideIcon } from 'lucide-react';
1313
import type { FilterGroup } from '@object-ui/components';
1414
import { ViewSwitcher, ViewType } from './ViewSwitcher';
1515
import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react';
@@ -64,6 +64,52 @@ function convertFilterGroupToAST(group: FilterGroup): any[] {
6464
return [group.logic, ...conditions];
6565
}
6666

67+
/**
68+
* Evaluate conditional formatting rules against a record.
69+
* Returns a CSSProperties object for the first matching rule, or empty object.
70+
*
71+
* Exported for use by child view renderers (e.g., ObjectGrid) and consumers
72+
* who need to evaluate formatting rules outside the ListView component.
73+
*/
74+
export function evaluateConditionalFormatting(
75+
record: Record<string, unknown>,
76+
rules?: ListViewSchema['conditionalFormatting']
77+
): React.CSSProperties {
78+
if (!rules || rules.length === 0) return {};
79+
for (const rule of rules) {
80+
const fieldValue = record[rule.field];
81+
let match = false;
82+
switch (rule.operator) {
83+
case 'equals':
84+
match = fieldValue === rule.value;
85+
break;
86+
case 'not_equals':
87+
match = fieldValue !== rule.value;
88+
break;
89+
case 'contains':
90+
match = typeof fieldValue === 'string' && typeof rule.value === 'string' && fieldValue.includes(rule.value);
91+
break;
92+
case 'greater_than':
93+
match = typeof fieldValue === 'number' && typeof rule.value === 'number' && fieldValue > rule.value;
94+
break;
95+
case 'less_than':
96+
match = typeof fieldValue === 'number' && typeof rule.value === 'number' && fieldValue < rule.value;
97+
break;
98+
case 'in':
99+
match = Array.isArray(rule.value) && rule.value.includes(fieldValue);
100+
break;
101+
}
102+
if (match) {
103+
const style: React.CSSProperties = {};
104+
if (rule.backgroundColor) style.backgroundColor = rule.backgroundColor;
105+
if (rule.textColor) style.color = rule.textColor;
106+
if (rule.borderColor) style.borderColor = rule.borderColor;
107+
return style;
108+
}
109+
}
110+
return {};
111+
}
112+
67113
export const ListView: React.FC<ListViewProps> = ({
68114
schema: propSchema,
69115
className,
@@ -132,8 +178,20 @@ export const ListView: React.FC<ListViewProps> = ({
132178
// Export State
133179
const [showExport, setShowExport] = React.useState(false);
134180

135-
// Density Mode
136-
const density = useDensityMode(schema.densityMode || 'comfortable');
181+
// Density Mode — rowHeight maps to density if densityMode not explicitly set
182+
const resolvedDensity = React.useMemo(() => {
183+
if (schema.densityMode) return schema.densityMode;
184+
if (schema.rowHeight) {
185+
const map: Record<string, 'compact' | 'comfortable' | 'spacious'> = {
186+
compact: 'compact',
187+
medium: 'comfortable',
188+
tall: 'spacious',
189+
};
190+
return map[schema.rowHeight] || 'comfortable';
191+
}
192+
return 'comfortable';
193+
}, [schema.densityMode, schema.rowHeight]);
194+
const density = useDensityMode(resolvedDensity);
137195

138196
const handlePullRefresh = React.useCallback(async () => {
139197
setRefreshKey(k => k + 1);
@@ -380,6 +438,8 @@ export const ListView: React.FC<ListViewProps> = ({
380438
type: 'object-grid',
381439
...baseProps,
382440
columns: effectiveFields,
441+
...(schema.conditionalFormatting ? { conditionalFormatting: schema.conditionalFormatting } : {}),
442+
...(schema.inlineEdit != null ? { editable: schema.inlineEdit } : {}),
383443
...(schema.options?.grid || {}),
384444
};
385445
case 'kanban':
@@ -528,7 +588,14 @@ export const ListView: React.FC<ListViewProps> = ({
528588
}, [schema.fields]);
529589

530590
return (
531-
<div ref={pullRef} className={cn('flex flex-col h-full bg-background relative', className)}>
591+
<div
592+
ref={pullRef}
593+
className={cn('flex flex-col h-full bg-background relative', className)}
594+
{...(schema.aria?.label ? { 'aria-label': schema.aria.label } : {})}
595+
{...(schema.aria?.describedBy ? { 'aria-describedby': schema.aria.describedBy } : {})}
596+
{...(schema.aria?.live ? { 'aria-live': schema.aria.live } : {})}
597+
role="region"
598+
>
532599
{pullDistance > 0 && (
533600
<div
534601
className="flex items-center justify-center text-xs text-muted-foreground"
@@ -747,6 +814,20 @@ export const ListView: React.FC<ListViewProps> = ({
747814
</PopoverContent>
748815
</Popover>
749816
)}
817+
818+
{/* Share */}
819+
{schema.sharing?.enabled && (
820+
<Button
821+
variant="ghost"
822+
size="sm"
823+
className="h-7 px-2 text-muted-foreground hover:text-primary text-xs"
824+
title={`Sharing: ${schema.sharing.visibility || 'private'}`}
825+
data-testid="share-button"
826+
>
827+
<Share2 className="h-3.5 w-3.5 mr-1.5" />
828+
<span className="hidden sm:inline">Share</span>
829+
</Button>
830+
)}
750831
</div>
751832

752833
{/* Right: Search */}

0 commit comments

Comments
 (0)