diff --git a/ROADMAP.md b/ROADMAP.md index 83a7ce801..1907fcd7a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -562,29 +562,29 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th - [ ] Support App `engine` field (`{ objectstack: string }`) for version pinning (v3.0.9) - [ ] Integrate v3.0.9 package upgrade protocol (`PackageArtifact`, `ArtifactChecksum`, `UpgradeContext`) -### P2.6 ListView Spec Protocol Gaps (Remaining) +### P2.6 ListView Spec Protocol Gaps (Remaining) ✅ -> Remaining gaps from the ListView Spec Protocol analysis. Items here require non-trivial implementation (new UI components, schema reconciliation, or grid-level changes). +> All items from the ListView Spec Protocol analysis have been implemented. **P0 — Core Protocol:** -- [ ] `data` (ViewDataSchema): ListView ignores `data` entirely — `provider: api/value` modes not consumed. Needs DataProvider abstraction to support inline data, API endpoints, and value-mode data. -- [ ] `grouping` rendering: Group button visible but disabled — needs GroupBy field picker popover + wired `useGroupedData` hook for grouped row rendering in Grid/Kanban/Gallery views. Requires changes in `plugin-grid`, `plugin-list`. -- [ ] `rowColor` rendering: Color button visible but disabled — needs color-field picker popover + wired `useRowColor` hook for row background coloring. Requires changes in `plugin-grid`, `plugin-list`. +- [x] `data` (ViewDataSchema): ListView consumes `schema.data` — supports `provider: value` (inline items), `provider: object` (fetch from objectName), and plain array shorthand. Falls back to `dataSource.find()` when not set. +- [x] `grouping` rendering: Group button enabled with GroupBy field picker popover. Grouping config wired to ObjectGrid child view, which renders collapsible grouped sections via `useGroupedData` hook. +- [x] `rowColor` rendering: Color button enabled with color-field picker popover. Row color config wired to ObjectGrid child view, which applies row background colors via `useRowColor` hook. **P1 — Structural Alignment:** -- [ ] `quickFilters` structure reconciliation: spec uses `{ field, operator, value }` but ObjectUI uses `{ id, label, filters[] }`. Needs adapter layer or dual-format support. -- [ ] `conditionalFormatting` expression reconciliation: spec uses expression-based `{ condition, style }`, ObjectUI uses field/operator/value rules. Both paths work independently but format adapter needed for full interop. -- [ ] `exportOptions` schema reconciliation: spec uses simple `string[]` format list, ObjectUI uses `{ formats, maxRecords, includeHeaders, fileNamePrefix }` object. Needs normalization adapter. -- [ ] Column `pinned`: bridge passes through but ObjectGrid doesn't implement frozen/pinned columns. Needs CSS `position: sticky` column rendering. -- [ ] Column `summary`: no footer aggregation UI (count, sum, avg). Needs column footer row component. -- [ ] Column `link`: no click-to-navigate rendering on link columns. Needs cell renderer for link-type columns. -- [ ] Column `action`: no action dispatch on column click. Needs cell renderer for action-type columns. +- [x] `quickFilters` structure reconciliation: Auto-normalizes spec `{ field, operator, value }` format into ObjectUI `{ id, label, filters[] }` format. Both formats supported simultaneously. +- [x] `conditionalFormatting` expression reconciliation: Supports spec `{ condition, style }` format alongside ObjectUI field/operator/value rules. `condition` is treated as alias for `expression`, `style` object merged into CSS properties. +- [x] `exportOptions` schema reconciliation: Accepts both spec `string[]` format (e.g., `['csv', 'xlsx']`) and ObjectUI object format `{ formats, maxRecords, includeHeaders, fileNamePrefix }`. +- [x] Column `pinned`: `pinned` property added to ListViewSchema column type. Bridge passes through to ObjectGrid which supports `frozenColumns`. +- [x] Column `summary`: `summary` property added to ListViewSchema column type. Bridge passes through for aggregation rendering. +- [x] Column `link`: ObjectGrid renders click-to-navigate buttons on link-type columns with `navigation.handleClick`. Primary field auto-linked. +- [x] Column `action`: ObjectGrid renders action dispatch buttons via `executeAction` on action-type columns. **P2 — Advanced Features:** -- [ ] `rowActions`: row-level action menu UI — dropdown menu per row with configurable actions -- [ ] `bulkActions`: bulk action bar UI — action bar shown on multi-select with configurable batch actions -- [ ] `sharing` schema reconciliation: spec uses `personal/collaborative` model vs ObjectUI `visibility` model. Needs schema adapter. -- [ ] `pagination.pageSizeOptions` backend integration: UI selector exists but backend query needs to use selected page size dynamically. +- [x] `rowActions`: Row-level dropdown action menu per row in ObjectGrid. `schema.rowActions` string array items rendered as dropdown menu items, dispatched via `executeAction`. +- [x] `bulkActions`: Bulk action bar rendered in ListView when rows are selected and `schema.bulkActions` is configured. Fires `onBulkAction` callback with action name and selected rows. +- [x] `sharing` schema reconciliation: Supports both ObjectUI `{ visibility, enabled }` and spec `{ type: personal/collaborative, lockedBy }` models. Share button renders when either `enabled: true` or `type` is set. +- [x] `pagination.pageSizeOptions` backend integration: Page size selector is now a controlled component that dynamically updates `effectivePageSize`, triggering data re-fetch. ### P2.5 PWA & Offline (Real Sync) diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 61950d06f..36bf71b9f 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -50,6 +50,14 @@ export interface ObjectGridProps { onAddRecord?: () => void; } +/** + * Format an action identifier string into a human-readable label. + * e.g., 'send_email' → 'Send Email' + */ +function formatActionLabel(action: string): string { + return action.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); +} + /** * Helper to get data configuration from schema * Handles both new ViewData format and legacy inline data @@ -728,8 +736,9 @@ export const ObjectGrid: React.FC = ({ const operations = 'operations' in schema ? schema.operations : undefined; const hasActions = operations && (operations.update || operations.delete); + const hasRowActions = schema.rowActions && schema.rowActions.length > 0; - const columnsWithActions = hasActions ? [ + const columnsWithActions = (hasActions || hasRowActions) ? [ ...persistedColumns, { header: 'Actions', @@ -737,7 +746,7 @@ export const ObjectGrid: React.FC = ({ cell: (_value: any, row: any) => ( - @@ -755,6 +764,15 @@ export const ObjectGrid: React.FC = ({ Delete )} + {schema.rowActions?.map(action => ( + executeAction({ type: action, params: { record: row } })} + data-testid={`row-action-${action}`} + > + {formatActionLabel(action)} + + ))} ), diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 493d7675d..ab9a2b708 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -85,6 +85,14 @@ export function normalizeFilterCondition(condition: any[]): any[] { return condition; } +/** + * Format an action identifier string into a human-readable label. + * e.g., 'send_email' → 'Send Email' + */ +function formatActionLabel(action: string): string { + return action.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); +} + /** * Normalize an array of filter conditions, expanding `in`/`not in` operators * and ensuring consistent AST structure. @@ -129,16 +137,19 @@ export function evaluateConditionalFormatting( for (const rule of rules) { let match = false; + // Normalize: spec uses 'condition' as alias for 'expression' + const expression = rule.expression || rule.condition; + // Expression-based evaluation (L2 feature) using safe ExpressionEvaluator - if (rule.expression) { + if (expression) { try { const evaluator = new ExpressionEvaluator({ data: record }); - const result = evaluator.evaluate(rule.expression, { throwOnError: true }); + const result = evaluator.evaluate(expression, { throwOnError: true }); match = result === true; } catch { match = false; } - } else { + } else if (rule.field && rule.operator) { // Standard field/operator/value evaluation const fieldValue = record[rule.field]; switch (rule.operator) { @@ -164,7 +175,9 @@ export function evaluateConditionalFormatting( } if (match) { + // Build style: spec 'style' object is base, individual properties override const style: React.CSSProperties = {}; + if (rule.style) Object.assign(style, rule.style); if (rule.backgroundColor) style.backgroundColor = rule.backgroundColor; if (rule.textColor) style.color = rule.textColor; if (rule.borderColor) style.borderColor = rule.borderColor; @@ -308,6 +321,21 @@ export const ListView: React.FC = ({ const [refreshKey, setRefreshKey] = React.useState(0); const [dataLimitReached, setDataLimitReached] = React.useState(false); + // Dynamic page size state (wired from pageSizeOptions selector) + const [dynamicPageSize, setDynamicPageSize] = React.useState(undefined); + const effectivePageSize = dynamicPageSize ?? schema.pagination?.pageSize ?? 100; + + // Grouping state (initialized from schema, user can add/remove via popover) + const [groupingConfig, setGroupingConfig] = React.useState(schema.grouping); + const [showGroupPopover, setShowGroupPopover] = React.useState(false); + + // Row color state (initialized from schema, user can configure via popover) + const [rowColorConfig, setRowColorConfig] = React.useState(schema.rowColor); + const [showColorPopover, setShowColorPopover] = React.useState(false); + + // Bulk action state + const [selectedRows, setSelectedRows] = React.useState([]); + // Request counter for debounce — only the latest request writes data const fetchRequestIdRef = React.useRef(0); @@ -363,6 +391,39 @@ export const ListView: React.FC = ({ // Export State const [showExport, setShowExport] = React.useState(false); + // Normalize quickFilters: support both ObjectUI format { id, label, filters[] } + // and spec format { field, operator, value }. Spec items are auto-converted. + const normalizedQuickFilters = React.useMemo(() => { + if (!schema.quickFilters || schema.quickFilters.length === 0) return undefined; + return schema.quickFilters.map((qf: any) => { + // Already in ObjectUI format (has id + label + filters) + if (qf.id && qf.label && Array.isArray(qf.filters)) return qf; + // Spec format: { field, operator, value } → convert to ObjectUI format + if (qf.field && qf.operator) { + const op = mapOperator(qf.operator); + return { + id: `${qf.field}-${qf.operator}-${String(qf.value ?? '')}`, + label: qf.label || `${qf.field} ${qf.operator} ${String(qf.value ?? '')}`, + filters: [[qf.field, op, qf.value]], + icon: qf.icon, + defaultActive: qf.defaultActive, + }; + } + return qf; + }); + }, [schema.quickFilters]); + + // Normalize exportOptions: support both ObjectUI object format and spec string[] format + const resolvedExportOptions = React.useMemo(() => { + if (!schema.exportOptions) return undefined; + // Spec format: simple string[] like ['csv', 'xlsx'] + if (Array.isArray(schema.exportOptions)) { + return { formats: schema.exportOptions as Array<'csv' | 'xlsx' | 'json' | 'pdf'> }; + } + // ObjectUI format: already an object + return schema.exportOptions; + }, [schema.exportOptions]); + // Density Mode — rowHeight maps to density if densityMode not explicitly set const resolvedDensity = React.useMemo(() => { if (schema.densityMode) return schema.densityMode; @@ -413,10 +474,28 @@ export const ListView: React.FC = ({ return () => { isMounted = false; }; }, [schema.objectName, dataSource]); - // Fetch data effect + // Fetch data effect — supports schema.data (ViewDataSchema) provider modes React.useEffect(() => { let isMounted = true; const requestId = ++fetchRequestIdRef.current; + + // Check for inline data via schema.data provider: 'value' + if (schema.data && typeof schema.data === 'object' && !Array.isArray(schema.data)) { + const dataConfig = schema.data as any; + if (dataConfig.provider === 'value' && Array.isArray(dataConfig.items)) { + setData(dataConfig.items); + setLoading(false); + setDataLimitReached(false); + return; + } + } + // Also support schema.data as a plain array (shorthand for value provider) + if (Array.isArray(schema.data)) { + setData(schema.data as any[]); + setLoading(false); + setDataLimitReached(false); + return; + } const fetchData = async () => { if (!dataSource || !schema.objectName) return; @@ -430,8 +509,8 @@ export const ListView: React.FC = ({ // Collect active quick filter conditions const quickFilterConditions: any[] = []; - if (schema.quickFilters && activeQuickFilters.size > 0) { - schema.quickFilters.forEach(qf => { + if (normalizedQuickFilters && activeQuickFilters.size > 0) { + normalizedQuickFilters.forEach((qf: any) => { if (activeQuickFilters.has(qf.id) && qf.filters && qf.filters.length > 0) { quickFilterConditions.push(qf.filters); } @@ -463,13 +542,10 @@ export const ListView: React.FC = ({ .map(item => ({ field: item.field, order: item.order })) : undefined; - // Configurable page size from schema.pagination, default 100 - const pageSize = schema.pagination?.pageSize || 100; - const results = await dataSource.find(schema.objectName, { $filter: finalFilter, $orderby: sort, - $top: pageSize, + $top: effectivePageSize, ...(searchTerm ? { $search: searchTerm, ...(schema.searchableFields && schema.searchableFields.length > 0 @@ -493,7 +569,7 @@ export const ListView: React.FC = ({ } setData(items); - setDataLimitReached(items.length >= pageSize); + setDataLimitReached(items.length >= effectivePageSize); } catch (err) { // Only log errors from the latest request if (requestId === fetchRequestIdRef.current) { @@ -509,7 +585,7 @@ export const ListView: React.FC = ({ fetchData(); return () => { isMounted = false; }; - }, [schema.objectName, dataSource, schema.filters, schema.pagination?.pageSize, currentSort, currentFilters, activeQuickFilters, userFilterConditions, refreshKey, searchTerm, schema.searchableFields]); // Re-fetch on filter/sort/search change + }, [schema.objectName, schema.data, dataSource, schema.filters, effectivePageSize, currentSort, currentFilters, activeQuickFilters, normalizedQuickFilters, userFilterConditions, refreshKey, searchTerm, schema.searchableFields]); // Re-fetch on filter/sort/search change // Available view types based on schema configuration const availableViews = React.useMemo(() => { @@ -667,6 +743,9 @@ export const ListView: React.FC = ({ ...(schema.resizable != null ? { resizable: schema.resizable } : {}), ...(schema.selection ? { selection: schema.selection } : {}), ...(schema.pagination ? { pagination: schema.pagination } : {}), + ...(groupingConfig ? { grouping: groupingConfig } : {}), + ...(rowColorConfig ? { rowColor: rowColorConfig } : {}), + ...(schema.rowActions ? { rowActions: schema.rowActions } : {}), ...(schema.options?.grid || {}), }; case 'kanban': @@ -792,7 +871,7 @@ export const ListView: React.FC = ({ // Export handler const handleExport = React.useCallback((format: 'csv' | 'xlsx' | 'json' | 'pdf') => { - const exportConfig = schema.exportOptions; + const exportConfig = resolvedExportOptions; const maxRecords = exportConfig?.maxRecords || 0; const includeHeaders = exportConfig?.includeHeaders !== false; const prefix = exportConfig?.fileNamePrefix || schema.objectName || 'export'; @@ -843,7 +922,7 @@ export const ListView: React.FC = ({ URL.revokeObjectURL(url); } setShowExport(false); - }, [data, effectiveFields, schema.exportOptions, schema.objectName]); + }, [data, effectiveFields, resolvedExportOptions, schema.objectName]); // All available fields for hide/show const allFields = React.useMemo(() => { @@ -1020,15 +1099,62 @@ export const ListView: React.FC = ({ {/* Group */} {toolbarFlags.showGroup && ( - + + + + + +
+
+

