Complete all remaining roadmap console development (Phases 0, 2.9, 15–18 L1) - #560
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- Add typed resolveDefault<T> helper for ESM/CJS interop instead of `as any` - Use `Parameters<typeof defineStack>[0]` for the defineStack call - No logic changes, only type-safety improvements Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace inline onSuccess/onCancel callbacks in the CRUD dialog's ObjectForm with ActionRunner-dispatched handlers (crud_success, dialog_cancel). This aligns with the Phase 2.9 roadmap goal of migrating CRUD dialog to ActionDef[]. The callbacks still conform to the ObjectFormSchema's function signature but now delegate to registered ActionRunner handlers, enabling future extensibility through the action system. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add ImportWizard component with 3-step workflow: 1. File Upload - drag & drop or file picker for CSV files 2. Column Mapping - map CSV columns to object fields with auto-detection 3. Preview & Import - data preview, validation, and batch import Features: - Built-in CSV parser with quote handling - Auto-mapping of CSV headers to field names/labels - Data type validation (number, boolean, date, etc.) - Required field validation - Import progress bar - Result summary with error details - Registered as 'import-wizard' in ComponentRegistry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Generate shareable read-only view links with Popover UI
- URL format: {baseUrl}/share/{objectName}/{viewId}?mode=readonly&token={random}
- Uses Shadcn UI primitives (Button, Popover, Badge, Input)
- Registered in ComponentRegistry as 'shared-view-link'
- Copy-to-clipboard with fallback support
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a class-based singleton UndoManager to @object-ui/core that provides global undo/redo capabilities for CRUD operations. Unlike the existing useUndoRedo hook (scoped to ProcessDesigner), this is framework-agnostic and can be used from ActionRunner, React hooks, or standalone contexts. - UndoableOperation interface for create/update/delete operations - Bounded history stacks with configurable maxHistory (default 50) - Subscriber pattern for reactive state change notifications - getHistory() for developer tools integration - Global singleton instance exported as globalUndoManager Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a React hook that provides reactive undo/redo state from the globalUndoManager singleton. The hook: - Subscribes via useSyncExternalStore for tear-free reads - Executes undo/redo through a pluggable dataSource interface - Registers Ctrl+Z / Ctrl+Shift+Z keyboard shortcuts - Exposes canUndo, canRedo, descriptions, and history Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…7 L1)
Add collaboration comments section below the DetailView in RecordDetailView.
Uses local state for comments (L1/mock mode) with useAuth for current user
and a fallback mock user. Thread ID follows ${objectName}:${recordId} pattern.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a lightweight ActivityFeed component that displays recent activity items (create, update, delete, comment) in a slide-out Sheet panel. - Shows activity items with type-specific icons and colors - Formats timestamps as relative time (e.g. '2m ago') - Validates timestamp input to handle invalid dates gracefully - Displays badge count on the bell trigger button - Shows empty state when no activities exist - Uses local state only (L1 - no server integration) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… components Phase 18 L1: Trigger-Action pipeline configuration UI. - AutomationBuilder: 3-section UI for configuring trigger type, actions pipeline, and automation metadata (name, description, toggle) - AutomationRunHistory: Status-badged list of past automation runs with duration, trigger event, and error display - Register both as 'automation-builder' and 'automation-run-history' in ComponentRegistry - Export all interfaces (TriggerConfig, ActionConfig, AutomationDefinition, AutomationRun) for consumers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Phase 0: ✅ Complete — removed all `as any` casts with typed helpers - Phase 2: ✅ Complete — CRUD dialog migrated to ActionDef[] - Phase 15: ✅ L1 Complete — CSV Import Wizard, Universal Export, Shared View Links - Phase 16: ✅ L1 Complete — Global UndoManager, useGlobalUndo hook - Phase 17: ✅ L1 Complete — CommentThread integrated, ActivityFeed sidebar - Phase 18: ✅ L1 Complete — AutomationBuilder, AutomationRunHistory - Updated execution timeline, milestone summary, and feature tables Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR completes several remaining console roadmap L1 items by adding new workflow/import/share components, introducing a global undo/redo foundation, migrating CRUD dialog callbacks to ActionRunner handlers, and updating the roadmap/status documentation.
Changes:
- Added global undo/redo foundations (
UndoManagerin core +useGlobalUndohook in react). - Added Phase 15/18 UI components (CSV
ImportWizard,SharedViewLink,AutomationBuilder,AutomationRunHistory) and registered them in their plugin registries. - Integrated collaboration UI (
CommentThread) into record detail, addedActivityFeed, removedas anycasts in shared stack config, and updatedROADMAP_CONSOLE.md.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/react/src/hooks/useGlobalUndo.ts | New React hook wrapping global undo manager + keyboard shortcuts |
| packages/react/src/hooks/index.ts | Re-export useGlobalUndo |
| packages/core/src/actions/UndoManager.ts | New global undo/redo manager + singleton |
| packages/core/src/actions/index.ts | Exports UndoManager |
| packages/plugin-grid/src/ImportWizard.tsx | New CSV import wizard dialog (upload → mapping → preview/import) |
| packages/plugin-grid/src/index.tsx | Exports/registers ImportWizard |
| packages/plugin-view/src/SharedViewLink.tsx | New UI to generate/copy shareable read-only view links |
| packages/plugin-view/src/index.tsx | Exports/registers SharedViewLink |
| packages/plugin-workflow/src/AutomationBuilder.tsx | New trigger/action automation builder UI |
| packages/plugin-workflow/src/AutomationRunHistory.tsx | New automation run history list UI |
| packages/plugin-workflow/src/index.tsx | Exports/registers automation components |
| packages/plugin-workflow/src/tests/AutomationBuilder.test.tsx | Tests for AutomationBuilder |
| packages/plugin-workflow/src/tests/AutomationRunHistory.test.tsx | Tests for AutomationRunHistory |
| apps/console/src/App.tsx | CRUD dialog success/cancel migrated to ActionRunner handlers |
| apps/console/src/components/RecordDetailView.tsx | Integrates CommentThread into record details |
| apps/console/src/components/ActivityFeed.tsx | Adds sheet-based activity feed UI component |
| apps/console/objectstack.shared.ts | Removes as any casts via typed resolveDefault + typed defineStack args |
| ROADMAP_CONSOLE.md | Marks phases complete and updates roadmap/status text |
| // Cache reference to avoid re-renders when nothing changed | ||
| let cachedSnapshot = getSnapshot(); | ||
| function subscribe(callback: () => void) { | ||
| return globalUndoManager.subscribe(() => { | ||
| cachedSnapshot = getSnapshot(); | ||
| callback(); | ||
| }); | ||
| } | ||
| function getCachedSnapshot() { return cachedSnapshot; } | ||
|
|
There was a problem hiding this comment.
useSyncExternalStore snapshot caching can become stale: cachedSnapshot is only updated inside subscribe(). If operations are pushed to globalUndoManager before any component subscribes (or between module init and first subscribe), getCachedSnapshot() will return outdated canUndo/canRedo/history until another change event fires. Prefer using getSnapshot directly as the snapshot getter (and drop the module-level cache), or update the cache on every getCachedSnapshot() call.
| export function useGlobalUndo(options: UseGlobalUndoOptions = {}) { | ||
| const optionsRef = useRef(options); | ||
| optionsRef.current = options; | ||
|
|
||
| const state = useSyncExternalStore(subscribe, getCachedSnapshot, getServerSnapshot); | ||
|
|
||
| const executeOp = useCallback(async (op: UndoableOperation, data: Record<string, unknown>, mode: 'undo' | 'redo') => { | ||
| const ds = optionsRef.current.dataSource; | ||
| if (!ds) return; | ||
| const action = mode === 'undo' | ||
| ? ({ create: 'delete', update: 'update', delete: 'create' } as const)[op.type] | ||
| : op.type; | ||
| if (action === 'delete') await ds.delete(op.objectName, op.recordId); | ||
| else if (action === 'update') await ds.update(op.objectName, op.recordId, data); | ||
| else await ds.create(op.objectName, data); | ||
| }, []); | ||
|
|
||
| const undo = useCallback(async () => { | ||
| const op = globalUndoManager.popUndo(); | ||
| if (!op) return; | ||
| await executeOp(op, op.undoData, 'undo'); | ||
| optionsRef.current.onUndo?.(op); | ||
| }, [executeOp]); | ||
|
|
||
| const redo = useCallback(async () => { | ||
| const op = globalUndoManager.popRedo(); | ||
| if (!op) return; | ||
| await executeOp(op, op.redoData, 'redo'); | ||
| optionsRef.current.onRedo?.(op); | ||
| }, [executeOp]); | ||
|
|
||
| // Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Shift+Z (redo) | ||
| useEffect(() => { | ||
| const handler = (e: KeyboardEvent) => { | ||
| if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z') return; | ||
| e.preventDefault(); | ||
| if (e.shiftKey) { void redo(); } else { void undo(); } | ||
| }; | ||
| window.addEventListener('keydown', handler); | ||
| return () => window.removeEventListener('keydown', handler); | ||
| }, [undo, redo]); | ||
|
|
||
| return useMemo(() => ({ ...state, undo, redo }), [state, undo, redo]); | ||
| } |
There was a problem hiding this comment.
New undo/redo behavior is introduced here but there are no tests validating snapshot updates, stack behavior on failures, and keyboard shortcut handling. The packages/react/src/hooks/__tests__ folder has tests for other hooks; adding a useGlobalUndo test suite would help prevent regressions (especially around failed undo/redo not corrupting history).
| export class UndoManager { | ||
| private undoStack: UndoableOperation[] = []; | ||
| private redoStack: UndoableOperation[] = []; | ||
| private readonly maxHistory: number; | ||
| private listeners: Set<() => void> = new Set(); | ||
|
|
||
| constructor(options?: UndoManagerOptions) { | ||
| this.maxHistory = options?.maxHistory ?? 50; | ||
| } | ||
|
|
||
| /** Record an undoable operation. Clears the redo stack. */ | ||
| push(operation: UndoableOperation): void { | ||
| this.undoStack.push(operation); | ||
| if (this.undoStack.length > this.maxHistory) { | ||
| this.undoStack.shift(); | ||
| } | ||
| this.redoStack = []; | ||
| this.notify(); | ||
| } | ||
|
|
||
| /** Peek at the next undoable operation without removing it. */ | ||
| peekUndo(): UndoableOperation | undefined { | ||
| return this.undoStack[this.undoStack.length - 1]; | ||
| } | ||
|
|
||
| /** Peek at the next redoable operation without removing it. */ | ||
| peekRedo(): UndoableOperation | undefined { | ||
| return this.redoStack[this.redoStack.length - 1]; | ||
| } | ||
|
|
||
| /** Pop the last operation from the undo stack and move it to redo. */ | ||
| popUndo(): UndoableOperation | undefined { | ||
| const op = this.undoStack.pop(); | ||
| if (op) { | ||
| this.redoStack.push(op); | ||
| this.notify(); | ||
| } | ||
| return op; | ||
| } | ||
|
|
||
| /** Pop the last operation from the redo stack and move it to undo. */ | ||
| popRedo(): UndoableOperation | undefined { | ||
| const op = this.redoStack.pop(); | ||
| if (op) { | ||
| this.undoStack.push(op); | ||
| this.notify(); | ||
| } | ||
| return op; | ||
| } | ||
|
|
||
| /** Clear all undo and redo history. */ | ||
| clear(): void { | ||
| this.undoStack = []; | ||
| this.redoStack = []; | ||
| this.notify(); | ||
| } | ||
|
|
||
| get canUndo(): boolean { return this.undoStack.length > 0; } | ||
| get canRedo(): boolean { return this.redoStack.length > 0; } | ||
| get undoCount(): number { return this.undoStack.length; } | ||
| get redoCount(): number { return this.redoStack.length; } | ||
|
|
||
| /** Subscribe to state changes. Returns an unsubscribe function. */ | ||
| subscribe(listener: () => void): () => void { | ||
| this.listeners.add(listener); | ||
| return () => this.listeners.delete(listener); | ||
| } | ||
|
|
||
| /** Get a shallow copy of the undo history (for developer tools). */ | ||
| getHistory(): UndoableOperation[] { return [...this.undoStack]; } | ||
|
|
||
| private notify(): void { this.listeners.forEach((fn) => fn()); } | ||
| } | ||
|
|
||
| /** Global singleton instance. */ | ||
| export const globalUndoManager = new UndoManager(); |
There was a problem hiding this comment.
UndoManager is new core behavior that will be depended on by multiple packages, but there are no unit tests covering stack bounds (maxHistory), push/pop semantics, redo clearing on push, and subscriber notifications. packages/core/src/actions already has a comprehensive test suite for ActionRunner/TransactionManager, so adding similar tests for UndoManager would help ensure correctness.
| // Main wizard component | ||
| export const ImportWizard: React.FC<ImportWizardProps> = ({ | ||
| objectName, objectLabel, fields, dataSource, onComplete, onCancel, open, onOpenChange, | ||
| }) => { | ||
| const [step, setStep] = useState<WizardStep>('upload'); | ||
| const [headers, setHeaders] = useState<string[]>([]); | ||
| const [rows, setRows] = useState<string[][]>([]); | ||
| const [mapping, setMapping] = useState<Record<number, string>>({}); | ||
| const [importing, setImporting] = useState(false); | ||
| const [progress, setProgress] = useState(0); | ||
| const [result, setResult] = useState<ImportResult | null>(null); | ||
| const label = objectLabel ?? objectName; | ||
|
|
||
| const missingRequired = useMemo(() => { | ||
| const mapped = new Set(Object.values(mapping)); | ||
| return fields.filter((f) => f.required && !mapped.has(f.name)); | ||
| }, [fields, mapping]); | ||
|
|
||
| const handleFileLoaded = useCallback((h: string[], r: string[][]) => { | ||
| setHeaders(h); setRows(r); setMapping(autoMapColumns(h, fields)); setStep('mapping'); | ||
| }, [fields]); | ||
|
|
||
| const handleImport = useCallback(async () => { | ||
| setImporting(true); setProgress(0); | ||
| const errors: ImportResult['errors'] = []; | ||
| let importedRows = 0, skippedRows = 0; | ||
| const mappedCols = Object.entries(mapping).map(([idx, name]) => ({ | ||
| csvIdx: Number(idx), field: fields.find((f) => f.name === name)!, | ||
| })); | ||
|
|
||
| for (let i = 0; i < rows.length; i++) { | ||
| const { record, errors: rowErrors } = validateRow(rows[i], mappedCols, i + 1); | ||
| if (rowErrors.length > 0) { | ||
| skippedRows++; | ||
| errors.push(...rowErrors); | ||
| } else { | ||
| try { if (dataSource?.create) await dataSource.create(objectName, record); importedRows++; } | ||
| catch (err) { | ||
| skippedRows++; | ||
| const msg = err instanceof Error ? err.message : 'Failed to create record'; | ||
| errors.push({ row: i + 1, field: '', message: msg }); | ||
| } | ||
| } | ||
| setProgress(Math.round(((i + 1) / rows.length) * 100)); | ||
| } | ||
| const importResult: ImportResult = { totalRows: rows.length, importedRows, skippedRows, errors }; | ||
| setResult(importResult); setImporting(false); onComplete?.(importResult); | ||
| }, [rows, mapping, fields, dataSource, objectName, onComplete]); | ||
|
|
||
| const reset = useCallback(() => { | ||
| setStep('upload'); setHeaders([]); setRows([]); setMapping({}); setProgress(0); setResult(null); | ||
| }, []); | ||
|
|
||
| const handleClose = useCallback(() => { reset(); onOpenChange?.(false); onCancel?.(); }, [reset, onOpenChange, onCancel]); | ||
|
|
||
| return ( | ||
| <Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); else onOpenChange?.(v); }}> | ||
| <DialogContent className="sm:max-w-2xl"> | ||
| <DialogHeader> | ||
| <DialogTitle className="flex items-center gap-2"> | ||
| <FileSpreadsheet className="h-5 w-5" /> Import {label} | ||
| </DialogTitle> | ||
| <DialogDescription> | ||
| {step === 'upload' && 'Upload a CSV file to get started.'} | ||
| {step === 'mapping' && 'Map CSV columns to object fields.'} | ||
| {step === 'preview' && 'Review data before importing.'} | ||
| </DialogDescription> | ||
| </DialogHeader> | ||
|
|
||
| {/* Step indicators */} | ||
| <div className="flex items-center justify-center gap-2 text-xs text-muted-foreground"> | ||
| {(['upload', 'mapping', 'preview'] as WizardStep[]).map((s, i) => ( | ||
| <React.Fragment key={s}> | ||
| {i > 0 && <ArrowRight className="h-3 w-3" />} | ||
| <span className={cn('rounded-full px-3 py-1', step === s ? 'bg-primary text-primary-foreground' : 'bg-muted')}> | ||
| {i + 1}. {s === 'upload' ? 'Upload' : s === 'mapping' ? 'Mapping' : 'Preview'} | ||
| </span> | ||
| </React.Fragment> | ||
| ))} | ||
| </div> | ||
|
|
||
| {!result ? ( | ||
| <> | ||
| {step === 'upload' && <StepUpload onFileLoaded={handleFileLoaded} />} | ||
| {step === 'mapping' && <StepMapping headers={headers} fields={fields} mapping={mapping} onMappingChange={setMapping} />} | ||
| {step === 'preview' && <StepPreview headers={headers} rows={rows} mapping={mapping} fields={fields} />} | ||
| {importing && ( | ||
| <div className="flex flex-col gap-1"> | ||
| <Progress value={progress} className="h-2" /> | ||
| <p className="text-center text-xs text-muted-foreground">Importing… {progress}%</p> | ||
| </div> | ||
| )} | ||
| </> | ||
| ) : ( | ||
| <div className="flex flex-col items-center gap-3 py-4"> | ||
| <CheckCircle2 className="h-10 w-10 text-green-500" /> | ||
| <p className="text-lg font-semibold">Import Complete</p> | ||
| <div className="flex gap-3"> | ||
| <Badge variant="default">{result.importedRows} imported</Badge> | ||
| {result.skippedRows > 0 && <Badge variant="destructive">{result.skippedRows} skipped</Badge>} | ||
| </div> | ||
| {result.errors.length > 0 && ( | ||
| <div className="max-h-32 w-full overflow-auto rounded border p-2 text-xs"> | ||
| {result.errors.slice(0, 10).map((err, i) => ( | ||
| <p key={i} className="text-destructive">Row {err.row}{err.field ? ` (${err.field})` : ''}: {err.message}</p> | ||
| ))} | ||
| {result.errors.length > 10 && <p className="text-muted-foreground">…and {result.errors.length - 10} more errors</p>} | ||
| </div> | ||
| )} | ||
| </div> | ||
| )} | ||
|
|
||
| <DialogFooter className="gap-2 sm:gap-0"> | ||
| {result ? ( | ||
| <Button onClick={handleClose}>Close</Button> | ||
| ) : ( | ||
| <> | ||
| <Button variant="ghost" onClick={handleClose} disabled={importing}><X className="mr-1 h-4 w-4" /> Cancel</Button> | ||
| {(step === 'mapping' || step === 'preview') && ( | ||
| <Button variant="outline" onClick={() => setStep(step === 'mapping' ? 'upload' : 'mapping')} disabled={importing}> | ||
| <ArrowLeft className="mr-1 h-4 w-4" /> Back | ||
| </Button> | ||
| )} | ||
| {step === 'mapping' && ( | ||
| <Button onClick={() => setStep('preview')} disabled={Object.keys(mapping).length === 0 || missingRequired.length > 0}> | ||
| Next <ArrowRight className="ml-1 h-4 w-4" /> | ||
| </Button> | ||
| )} | ||
| {step === 'preview' && ( | ||
| <Button onClick={handleImport} disabled={importing}> | ||
| {importing ? 'Importing…' : `Import ${rows.length} Rows`} | ||
| </Button> | ||
| )} | ||
| </> | ||
| )} | ||
| </DialogFooter> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
ImportWizard is a new end-to-end flow (CSV parsing, mapping, validation, import result handling) but there are currently no tests for it. packages/plugin-grid/src/__tests__ already has coverage for other complex flows; adding tests for mapping behavior, required-field blocking, and import error aggregation would reduce regression risk.
| function generateToken(): string { | ||
| if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { | ||
| return crypto.randomUUID(); | ||
| } | ||
| // Fallback for environments without crypto.randomUUID | ||
| if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { | ||
| const bytes = new Uint8Array(16); | ||
| crypto.getRandomValues(bytes); | ||
| return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); | ||
| } | ||
| return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); | ||
| } |
There was a problem hiding this comment.
generateToken() falls back to Math.random() when Web Crypto APIs are unavailable. If the token is used for access control (as implied by “public link” + token=), Math.random() is not suitable and can weaken security. Prefer requiring a cryptographically secure token (or generating the token server-side) and showing an explicit error/disabled state when secure randomness isn’t available.
| const handleClose = useCallback(() => { reset(); onOpenChange?.(false); onCancel?.(); }, [reset, onOpenChange, onCancel]); | ||
|
|
||
| return ( | ||
| <Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); else onOpenChange?.(v); }}> | ||
| <DialogContent className="sm:max-w-2xl"> | ||
| <DialogHeader> | ||
| <DialogTitle className="flex items-center gap-2"> | ||
| <FileSpreadsheet className="h-5 w-5" /> Import {label} | ||
| </DialogTitle> | ||
| <DialogDescription> | ||
| {step === 'upload' && 'Upload a CSV file to get started.'} | ||
| {step === 'mapping' && 'Map CSV columns to object fields.'} | ||
| {step === 'preview' && 'Review data before importing.'} | ||
| </DialogDescription> | ||
| </DialogHeader> | ||
|
|
||
| {/* Step indicators */} | ||
| <div className="flex items-center justify-center gap-2 text-xs text-muted-foreground"> | ||
| {(['upload', 'mapping', 'preview'] as WizardStep[]).map((s, i) => ( | ||
| <React.Fragment key={s}> | ||
| {i > 0 && <ArrowRight className="h-3 w-3" />} | ||
| <span className={cn('rounded-full px-3 py-1', step === s ? 'bg-primary text-primary-foreground' : 'bg-muted')}> | ||
| {i + 1}. {s === 'upload' ? 'Upload' : s === 'mapping' ? 'Mapping' : 'Preview'} | ||
| </span> | ||
| </React.Fragment> | ||
| ))} | ||
| </div> | ||
|
|
||
| {!result ? ( | ||
| <> | ||
| {step === 'upload' && <StepUpload onFileLoaded={handleFileLoaded} />} | ||
| {step === 'mapping' && <StepMapping headers={headers} fields={fields} mapping={mapping} onMappingChange={setMapping} />} | ||
| {step === 'preview' && <StepPreview headers={headers} rows={rows} mapping={mapping} fields={fields} />} | ||
| {importing && ( | ||
| <div className="flex flex-col gap-1"> | ||
| <Progress value={progress} className="h-2" /> | ||
| <p className="text-center text-xs text-muted-foreground">Importing… {progress}%</p> | ||
| </div> | ||
| )} | ||
| </> | ||
| ) : ( | ||
| <div className="flex flex-col items-center gap-3 py-4"> | ||
| <CheckCircle2 className="h-10 w-10 text-green-500" /> | ||
| <p className="text-lg font-semibold">Import Complete</p> | ||
| <div className="flex gap-3"> | ||
| <Badge variant="default">{result.importedRows} imported</Badge> | ||
| {result.skippedRows > 0 && <Badge variant="destructive">{result.skippedRows} skipped</Badge>} | ||
| </div> | ||
| {result.errors.length > 0 && ( | ||
| <div className="max-h-32 w-full overflow-auto rounded border p-2 text-xs"> | ||
| {result.errors.slice(0, 10).map((err, i) => ( | ||
| <p key={i} className="text-destructive">Row {err.row}{err.field ? ` (${err.field})` : ''}: {err.message}</p> | ||
| ))} | ||
| {result.errors.length > 10 && <p className="text-muted-foreground">…and {result.errors.length - 10} more errors</p>} | ||
| </div> | ||
| )} | ||
| </div> | ||
| )} | ||
|
|
||
| <DialogFooter className="gap-2 sm:gap-0"> | ||
| {result ? ( | ||
| <Button onClick={handleClose}>Close</Button> | ||
| ) : ( |
There was a problem hiding this comment.
handleClose() always calls onCancel?.() even when the wizard is being closed after a successful import (the “Close” button shown in the result state also calls handleClose). This makes it hard for callers to distinguish a true cancel vs. a completed flow. Consider only calling onCancel for explicit cancel actions, and/or introduce a separate onClose callback for general dialog closure.
| export const SharedViewLink: React.FC<SharedViewLinkProps> = ({ | ||
| objectName, | ||
| viewId = 'default', | ||
| baseUrl, | ||
| onShare, | ||
| className, | ||
| }) => { | ||
| const [shareUrl, setShareUrl] = React.useState<string | null>(null); | ||
| const [copied, setCopied] = React.useState(false); | ||
| const [open, setOpen] = React.useState(false); | ||
|
|
||
| const resolvedBaseUrl = baseUrl ?? (typeof window !== 'undefined' ? window.location.origin : ''); | ||
|
|
||
| const handleGenerateLink = React.useCallback(() => { | ||
| const token = generateToken(); | ||
| const url = buildShareUrl(resolvedBaseUrl, objectName, viewId, token); | ||
| setShareUrl(url); | ||
| setCopied(false); | ||
| onShare?.(url); | ||
| }, [resolvedBaseUrl, objectName, viewId, onShare]); | ||
|
|
||
| const handleCopy = React.useCallback(async () => { | ||
| if (!shareUrl) return; | ||
| try { | ||
| await navigator.clipboard.writeText(shareUrl); | ||
| setCopied(true); | ||
| setTimeout(() => setCopied(false), 2000); | ||
| } catch { | ||
| // Fallback for environments without clipboard API | ||
| const textarea = document.createElement('textarea'); | ||
| textarea.value = shareUrl; | ||
| document.body.appendChild(textarea); | ||
| textarea.select(); | ||
| document.execCommand('copy'); | ||
| document.body.removeChild(textarea); | ||
| setCopied(true); | ||
| setTimeout(() => setCopied(false), 2000); | ||
| } | ||
| }, [shareUrl]); | ||
|
|
||
| return ( | ||
| <Popover open={open} onOpenChange={setOpen}> | ||
| <PopoverTrigger asChild> | ||
| <Button variant="outline" size="sm" className={cn('gap-2', className)}> | ||
| <Share2 className="h-4 w-4" /> | ||
| Share | ||
| </Button> | ||
| </PopoverTrigger> | ||
| <PopoverContent className="w-96 space-y-4" align="end"> | ||
| <div className="space-y-2"> | ||
| <div className="flex items-center justify-between"> | ||
| <h4 className="text-sm font-medium">Share View</h4> | ||
| <Badge variant="secondary" className="text-xs"> | ||
| Read-only | ||
| </Badge> | ||
| </div> | ||
| <p className="text-xs text-muted-foreground"> | ||
| Generate a public link to share this view. Recipients can view data without logging in. | ||
| </p> | ||
| </div> | ||
|
|
||
| {!shareUrl ? ( | ||
| <Button onClick={handleGenerateLink} className="w-full gap-2" size="sm"> | ||
| <Share2 className="h-4 w-4" /> | ||
| Generate Link | ||
| </Button> | ||
| ) : ( | ||
| <div className="flex items-center gap-2"> | ||
| <Input | ||
| value={shareUrl} | ||
| readOnly | ||
| className="h-8 text-xs" | ||
| onClick={(e) => (e.target as HTMLInputElement).select()} | ||
| /> | ||
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={handleCopy} | ||
| className="shrink-0 gap-1" | ||
| > | ||
| {copied ? ( | ||
| <Check className="h-4 w-4 text-green-500" /> | ||
| ) : ( | ||
| <Copy className="h-4 w-4" /> | ||
| )} | ||
| </Button> | ||
| </div> | ||
| )} | ||
| </PopoverContent> | ||
| </Popover> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
SharedViewLink is registered as a new plugin component but there are no tests for link generation/copy behavior or for the SSR-safe baseUrl resolution. packages/plugin-view/src/__tests__ already covers similar UI components; adding tests here would help catch regressions (e.g., correct URL shape, copy state toggling).
|
|
||
| export interface ActionConfig { | ||
| type: 'send_email' | 'update_field' | 'create_record' | 'delete_record' | 'webhook' | 'notification'; | ||
| params: Record<string, any>; |
There was a problem hiding this comment.
ActionConfig.params is typed as Record<string, any>, which drops type-safety for action parameters and makes it easy to mis-handle values (especially since these automations are meant to be persisted/serialized). Prefer Record<string, unknown> at minimum, or a discriminated union that types params based on ActionConfig['type'].
| params: Record<string, any>; | |
| params: Record<string, unknown>; |
| ## 5. UI Feature Roadmap | ||
|
|
||
| > **Re-prioritized (Feb 16, 2026):** Features reorganized by progressive maturity stages (L1/L2/L3). Phases 10-18 use "shallow to deep" (由浅入深) approach for incremental development. | ||
| > **Re-prioritized (Feb 16, 2026):** Features reorganized by progressive maturity stages (L1/L2/L3). Phases 10-18 use "shallow to deep" (由浅入深) approach for incremental development. **All L1 development complete as of July 2025.** |
There was a problem hiding this comment.
This line contains non-English text (由浅入深). The repository’s docs are intended to be English-only for consistency and accessibility; please translate/remove the non-English phrase so the roadmap remains fully English.
| > **Re-prioritized (Feb 16, 2026):** Features reorganized by progressive maturity stages (L1/L2/L3). Phases 10-18 use "shallow to deep" (由浅入深) approach for incremental development. **All L1 development complete as of July 2025.** | |
| > **Re-prioritized (Feb 16, 2026):** Features reorganized by progressive maturity stages (L1/L2/L3). Phases 10-18 use a "shallow to deep" approach for incremental development. **All L1 development complete as of July 2025.** |
| for (let i = 0; i < rows.length; i++) { | ||
| const { record, errors: rowErrors } = validateRow(rows[i], mappedCols, i + 1); | ||
| if (rowErrors.length > 0) { | ||
| skippedRows++; | ||
| errors.push(...rowErrors); | ||
| } else { | ||
| try { if (dataSource?.create) await dataSource.create(objectName, record); importedRows++; } | ||
| catch (err) { | ||
| skippedRows++; | ||
| const msg = err instanceof Error ? err.message : 'Failed to create record'; | ||
| errors.push({ row: i + 1, field: '', message: msg }); | ||
| } | ||
| } | ||
| setProgress(Math.round(((i + 1) / rows.length) * 100)); | ||
| } |
There was a problem hiding this comment.
Progress is updated via setProgress(...) on every row iteration. For large files this will trigger thousands of React re-renders and can freeze the UI. Consider throttling progress updates (e.g., every N rows / on animation frame) or delegating progress reporting to a bulk API that reports aggregated progress.
Implements all remaining L1 foundation features across Phases 0, 2.9, 15–18 of the console roadmap and updates ROADMAP_CONSOLE.md to reflect current status.
Phase 0: Type safety
as anycasts inobjectstack.shared.tsvia typedresolveDefault<ObjectStackDefinition>()helper andParameters<typeof defineStack>[0]Phase 2.9: CRUD dialog ActionRunner migration
onSuccess/onCancelin App.tsx now dispatch through ActionRunner via registeredcrud_successanddialog_cancelhandlers instead of inline callbacksPhase 15 L1: Import/Export & Data Portability
plugin-grid/src/ImportWizard.tsx) — 3-step CSV import: file upload → column mapping (auto-match by name) → preview & validate → bulk createplugin-view/src/SharedViewLink.tsx) — generate shareable read-only view URLs with token, copy-to-clipboardPhase 16 L1: Global Undo/Redo
core/src/actions/UndoManager.ts) — bounded undo/redo stacks with subscriber pattern, exported asglobalUndoManagersingletonreact/src/hooks/useGlobalUndo.ts) — React hook usinguseSyncExternalStore, wiresCtrl+Z/Ctrl+Shift+Z, delegates create↔delete / update↔update reversal to dataSourcePhase 17 L1: Collaboration integration
@object-ui/collaborationintegrated intoRecordDetailViewwith auth-derived current userconsole/src/components/ActivityFeed.tsx) — Sheet-based sidebar panel with type-specific icons, relative timestamps, badge countPhase 18 L1: Automation & Workflows
plugin-workflow/src/AutomationBuilder.tsx) — trigger selection (record events + scheduled), multi-action configuration, save/cancel flowplugin-workflow/src/AutomationRunHistory.tsx) — status-badged execution list with duration and error displayRoadmap
ROADMAP_CONSOLE.mdupdated: all phases marked ✅ L1 Complete, execution timeline, milestone table, and success metrics reflect current stateOriginal prompt
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.