-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathObjectView.tsx
More file actions
1876 lines (1800 loc) · 94.2 KB
/
Copy pathObjectView.tsx
File metadata and controls
1876 lines (1800 loc) · 94.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Console ObjectView
*
* Thin wrapper around the plugin-view ObjectView that adds:
* - Multi-view resolution from objectDef.list_views
* - MetadataInspector toggle
* - Drawer for record detail preview
* - useObjectActions for toolbar create button
* - ListView delegation for non-grid view types (kanban, calendar, chart, etc.)
*/
import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense, type ComponentType } from 'react';
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState';
const ObjectChart = lazy(() =>
import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })),
);
const ImportWizard = lazy(() =>
import('@object-ui/plugin-grid').then((m) => ({ default: m.ImportWizard })),
);
import { ListView } from '@object-ui/plugin-list';
import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view';
import type { ViewTabItem } from '@object-ui/plugin-view';
// Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx
// uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
// Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here.
import {
Button,
Empty,
EmptyTitle,
EmptyDescription,
NavigationOverlay,
} from '@object-ui/components';
import { Plus, Upload, Star, StarOff, Table as TableIcon, KanbanSquare, Calendar, LayoutGrid, Activity, GanttChart, MapPin, BarChart3 } from 'lucide-react';
import { useFavorites } from '../hooks/useFavorites';
import { getIcon } from '../utils/getIcon';
import type { ListViewSchema, ViewNavigationConfig, FeedItem } from '@object-ui/types';
import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
import { ViewConfigPanel } from './ViewConfigPanel';
import { useMetadataClient } from './metadata-admin/useMetadata';
import { persistRuntimeMetadata, createRuntimeMetadata } from './runtime-metadata-persistence';
import { CreateViewDialog } from './CreateViewDialog';
import { PageHeader } from '../layout/PageHeader';
import { useMobileViewSwitcherRegistration } from '../layout/MobileViewSwitcherContext';
import type { MobileViewSwitcherItem } from '../layout/MobileViewSwitcherContext';
import { ManagedByBadge } from '../components/ManagedByBadge';
import { RecordDetailView } from './RecordDetailView';
import { resolveCrudAffordances } from '../utils/crudAffordances';
import { resolveManagedByEmptyState } from '../utils/managedByEmptyState';
import { useObjectActions } from '../hooks/useObjectActions';
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
import { usePermissions } from '@object-ui/permissions';
import { useAuth, useIsWorkspaceAdmin } from '@object-ui/auth';
import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration';
import { ActionProvider, useNavigationOverlay, SchemaRenderer } from '@object-ui/react';
import { toast } from 'sonner';
import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime';
import { useEnvironmentEntitlements } from '../environment/useEnvironmentEntitlements';
import { EnvironmentListToolbar } from '../environment/EnvironmentListToolbar';
/** Map view types to Lucide icons (Airtable-style) */
const VIEW_TYPE_ICONS: Record<string, ComponentType<{ className?: string }>> = {
grid: TableIcon,
kanban: KanbanSquare,
calendar: Calendar,
gallery: LayoutGrid,
timeline: Activity,
gantt: GanttChart,
map: MapPin,
chart: BarChart3,
};
const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
/**
* Replace built-in tokens (e.g. `{current_user_id}`) inside a filter array
* with concrete values. Filters from platform-shipped `listViews` or saved
* `sys_view` rows may declare context-sensitive predicates like
* `{ field: 'submitter_id', operator: 'equals', value: '{current_user_id}' }`
* — those need to be substituted before the query reaches the API.
*
* Recognised tokens:
* • `{current_user_id}` → the authenticated user's id
*
* Returns a deep-cloned copy with substitutions applied. Non-array input
* is returned unchanged.
*/
function substituteFilterTokens(filter: any, currentUserId: string | undefined): any {
if (!Array.isArray(filter)) return filter;
const sub = (v: any): any => {
if (typeof v === 'string') {
if (v === '{current_user_id}') return currentUserId ?? v;
return v;
}
if (Array.isArray(v)) return v.map(sub);
if (v && typeof v === 'object') {
const out: any = {};
for (const k of Object.keys(v)) out[k] = sub(v[k]);
return out;
}
return v;
};
return filter.map(sub);
}
export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }: any) {
const { objectName } = useParams();
const { t } = useObjectTranslation();
// Resolve the object definition up front. When it's missing we render the
// "object not found" empty state *here*, before the inner component mounts.
// This is the key to keeping the Rules of Hooks satisfied: ObjectViewInner
// holds ~50 hooks and must call them unconditionally, so the missing-object
// branch lives in this thin wrapper instead of as a mid-component early
// return. The inner subtree then mounts/unmounts as a whole (object exists
// ↔ doesn't) rather than toggling the number of hooks executed per render.
const objectDef = objects.find((o: any) => o.name === objectName);
if (!objectDef) {
return (
<div className="h-full p-4 flex items-center justify-center">
<Empty>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<TableIcon className="h-6 w-6 text-muted-foreground" />
</div>
<EmptyTitle>{t('console.objectView.objectNotFound')}</EmptyTitle>
<EmptyDescription>
{t('console.objectView.objectNotFoundDescription', { objectName })}
{' '}
{t('console.objectView.objectNotFoundHint')}
</EmptyDescription>
</Empty>
</div>
);
}
return (
<ObjectViewInner
dataSource={dataSource}
objects={objects}
onEdit={onEdit}
externalRefreshKey={externalRefreshKey}
/>
);
}
/**
* Inner ObjectView body. Only mounted by {@link ObjectView} once the object
* definition is known to exist, so every hook below runs unconditionally on
* every render of this component — no early return sits between hook calls.
*/
function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: any) {
const navigate = useNavigate();
const { appName, objectName, viewId } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const { showDebug } = useMetadataInspector();
const { t } = useObjectTranslation();
const { objectLabel, objectDescription: objectDesc, viewLabel, viewEmptyState, actionLabel, actionConfirm, actionSuccess, actionParamText, fieldLabel, fieldOptionLabel } = useObjectLabel();
const { isFavorite, toggleFavorite } = useFavorites();
// ADR-0034: runtime view edits persist via the metadata draft/publish
// model (the `sys_view` table is retired).
const metadataClient = useMetadataClient();
// Inline view config panel state (Airtable-style right sidebar)
const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
const [viewConfigPanelMode, setViewConfigPanelMode] = useState<'create' | 'edit'>('edit');
// Airtable-style "Create view" dialog (type picker + name input)
const [showCreateViewDialog, setShowCreateViewDialog] = useState(false);
// Manage Views dialog (vertical sortable list of all views)
const [manageViewsOpen, setManageViewsOpen] = useState(false);
// Draft state for view config edits — cached locally, saved on demand
const [viewDraft, setViewDraft] = useState<Record<string, any> | null>(null);
// Per-view debounce timers + latest pending patch payloads. Keyed by
// viewId so toggles on different views don't clobber each other. We
// merge incoming patches into a single payload so rapid successive
// toggles (e.g. resize-drag emitting 60 events/sec) collapse into one
// network write.
const persistTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const persistPending = useRef<Record<string, Record<string, any>>>({});
const persistViewPatch = useCallback(
(viewIdLocal: string, baseViewDef: Record<string, any>, patch: Record<string, any>) => {
if (!dataSource?.updateViewConfig || !objectName || !viewIdLocal) return;
// Merge into pending payload — every key present is the latest
// value the user intended.
const prev = persistPending.current[viewIdLocal] || {};
persistPending.current[viewIdLocal] = { ...prev, ...patch };
const existing = persistTimers.current[viewIdLocal];
if (existing) clearTimeout(existing);
persistTimers.current[viewIdLocal] = setTimeout(() => {
const merged = persistPending.current[viewIdLocal] || {};
delete persistPending.current[viewIdLocal];
delete persistTimers.current[viewIdLocal];
Promise.resolve(
dataSource.updateViewConfig(objectName, viewIdLocal, {
...baseViewDef,
...merged,
})
).catch((err: any) => {
console.error('[ObjectView] Failed to persist view config:', err);
});
}, 300);
},
[dataSource, objectName]
);
const handleViewConfigSave = useCallback((draft: Record<string, any>) => {
setViewDraft(draft);
setRefreshKey(k => k + 1);
// ADR-0034: stage a per-item draft via the metadata seam; an explicit
// Publish (RuntimeDraftBar) promotes it + records a version.
const vid = draft.id;
if (metadataClient && vid) {
persistRuntimeMetadata('view', vid, draft, { metadataClient }).catch((err: any) => {
console.error('[ViewConfigPanel] Failed to persist view config:', err);
});
} else {
console.warn('[ViewConfigPanel] Cannot persist view config: missing metadataClient or viewId.');
}
}, [metadataClient]);
/** Create a new view via the config panel */
const handleViewCreate = useCallback(async (config: Record<string, any>) => {
try {
let createdId: string | undefined;
if (metadataClient) {
// Prefill sensible defaults so the saved view renders rows
// immediately even if the user didn't pick columns yet.
const objectDef = objects?.find?.((o: any) => o.name === objectName);
const SYSTEM_FIELDS = new Set([
'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
'deleted_at', 'deletedAt', 'created_by', 'createdBy',
'updated_by', 'updatedBy', '_version', '_rev',
]);
let defaultColumns: string[] = [];
const curated = Array.isArray(objectDef?.highlightFields) && objectDef.highlightFields.length > 0
? objectDef.highlightFields
: objectDef?.compactLayout;
if (Array.isArray(curated) && curated.length > 0) {
defaultColumns = curated.filter((n: string) => objectDef.fields?.[n]);
} else if (objectDef?.fields) {
defaultColumns = Object.entries(objectDef.fields)
.filter(([name, f]: [string, any]) => f && !f.hidden && !SYSTEM_FIELDS.has(name))
.map(([name]) => name)
.slice(0, 5);
}
const incomingColumns = Array.isArray(config.columns) && config.columns.length > 0
? config.columns
: defaultColumns;
// ADR-0005 overlay path — write the full spec under a unique
// `name` via the metadata customization API instead of into
// the physical `sys_view` table (whose columns no longer
// accommodate the spec shape: arrays, nested objects, etc.).
const spec: Record<string, any> = { ...config, columns: incomingColumns };
// Per @objectstack/spec, certain view types nest their card/field
// list inside their type-specific subconfig (e.g. kanban.columns,
// gallery.visibleFields). The CreateViewDialog only collects
// required *picker* fields; we mirror the resolved column list
// into the subconfig here so the spec validator accepts the row.
if (config.type === 'kanban') {
spec.kanban = { ...(spec.kanban || {}), columns: incomingColumns };
} else if (config.type === 'gallery') {
const existing = spec.gallery || {};
if (!Array.isArray(existing.visibleFields) || existing.visibleFields.length === 0) {
spec.gallery = { ...existing, visibleFields: incomingColumns };
}
}
// ADR-0034: a new view is created as an invisible per-item
// draft via the metadata seam; an explicit Publish promotes it.
// UI-layer concerns (default columns, kanban/gallery massaging
// above, and the auto-activation below) stay here.
const draftName = String(
(config as any)?.name ?? (config as any)?.id ?? (spec as any)?.id ?? '',
);
createdId = await createRuntimeMetadata('view', draftName, spec, {
metadataClient,
});
}
setShowViewConfigPanel(false);
setViewConfigPanelMode('edit');
setRefreshKey(k => k + 1);
// Auto-activate the newly created view (Airtable parity).
// Routing falls back to the default view if `createdId` doesn't
// resolve yet — re-render after refresh will pick it up.
if (createdId) {
if (viewId) {
navigate(`../${createdId}`, { relative: 'path' });
} else {
navigate(`view/${createdId}`);
}
}
} catch (err) {
console.error('[ViewConfigPanel] Failed to create view:', err);
}
}, [dataSource, objectName, objects, navigate, viewId, metadataClient]);
// Record count tracking for footer
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
// Admin users automatically get design tools (no toggle needed)
const { user, activeOrganization } = useAuth();
const isAdmin = useIsWorkspaceAdmin();
const { can } = usePermissions();
// Get Object Definition. The outer ObjectView wrapper already guards the
// missing-object case, so this always resolves while this component is
// mounted — every hook below can therefore run unconditionally.
const objectDef = objects.find((o: any) => o.name === objectName);
// Refresh trigger — bumped after view CRUD or external data mutations.
const [refreshKey, setRefreshKey] = useState(0);
// Shared console action runtime: confirm/param/result dialogs, the
// authenticated api/flow/server-action handlers, SPA navigation, and the
// paused screen-flow runner. The same runtime PageView mounts (#1605).
// ObjectView additionally feeds its confirm/toast handlers into
// useObjectActions below, so it consumes the hook directly (rather than the
// ConsoleActionRuntimeProvider wrapper).
const actionRuntime = useConsoleActionRuntime({
dataSource,
objects,
objectName: objectDef.name,
onRefresh: () => setRefreshKey((k) => k + 1),
});
const { confirmHandler, toastHandler } = actionRuntime;
// Environment list (sys_environment) is entitlement- + state-aware: born
// with one production env per org, its single `create_environment` action
// means different things — "Set up your production environment", "Add
// development environment", or an upgrade prompt — depending on org state.
// Resolved org-side here; the hook is a no-op for every other object.
const isEnvironmentList = objectDef.name === 'sys_environment';
const environmentEntitlements = useEnvironmentEntitlements({
enabled: isEnvironmentList,
dataSource,
authFetch: actionRuntime.authFetch,
apiBase: (import.meta as any).env?.VITE_SERVER_URL || '',
refreshKey,
});
// Localized `list_toolbar` actions, shared by the generic action bar and the
// environment-aware toolbar (the action:bar renderer filters by location).
const localizedToolbarActions = useMemo(
() => (objectDef.actions || []).map((a: any) => ({
...a,
label: actionLabel(objectDef.name, a.name, a.label || a.name),
...(a.confirmText !== undefined && { confirmText: actionConfirm(objectDef.name, a.name, a.confirmText) }),
...(a.successMessage !== undefined && { successMessage: actionSuccess(objectDef.name, a.name, a.successMessage) }),
})),
[objectDef, actionLabel, actionConfirm, actionSuccess],
);
// Resolve which generic CRUD affordances belong in the toolbar for
// this object's lifecycle bucket (`managedBy`). config tables show
// New/Edit/Delete but no CSV Import; system / append-only / better-auth
// hide the lot — those flows go through purpose-built actions on the
// source record (e.g. "Submit for Approval" on an Opportunity creates
// an `sys_approval_request`). Permissions still gate the buttons.
const affordances = useMemo(
() => resolveCrudAffordances(objectDef as any),
[objectDef],
);
// Propagate externally-triggered refreshes (e.g. global ModalForm submit)
// into our internal refreshKey so list/data effects re-run.
useEffect(() => {
if (externalRefreshKey === undefined || externalRefreshKey === 0) return;
setRefreshKey(k => k + 1);
}, [externalRefreshKey]);
// Import wizard open/close state — toolbar entry triggers it.
const [showImport, setShowImport] = useState(false);
// ─── User-defined views (metadata overlay) ──────────────────────────
// Saved views created via the ViewConfigPanel ("Add View") live in the
// metadata overlay (`/meta/view`). We fetch them via `listViews` and merge
// into `views` so the ViewTabBar renders them alongside metadata-defined
// listViews.
const [savedViews, setSavedViews] = useState<any[]>([]);
useEffect(() => {
let cancelled = false;
if (!objectName) {
setSavedViews([]);
return;
}
// Read saved views from the metadata overlay (`/meta/view`) via the
// adapter's `listViews`. Adapters without it surface no saved views.
if (typeof (dataSource as any)?.listViews === 'function') {
(dataSource as any).listViews(objectName)
.then((rows: any[]) => {
if (cancelled) return;
// Normalize: ensure each view has an `id` for ViewTabBar
// (which is name-keyed downstream). Stamp `objectName`
// so the defensive filter in handlers still works.
const normalized = (rows || []).map((sv: any) => ({
...sv,
// Overlay rows are keyed by `name`. Prefer that as the
// tab id so a duplicate's `id` field (which may have
// been copied verbatim from the source artifact) does
// not collide with the source's view id during dedup.
id: sv.name || sv.id,
objectName: sv.objectName || sv.object || objectName,
}));
setSavedViews(normalized);
})
.catch((err: any) => {
console.error('[ObjectView] Failed to load overlay views:', err);
if (!cancelled) setSavedViews([]);
});
return () => { cancelled = true; };
}
// No overlay API available (e.g. a minimal adapter / test mock) → no
// saved views. The retired `sys_view` table is no longer read.
setSavedViews([]);
return () => { cancelled = true; };
}, [dataSource, objectName, refreshKey]);
// Persisted per-view config overrides (e.g. density toggle). Saved
// separately from `objectDef.listViews` (the embedded definition) via
// `dataSource.updateViewConfig` and read back here so toggle preferences
// survive a hard reload. Keyed by viewId → partial view config to merge.
//
// Use the batch listViewOverrides() when available — fires one HTTP
// GET per object instead of N (one per defined view), avoiding a flurry
// of 404s for objects whose views have never been customized. Falls
// back to per-view getView() for adapters that don't support the batch
// method.
const [viewOverrides, setViewOverrides] = useState<Record<string, any>>({});
useEffect(() => {
let cancelled = false;
if (!dataSource || !objectName) {
setViewOverrides({});
return;
}
const definedViews = (objectDef.listViews || objectDef.list_views || {}) as Record<string, any>;
const ids = Object.keys(definedViews);
// Include the primary view id so overrides apply to it too.
const primary = (objectDef as any).list;
if (primary && typeof primary === 'object') {
const primaryId = primary.name || 'list';
if (!ids.includes(primaryId)) ids.unshift(primaryId);
}
if (ids.length === 0) {
setViewOverrides({});
return;
}
const loadBatch = async (): Promise<Record<string, any>> => {
if (typeof (dataSource as any).listViewOverrides === 'function') {
try {
const all = await (dataSource as any).listViewOverrides(objectName);
if (all && typeof all === 'object') {
const map: Record<string, any> = {};
for (const id of ids) {
if (all[id]) map[id] = all[id];
}
return map;
}
} catch {
// fall through to per-view fetch
}
}
if (typeof dataSource.getView !== 'function') return {};
const entries = await Promise.all(
ids.map(async (id) => {
try {
const v = await dataSource.getView!(objectName, id);
return [id, v] as const;
} catch {
return [id, null] as const;
}
})
);
const map: Record<string, any> = {};
for (const [id, v] of entries) {
if (v && typeof v === 'object') map[id] = v;
}
return map;
};
loadBatch().then((map) => {
if (!cancelled) setViewOverrides(map);
});
return () => { cancelled = true; };
}, [dataSource, objectName, objectDef.listViews, objectDef.list_views, (objectDef as any).list, refreshKey]);
// Resolve Views from objectDef.listViews (camelCase per @objectstack/spec)
const views = useMemo(() => {
// Default column resolution priority:
// 1. The `highlightFields` semantic role (ADR-0085; deprecated
// `compactLayout` read as fallback).
// 2. Business fields only — exclude system-managed identifiers/audit
// columns (id, created_at, updated_at, …) and fields explicitly
// marked hidden/readonly on the schema. First 5 kept for compactness.
const SYSTEM_FIELDS = new Set([
'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
'deleted_at', 'deletedAt', 'created_by', 'createdBy',
'updated_by', 'updatedBy', '_version', '_rev',
]);
const resolveDefaultColumns = (): string[] => {
const curated = Array.isArray(objectDef.highlightFields) && objectDef.highlightFields.length > 0
? objectDef.highlightFields
: objectDef.compactLayout;
if (Array.isArray(curated) && curated.length > 0) {
return curated.filter((n: string) => objectDef.fields?.[n]);
}
if (objectDef.fields) {
return Object.entries(objectDef.fields)
.filter(([name, f]: [string, any]) => {
if (!f) return false;
if (f.hidden) return false;
if (SYSTEM_FIELDS.has(name)) return false;
return true;
})
.map(([name]) => name)
.slice(0, 5);
}
return [];
};
const definedViews = objectDef.listViews || objectDef.list_views || {};
const viewList = Object.entries(definedViews).map(([key, value]: [string, any]) => {
const override = viewOverrides[key];
// Override wins per-key — saved overrides represent user
// preferences (density, column widths, etc.) that should
// shadow the embedded definition.
return {
id: key,
...value,
...(override || {}),
type: (override?.type) || value.type || 'grid',
};
});
// Honor `objectDef.list` (the primary list view, per @objectstack/spec
// ViewSchema). MetadataProvider mirrors it into `listViews` so it's
// already in `viewList` above; promote it to the front and mark it as
// the default so `defaultViewId` picks it over secondary listViews.
const primary = (objectDef as any).list;
if (primary && typeof primary === 'object') {
const primaryId = primary.name || 'list';
const idx = viewList.findIndex(v => v.id === primaryId);
if (idx >= 0) {
const [entry] = viewList.splice(idx, 1);
viewList.unshift({ ...entry, isDefault: true });
} else {
const override = viewOverrides[primaryId];
viewList.unshift({
id: primaryId,
...primary,
...(override || {}),
type: (override?.type) || primary.type || 'grid',
isDefault: true,
});
}
}
if (viewList.length === 0) {
viewList.push({
id: 'all',
label: t('console.objectView.allRecords'),
type: 'grid',
columns: resolveDefaultColumns(),
});
}
// Merge user-defined views (sys_view) after metadata-defined views.
// Dedup by id so a saved view that shadows a metadata view wins.
const metaIds = new Set(viewList.map(v => v.id));
for (const sv of savedViews) {
const id = sv.id || sv._id;
if (!id) continue;
// Drop undefined fields so a partial overlay (e.g. baseline row
// with no user customization) does not stomp `isDefault`/`columns`
// populated from the metadata view it shadows.
const rawNormalized: Record<string, any> = {
label: sv.label || sv.name || id,
type: sv.type || 'grid',
columns: sv.columns,
filter: sv.filter,
sort: sv.sort,
showSearch: sv.showSearch,
showFilters: sv.showFilters,
showSort: sv.showSort,
isPinned: sv.isPinned,
isDefault: sv.isDefault,
visibility: sv.visibility,
sortOrder: sv.sortOrder,
...sv,
id,
};
const normalized: Record<string, any> = {};
for (const [k, v] of Object.entries(rawNormalized)) {
if (v !== undefined) normalized[k] = v;
}
if (metaIds.has(id)) {
const idx = viewList.findIndex(v => v.id === id);
viewList[idx] = { ...viewList[idx], ...normalized };
} else {
viewList.push(normalized);
}
}
// Apply default columns to any grid-like view that has no explicit
// columns (e.g. saved views created via "Add View" before the user
// configured fields). Without this, the grid renders an empty header
// row and the data fetch omits a `select` clause.
const GRID_LIKE = new Set(['grid', 'list', 'table']);
for (const v of viewList) {
if (!GRID_LIKE.has(v.type)) continue;
if (!Array.isArray(v.columns) || v.columns.length === 0) {
v.columns = resolveDefaultColumns();
}
}
// Stable sort: respect a per-user view-order preference (for both
// metadata and saved views). Falls back to: metadata views first
// in declared order, then saved views by `sortOrder` / created_at.
const indexOf = new Map(viewList.map((v, i) => [v.id, i]));
let userOrder: string[] = [];
try {
const raw = typeof window !== 'undefined'
? window.localStorage?.getItem(`viewOrder:${objectDef.name}`)
: null;
if (raw) userOrder = JSON.parse(raw);
} catch { /* ignore */ }
const userOrderIndex = new Map(userOrder.map((id, i) => [id, i]));
viewList.sort((a, b) => {
const aUser = userOrderIndex.get(a.id);
const bUser = userOrderIndex.get(b.id);
if (aUser !== undefined && bUser !== undefined) return aUser - bUser;
if (aUser !== undefined) return -1;
if (bUser !== undefined) return 1;
const aSaved = savedViews.find((sv: any) => (sv.id || sv._id) === a.id);
const bSaved = savedViews.find((sv: any) => (sv.id || sv._id) === b.id);
const aHasOrder = aSaved && typeof aSaved.sortOrder === 'number';
const bHasOrder = bSaved && typeof bSaved.sortOrder === 'number';
// Only an explicit user `sortOrder` should reorder views away from
// the metadata-declared sequence. A bare overlay row (no sortOrder)
// must not demote a metadata view: that would break primary-view
// promotion (which relies on declared order) for objects whose
// overlay seeded a baseline `sys_view` row without sort info.
if (aHasOrder && bHasOrder) {
if (aSaved.sortOrder !== bSaved.sortOrder) {
return aSaved.sortOrder - bSaved.sortOrder;
}
return (aSaved.created_at || '').localeCompare(bSaved.created_at || '');
}
if (aHasOrder) return -1;
if (bHasOrder) return 1;
return (indexOf.get(a.id) ?? 0) - (indexOf.get(b.id) ?? 0);
});
return viewList;
}, [objectDef, savedViews, viewOverrides, t]);
// Active View State — merge saved draft if available for this view.
// Resolution priority: URL viewId → ?view= → user-marked default → first.
const defaultViewId = useMemo(() => {
const def = views.find((v: any) => v.isDefault);
return def?.id;
}, [views]);
const activeViewId = viewId || searchParams.get('view') || defaultViewId || views[0]?.id;
const baseView = views.find((v: any) => v.id === activeViewId) || views[0];
const activeView = viewDraft && viewDraft.id === baseView?.id
? { ...baseView, ...viewDraft }
: baseView;
/** Real-time draft field update — propagates each toggle/input change immediately */
const handleViewUpdate = useCallback((field: string, value: any) => {
setViewDraft(prev => ({
...(prev || {}),
id: baseView?.id,
[field]: value,
}));
}, [baseView?.id]);
const handleViewChange = (newViewId: string) => {
// The plugin ObjectView returns the view ID directly via onViewChange
const matchedView = views.find((v: any) => v.id === newViewId);
if (!matchedView) return;
// Auto-close the config panel only when actually switching to a
// different view. Same-view clicks (e.g., bubbling from the actions
// dropdown menu item) must not stomp on a freshly-opened panel.
if (matchedView.id !== activeViewId) {
setShowViewConfigPanel(false);
}
if (viewId) {
navigate(`../${matchedView.id}`, { relative: "path" });
} else {
navigate(`view/${matchedView.id}`);
}
};
// Mobile view switcher — registers our view list with the AppHeader so
// the topbar can render a `<viewName> ▾` dropdown instead of the static
// page label. Desktop ignores this (ViewTabBar handles switching there).
const mobileViewSwitcherItems = useMemo<MobileViewSwitcherItem[]>(() => {
return (views || []).map((view: any) => {
const Icon = VIEW_TYPE_ICONS[view.type as keyof typeof VIEW_TYPE_ICONS];
return {
id: view.id,
label: viewLabel(objectDef.name, view.name || view.id, view.label || view.name || view.id),
icon: Icon ? <Icon className="h-4 w-4" /> : undefined,
};
});
}, [views, objectDef.name, viewLabel]);
useMobileViewSwitcherRegistration({
views: mobileViewSwitcherItems,
activeViewId: activeViewId ?? '',
onChange: handleViewChange,
enabled: mobileViewSwitcherItems.length > 0 && !!activeViewId,
});
// ViewSwitcher callbacks — wired to both PluginObjectView instances
const handleCreateView = useCallback(() => {
setShowCreateViewDialog(true);
}, []);
const handleViewAction = useCallback((actionType: string, viewType: string) => {
if (actionType === 'settings') {
const matchedView = views.find((v: { id: string; type: string }) => v.type === viewType);
if (matchedView) handleViewChange(matchedView.id);
setViewConfigPanelMode('edit');
setShowViewConfigPanel(true);
}
}, [views, handleViewChange]);
// ─── ViewTabBar CRUD callbacks (Phase 2) ────────────────────────────
/** Returns true if the view is backed by a sys_view record (mutable). */
const isSavedView = useCallback((vid: string) => {
return savedViews.some((sv: any) => (sv.id || sv._id) === vid);
}, [savedViews]);
const handleRenameView = useCallback(async (vid: string, newName: string) => {
if (!isSavedView(vid)) {
toast.error(t('console.objectView.cannotEditMetaView') || 'Built-in views cannot be renamed.');
return;
}
try {
// Metadata overlay path — `vid` is the view's `name` field.
if (typeof (dataSource as any)?.updateView === 'function') {
await (dataSource as any).updateView(objectName, vid, { label: newName });
}
setRefreshKey(k => k + 1);
} catch (err) {
console.error('[ViewTabBar] Failed to rename view:', err);
toast.error(t('objectViewActions.renameFailed'));
}
}, [dataSource, objectName, isSavedView, t]);
const handleDeleteView = useCallback(async (vid: string) => {
if (!dataSource) return;
if (!isSavedView(vid)) {
toast.error(t('console.objectView.cannotDeleteMetaView') || 'Built-in views cannot be deleted.');
return;
}
const targetView = views.find((v: any) => v.id === vid);
const viewLabel = targetView?.label || vid;
const confirmed = await confirmHandler(
t('console.objectView.deleteViewConfirm', { name: viewLabel }) ||
`Are you sure you want to delete the view "${viewLabel}"? This cannot be undone.`,
{
title: t('console.objectView.deleteViewTitle') || 'Delete view',
confirmText: t('console.objectView.delete') || 'Delete',
cancelText: t('console.objectView.cancel') || 'Cancel',
},
);
if (!confirmed) return;
try {
if (typeof (dataSource as any)?.deleteView === 'function') {
await (dataSource as any).deleteView(objectName, vid);
}
// If we deleted the active view, fall back to the first remaining view.
if (vid === activeViewId) {
const fallback = views.find((v: any) => v.id !== vid);
if (fallback) navigate(viewId ? `../${fallback.id}` : `view/${fallback.id}`, viewId ? { relative: 'path' } : undefined);
}
setRefreshKey(k => k + 1);
} catch (err) {
console.error('[ViewTabBar] Failed to delete view:', err);
toast.error(t('objectViewActions.deleteFailed'));
}
}, [dataSource, isSavedView, activeViewId, views, viewId, navigate, t, confirmHandler]);
const handlePinView = useCallback(async (vid: string, pinned: boolean) => {
if (!dataSource) return;
if (!isSavedView(vid)) {
toast.error(t('console.objectView.cannotEditMetaView') || 'Built-in views cannot be pinned.');
return;
}
try {
if (typeof (dataSource as any)?.updateView === 'function') {
await (dataSource as any).updateView(objectName, vid, { isPinned: pinned });
}
setRefreshKey(k => k + 1);
} catch (err) {
console.error('[ViewTabBar] Failed to pin view:', err);
}
}, [dataSource, objectName, isSavedView, t]);
const handleSetDefaultView = useCallback(async (vid: string) => {
if (!dataSource) return;
if (!isSavedView(vid)) {
toast.error(
t('console.objectView.cannotEditMetaView')
|| 'System view — it cannot be set as a default.',
);
return;
}
try {
// Clear `isDefault` on all other saved views, then set this one.
if (typeof (dataSource as any)?.updateView !== 'function') return;
const updateView = (dataSource as any).updateView;
const updates = savedViews
.filter((sv: any) => (sv.id || sv._id) !== vid && sv.isDefault)
.map((sv: any) => updateView(objectName, sv.id || sv._id, { isDefault: false }));
updates.push(updateView(objectName, vid, { isDefault: true }));
await Promise.all(updates);
setRefreshKey(k => k + 1);
} catch (err) {
console.error('[ViewTabBar] Failed to set default view:', err);
toast.error('Failed to set default view');
}
}, [dataSource, objectName, savedViews, isSavedView, t]);
const handleReorderViews = useCallback(async (orderedIds: string[]) => {
// Persist order for ALL views (incl. metadata) in localStorage so the
// UI immediately reflects the new ordering, including reorderings
// that involve metadata-only views.
try {
if (typeof window !== 'undefined') {
window.localStorage?.setItem(`viewOrder:${objectName}`, JSON.stringify(orderedIds));
}
} catch { /* ignore */ }
// Best-effort: also persist `sortOrder` on each saved view so other
// sessions / users can pick up the order from the backend.
if (typeof (dataSource as any)?.updateView === 'function') {
const updateView = (dataSource as any).updateView;
const savedIdSet = new Set(savedViews.map((sv: any) => sv.id || sv._id));
const updates = orderedIds
.filter(id => savedIdSet.has(id))
.map((id, idx) => updateView(objectName, id, { sortOrder: idx }));
try {
await Promise.all(updates);
} catch (err) {
console.error('[ViewTabBar] Failed to reorder views:', err);
}
}
setRefreshKey(k => k + 1);
}, [dataSource, savedViews, objectName]);
const handleConfigView = useCallback((vid: string) => {
// System (metadata-defined) views are read-only — opening the
// ViewConfigPanel against one would let the user save changes that
// never persist.
if (!isSavedView(vid)) {
toast.error(
t('console.objectView.cannotEditMetaView')
|| 'System view — it cannot be edited.',
);
return;
}
if (vid !== activeViewId) handleViewChange(vid);
setViewConfigPanelMode('edit');
setShowViewConfigPanel(true);
}, [activeViewId, handleViewChange, isSavedView, t]);
const handleAddView = useCallback(() => {
setShowCreateViewDialog(true);
}, []);
// Current user — also used below for `{current_user_id}` filter-token
// substitution. The schema-action handlers (toast/navigate/api/flow/server)
// now live in the shared `useConsoleActionRuntime` hook (declared earlier).
const currentUser = user
? { id: user.id, name: user.name, avatar: user.image }
: FALLBACK_USER;
// Action system for toolbar operations — refreshKey moved up (declared earlier).
// Wired to confirmHandler/toastHandler so deletes use the Shadcn AlertDialog
// and Sonner toast instead of native window.confirm.
const actions = useObjectActions({
objectName: objectDef.name,
objectLabel: objectDef.label,
dataSource,
onEdit,
onRefresh: () => setRefreshKey(k => k + 1),
onConfirm: confirmHandler,
// ToastHandler's `type` is a string-literal union — a subset of the
// looser `{ type?: string }` useObjectActions declares; cast is sound.
onToast: toastHandler as (message: string, options?: { type?: string }) => void,
});
// Real-time: auto-refresh when server reports data changes
const { lastMessage: realtimeMessage } = useRealtimeSubscription({
channel: `object:${objectDef.name}`,
});
// Conflict resolution: detect and queue conflicts on reconnection
const conflictUserId = objectDef.name ? `user-${objectDef.name}` : 'current-user';
const { hasConflicts, resolveAllConflicts } = useConflictResolution(conflictUserId);
useEffect(() => {
if (realtimeMessage) {
// On reconnection data change, auto-resolve with server-wins strategy
if (hasConflicts) {
resolveAllConflicts('remote');
}
setRefreshKey(k => k + 1);
}
}, [realtimeMessage, hasConflicts, resolveAllConflicts]);
// Fetch record count for footer display
useEffect(() => {
if (dataSource?.find && objectDef.name) {
dataSource.find(objectDef.name, { limit: 0 }).then((result: any) => {
if (typeof result?.total === 'number') {
setRecordCount(result.total);
} else if (Array.isArray(result?.data)) {
setRecordCount(result.data.length);
} else if (Array.isArray(result)) {
setRecordCount(result.length);
}
}).catch(() => {
// Silently ignore — record count is non-critical
});
}
}, [dataSource, objectDef.name, refreshKey]);
// Navigation overlay for record detail (supports drawer/modal/split/popover via config)
// Priority: activeView.navigation > objectDef.navigation > default drawer.
//
// Default mode = 'drawer'. Mirrors Linear / Notion / Airtable / Jira where
// record peek is the primary interaction and full-page is the upgrade.
// Direct URL access (`/record/:id`) still opens as a full page because
// RecordDetailView owns its own route — only same-page click navigation
// is drawer-by-default. Per-view config can still override (e.g. a heavy
// detail object can set `navigation.mode = 'page'`).
const detailNavigation: ViewNavigationConfig = useMemo(
() =>
activeView?.navigation ??
objectDef.navigation ?? { mode: 'drawer', width: 'min(92vw, 1280px)' },
[activeView?.navigation, objectDef.navigation]
);
const drawerRecordId = searchParams.get('recordId');
/**
* URL-derived equality filters in the form `?filter[<field>]=<value>`.
* Used by related-list "View All" buttons to scope the destination list
* to a single parent record. Emitted as ObjectQL triples (`[field, '=', value]`)
* which matches the shape consumed by the list view's data fetcher when
* merging base filters.
*/
// Dep on the serialized `filter[...]` entries only — `uf_*` user-filter
// params also live in the URL and must not invalidate this memo (a new
// array identity here rebuilds the whole list schema and refetches).
const filterParamsKey = Array.from(searchParams.entries())
.filter(([k]) => k.startsWith('filter['))
.map(([k, v]) => `${k}=${v}`)
.join('&');
const urlFilters = useMemo(() => {
const out: Array<[string, string, any]> = [];
new URLSearchParams(filterParamsKey).forEach((value, key) => {
const m = /^filter\[(.+)\]$/.exec(key);
if (m && m[1] && value !== '') {
out.push([m[1], '=', value]);
}
});
return out;
}, [filterParamsKey]);
/**
* End-user filter selections restored from `uf_*` URL params (ADR-0047
* persistence). Captured once per ObjectView mount — UserFilters only
* reads them at its own mount, and later URL writes must not churn the
* schema memo.
*/
const [initialUfSelections] = useState<Record<string, string[]> | undefined>(
() => parseUserFilterParams(new URLSearchParams(window.location.search)),
);
const handleUserFilterSelectionsChange = useCallback(
(selections: Record<string, Array<string | number | boolean>>) => {
setSearchParams(prev => applyUserFilterParams(prev, selections), { replace: true });
},
[setSearchParams],
);
// Memoize onNavigate to prevent stale closure in useNavigationOverlay's handleClick
const handleNavOverlayNavigate = useCallback(
(recordId: string | number, action?: string) => {
if (action === 'new_window') {
// Open record detail in a new browser tab with Console-correct URL
const basePath = window.location.pathname.replace(/\/view\/.*$/, '');
window.open(`${basePath}/record/${encodeURIComponent(String(recordId))}`, '_blank');
return;
}
// Default: navigate to record detail page.
// `action` may be 'view' / 'page' / undefined, OR a custom view name