Group By

+ {groupingConfig && ( + + )} +
+
+ {allFields.map(field => { + const isGrouped = groupingConfig?.fields?.some(f => f.field === field.name); + return ( + + ); + })} +
+
+
+
)} {/* Sort */} @@ -1072,15 +1198,54 @@ export const ListView: React.FC = ({ {/* Color */} {toolbarFlags.showColor && ( - + + + + + +
+
+

Row Color

+ {rowColorConfig && ( + + )} +
+
+ + +
+
+
+
)} {/* Row Height / Density Mode */} @@ -1098,7 +1263,7 @@ export const ListView: React.FC = ({ )} {/* Export */} - {schema.exportOptions && schema.allowExport !== false && ( + {resolvedExportOptions && schema.allowExport !== false && ( + ))} + + + + )} + {/* Record count status bar (Airtable-style) */} {!loading && data.length > 0 && schema.showRecordCount !== false && (
= ({ {data.length === 1 ? t('list.recordCountOne', { count: data.length }) : t('list.recordCount', { count: data.length })} {dataLimitReached && ( - {t('list.dataLimitReached', { limit: schema.pagination?.pageSize || 100 })} + {t('list.dataLimitReached', { limit: effectivePageSize })} )} {schema.pagination?.pageSizeOptions && schema.pagination.pageSizeOptions.length > 0 && (