-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathResourceEditPage.tsx
More file actions
2589 lines (2514 loc) · 109 KB
/
Copy pathResourceEditPage.tsx
File metadata and controls
2589 lines (2514 loc) · 109 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
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* MetadataResourceEditPage — generic AutoForm-driven editor (Phase 3c).
*
* What it does:
* 1. Fetches the layered view (`?layers=true`) so the user sees code
* vs overlay vs effective.
* 2. Renders a SchemaForm against the JSONSchema in the type's
* `/meta/types` registry row.
* 3. Save → PUT, with automatic destructive-change handling: a 409
* `destructive_change` response opens a confirmation dialog
* listing the issues, and on confirm we retry with `?force=true`.
* 4. Reset overlay → DELETE (overlay only).
* 5. References tab → calls `client.references()` and lists
* back-pointers so admins know what will break before deleting.
*
* Works for any of the 27 metadata types — bespoke editors (Object,
* Field, View, Permission Matrix) opt out by registering a custom
* EditPage via `registerMetadataResource()`.
*/
import * as React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
Save,
RotateCcw,
Trash2,
History,
Link2,
Loader2,
AlertTriangle,
Layers3,
GitCompareArrows,
Boxes,
Eye,
Pencil,
X,
PanelRightClose,
PanelRightOpen,
Maximize2,
Minimize2,
MousePointer2,
SlidersHorizontal,
FileCode2,
Zap,
ZapOff,
Send,
Undo2,
Lock,
ShieldCheck,
} from 'lucide-react';
import { Button } from '@object-ui/components';
import { Badge } from '@object-ui/components';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@object-ui/components';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@object-ui/components';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@object-ui/components';
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
import type {
MetadataLayered,
MetadataReference,
} from '@object-ui/data-objectstack';
import { PageShell } from './PageShell';
import { MetadataTypeActions } from './MetadataTypeActions';
import { LayeredDiff, countOverlaidFields } from './LayeredDiff';
import { DraftReviewPanel, computeDraftChangeCount } from './DraftReviewPanel';
import { SchemaForm, type SchemaFormIssue } from './SchemaForm';
import {
useMetadataClient,
useMetadataTypes,
type RichMetadataTypeEntry,
} from './useMetadata';
import {
getMetadataResource,
resolveResourceConfig,
listAnchorsFor,
} from './registry';
import { useCreateDerive, deriveDefaultCreateFields } from './createDerive';
import { RelatedPanel, type RelatedTarget } from './RelatedPanel';
import { MetadataDetailDrawer } from './MetadataDetailDrawer';
import { HistoryPanel } from './ResourceHistoryPage';
import { AuditPanel } from './AuditPanel';
import { getMetadataPreview, type MetadataSelection } from './preview-registry';
import { readFields } from './previews/object-fields-io';
import { useRegisterAssistantEditor, type AssistantEditorContext } from '../../assistant/assistantBus';
import { getMetadataInspector } from './inspector-registry';
import { getMetadataDefaultInspector } from './default-inspector-registry';
import { detectLocale, t, tFormat, translateValidationMessage } from './i18n';
import { JsonSourceEditor } from './JsonSourceEditor';
import { validateMetadataDraft, hasClientValidator } from './clientValidation';
import { describeIssuePath } from './issuePath';
// react-resizable-panels' `direction` prop type does not always narrow
// cleanly in our TS config; cast at the boundary (precedent:
// packages/components/src/custom/navigation-overlay.tsx).
const PanelGroup = ResizablePanelGroup as React.FC<any>;
/**
* Metadata types whose canvas IS the primary create-time authoring
* surface, so we render the preview/inspector split during create
* instead of the centered basic-info form. Object-level basics stay
* editable via the no-selection default inspector. Other types keep
* the conventional "name it first, design after save" create flow.
*/
const CREATE_MODE_CANVAS_TYPES = new Set<string>(['object', 'report']);
/**
* Top-level metadata keys that a type's canvas PreviewComponent owns and
* edits visually (e.g. the object designer owns `fields` + `fieldGroups`).
* These must never surface in the inspector's fallback SchemaForm — the
* no-selection panel would otherwise render a raw JSON editor for data
* the user is already editing on the canvas.
*/
const CANVAS_OWNED_KEYS: Record<string, string[]> = {
object: ['fields', 'fieldGroups'],
};
/**
* Normalize the framework's draft envelope into either the draft body or
* `null` (no pending draft). The envelope is:
*
* - `{ type, name, item: {...} }` when a draft exists,
* - `{ type, name, label }` when no draft exists (HTTP 200, item absent).
*
* The presence of the `item` key is the single signal; we do NOT fall back
* to using the envelope itself as the body — doing so would mis-identify the
* "no draft" stub (which still has `type`/`name`/`label` keys) as a real
* pending draft and would corrupt the editor baseline.
*/
function extractDraftBody(
draftResp: unknown,
): Record<string, unknown> | null {
if (!draftResp || typeof draftResp !== 'object') return null;
const env = draftResp as Record<string, unknown>;
if (!('item' in env)) return null;
const body = env.item;
if (!body || typeof body !== 'object') return null;
return Object.keys(body as object).length > 0
? (body as Record<string, unknown>)
: null;
}
/**
* Decide whether the validation-diagnostics banner should render at all.
*
* The gate has two reasons to stay hidden:
* - `loadFailed` — the layered/draft fetch itself failed, so the form is
* sitting on empty defaults. Any required-field issues the client
* validator produces are an artefact of the empty form, not a verdict on
* the item; the explicit "failed to load" banner already tells the real
* story. Suppress so a transport failure never masquerades as a broken
* item.
* - no diagnostics source — there is neither a server `_diagnostics`
* payload nor a client-side validator for this type, so there is nothing
* to show.
*/
export function shouldRenderDiagnostics(opts: {
loadFailed: boolean;
hasDiag: boolean;
hasClientValidator: boolean;
}): boolean {
if (opts.loadFailed) return false;
return opts.hasDiag || opts.hasClientValidator;
}
export interface MetadataResourceEditPageProps {
type?: string;
name?: string;
/** When true, this is the Create flow (skip initial fetch). */
createMode?: boolean;
/**
* When true, the editor is rendered inside another surface (e.g.
* the Related drawer). Hides Related-tab and URL-sync so the inner
* page does not fight the outer page for `?tab` / `?open`.
*/
embedded?: boolean;
}
export function MetadataResourceEditPage({
type: typeProp,
name: nameProp,
createMode = false,
embedded = false,
}: MetadataResourceEditPageProps) {
// Tiny dispatcher: a registered Custom EditPage / CreatePage is a
// different component type than MetadataResourceEditPageImpl, so React
// will unmount/remount when the registry-driven branch wins or loses
// (e.g. navigating from `/object/new` → `/object/sales_order`). Doing
// the dispatch INSIDE the impl below would leak hooks between
// branches and trigger "Rendered more hooks than during the previous
// render". We therefore keep this outer dispatcher hook-free apart
// from `useParams`, which is unconditional.
const params = useParams<{ type?: string; name?: string }>();
const type = typeProp ?? params.type ?? '';
const name = nameProp ?? params.name ?? '';
const customConfig = getMetadataResource(type);
if (customConfig?.EditPage && !createMode) {
const Custom = customConfig.EditPage;
return <Custom type={type} name={name} />;
}
if (customConfig?.CreatePage && createMode) {
const Custom = customConfig.CreatePage;
return <Custom type={type} />;
}
return (
<MetadataResourceEditPageImpl
type={type}
name={name}
createMode={createMode}
embedded={embedded}
/>
);
}
interface MetadataResourceEditPageImplProps {
type: string;
name: string;
createMode: boolean;
embedded: boolean;
}
function MetadataResourceEditPageImpl({
type,
name,
createMode,
embedded,
}: MetadataResourceEditPageImplProps) {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// ADR-0048 — the owning package of the item being edited, carried on the
// edit URL as `?package=` (emitted by the metadata list links). Scopes the
// layered/draft read so a same-name collision resolves to the right
// package's item. NOT the active Studio app's package — Studio edits items
// across all installed packages.
const ownerPackageId = searchParams.get('package') ?? undefined;
const client = useMetadataClient();
const { entries } = useMetadataTypes(client);
const entry: RichMetadataTypeEntry | undefined = entries.find((t) => t.type === type);
const config = resolveResourceConfig(type, entry);
// Hoist `schema` to the top: it's a pure derivation of entry/config
// and several create-mode hooks below need it. Keeping it down here
// would put those hooks *after* the loading early-return, which
// breaks the rules of hooks when navigating new→edit (a different
// number of hooks runs across renders of the same instance).
const schema =
(createMode && config.createSchema
? config.createSchema
: (entry?.schema as Record<string, unknown> | undefined)) ??
(config.defaultSchema as Record<string, unknown> | undefined);
const locale = React.useMemo(() => detectLocale(), []);
const [layered, setLayered] = React.useState<MetadataLayered<any> | null>(null);
const identityField = config.identityField ?? 'name';
const [draft, setDraft] = React.useState<Record<string, unknown>>(() =>
createMode ? { ...(config.createDefaults ?? {}), [identityField]: '' } : {},
);
const [refs, setRefs] = React.useState<MetadataReference[] | null>(null);
const [loading, setLoading] = React.useState(!createMode);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
// Distinguishes "the layered/draft fetch itself failed" (network/500/
// timeout) from "we loaded an item that fails validation". Without it a
// failed load renders the form with empty defaults and the client
// validator fires spurious "name/label/regions required" diagnostics,
// making a transport failure look like a structurally broken item. Set
// in the load catch block, reset at the start of each load.
const [loadFailed, setLoadFailed] = React.useState(false);
const [issues, setIssues] = React.useState<SchemaFormIssue[]>([]);
// In create mode, hold back validation noise until the author has actually
// edited a field. A blank new-item form firing 3 red "required" errors before
// the user types anything reads as broken, not helpful (the save path still
// validates). Flips true on the first real edit.
const [createDirty, setCreateDirty] = React.useState(false);
// Wrap setDraft so that editing a field clears any *server-side*
// diagnostic issues whose path begins with that field. The user
// gets immediate visual feedback — the red ring disappears as
// they type — and the form re-validates on save. We diff at the
// top-level segment, which matches how Zod's `issue.path[0]`
// identifies the offending field.
const handleDraftChange = React.useCallback(
(next: Record<string, unknown> | ((prev: Record<string, unknown>) => Record<string, unknown>)) => {
setDraft((prev) => {
const resolved = typeof next === 'function' ? next(prev) : next;
const changed = new Set<string>();
const keys = new Set([...Object.keys(prev ?? {}), ...Object.keys(resolved ?? {})]);
for (const k of keys) {
if (!Object.is(prev?.[k], resolved?.[k])) changed.add(k);
}
if (changed.size > 0) {
setCreateDirty(true);
setIssues((prevIssues) =>
prevIssues.filter((i) => {
const head = (i.path ?? '').split('.')[0];
return !changed.has(head);
}),
);
}
return resolved;
});
},
[],
);
const [destructiveIssues, setDestructiveIssues] = React.useState<
null | Array<{ kind?: string; path?: string; message?: string }>
>(null);
const [pendingItem, setPendingItem] = React.useState<unknown>(null);
// ── Create-mode form harness ──────────────────────────────────────
//
// Apply the registry's `createDerive` rules live (label→name slug,
// singular→plural, etc.). The hook is a no-op when not in create
// mode or when no rules are declared, so we always mount it.
const onCreatePatch = React.useCallback(
(patch: Partial<Record<string, unknown>>) => {
handleDraftChange((d) => ({ ...(d as Record<string, unknown>), ...patch }));
},
[handleDraftChange],
);
const { markTouched: markCreateFieldTouched } = useCreateDerive({
rules: config.createDerive,
draft,
onPatch: onCreatePatch,
enabled: !!createMode,
});
// Effective hidden-fields for create mode: collapse the form to just
// the identity inputs declared by the type (or required-fields ∪
// label/name as a sensible default). Edit mode keeps the full form.
//
// The complement-set is what SchemaForm consumes (it hides paths
// listed in `hiddenFields`), so we invert the allowlist here.
const createFieldList = React.useMemo(() => {
if (!createMode) return undefined;
if (config.createFields && config.createFields.length > 0) return config.createFields;
const props = (schema?.properties as Record<string, unknown> | undefined) ?? undefined;
const required = (schema?.required as readonly string[] | undefined) ?? undefined;
return deriveDefaultCreateFields(props, required);
}, [createMode, config.createFields, schema]);
const effectiveHiddenFields = React.useMemo<string[] | undefined>(() => {
// Keys edited on the canvas (fields, fieldGroups) are never shown in
// the inspector's SchemaForm fallback — otherwise deselecting reveals
// a raw JSON editor for data the canvas already owns.
const canvasOwned = CANVAS_OWNED_KEYS[type] ?? [];
if (!createMode || !createFieldList) {
if (canvasOwned.length === 0) return config.hiddenFields;
return Array.from(new Set([...(config.hiddenFields ?? []), ...canvasOwned]));
}
const props = (schema?.properties as Record<string, unknown> | undefined) ?? {};
const allow = new Set(createFieldList);
const hidden = Object.keys(props).filter((k) => !allow.has(k));
// Preserve any registry-declared `hiddenFields` too — they remain
// hidden in create mode even if they appeared in `createFields`.
if (config.hiddenFields) {
for (const k of config.hiddenFields) if (!hidden.includes(k)) hidden.push(k);
}
// Canvas-owned keys are hidden regardless of the create allowlist.
for (const k of canvasOwned) if (!hidden.includes(k)) hidden.push(k);
return hidden;
}, [createMode, createFieldList, schema, config.hiddenFields, type]);
const effectiveFieldOrder = React.useMemo<string[] | undefined>(() => {
if (createMode && createFieldList) return createFieldList;
return config.fieldOrder;
}, [createMode, createFieldList, config.fieldOrder]);
// Mark a top-level field as user-touched so create-mode derivations
// (label→name slug, etc.) leave it alone going forward. Wraps the
// standard onChange so the rest of the form is unaffected.
const handleCreateAwareChange = React.useCallback(
(next: Record<string, unknown> | ((prev: Record<string, unknown>) => Record<string, unknown>)) => {
if (createMode) {
const before = draft;
const resolved = typeof next === 'function' ? next(before) : next;
const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(resolved ?? {})]);
for (const k of keys) {
if (!Object.is(before?.[k], resolved?.[k])) markCreateFieldTouched(k);
}
}
handleDraftChange(next);
},
[createMode, draft, handleDraftChange, markCreateFieldTouched],
);
// Live client-side Zod validation. Debounced 200ms so we don't run
// on every keystroke through a complex AutoForm tree. When a client
// schema exists for `type` (spec 7.x exports per-type schemas under
// /data, /ui, /automation, /ai, /system, /kernel), we replace the
// `issues` state with Zod's output — same schemas the server runs,
// so behavior matches the post-save diagnostics but appears live.
// Types without a client schema keep the existing server-only flow.
React.useEffect(() => {
if (!hasClientValidator(type)) return;
let cancelled = false;
const handle = window.setTimeout(() => {
// Pass the live server schema so the client never flags fields the
// running server now treats as optional (cross-repo spec-skew root-cure).
void validateMetadataDraft(type, draft, entry?.schema as { required?: unknown } | undefined).then((res) => {
if (cancelled) return;
setIssues(res.issues);
});
}, 200);
return () => {
cancelled = true;
window.clearTimeout(handle);
};
}, [type, draft, entry?.schema]);
// Issues to DISPLAY (banner + inline). Suppressed on a pristine create form
// so a blank new item doesn't open covered in required-field errors.
const displayIssues = React.useMemo(
() => (createMode && !createDirty ? [] : issues),
[createMode, createDirty, issues],
);
// Per-item draft pending publish (mode=draft saves land here).
// When non-null, the editor is "viewing the draft" and we surface
// Publish / Discard-draft actions.
const [hasDraft, setHasDraft] = React.useState(false);
const [publishing, setPublishing] = React.useState(false);
// Bumped by destructive operations (rollback / discard-draft) to
// force the load effect to refetch layered + draft state.
const [reloadKey, setReloadKey] = React.useState(0);
// Form edit mode. The form is read-only by default — admins land in a
// "view" state and must click Edit to mutate, mirroring the Salesforce /
// Notion convention. createMode is always editing (you can't view what
// doesn't exist yet). Truly read-only types (no allowOrgOverride) stay
// read-only regardless.
const [editing, setEditing] = React.useState<boolean>(!!createMode);
// Currently selected sub-element (e.g. a dashboard widget). The
// preview emits this; the inspector consumes it. Must live above
// any early returns to preserve hook order — reset on item
// navigation or when leaving edit mode below.
const [selection, setSelection] = React.useState<MetadataSelection | null>(null);
React.useEffect(() => {
setSelection(null);
}, [type, name]);
React.useEffect(() => {
if (!editing) setSelection(null);
}, [editing]);
// Snapshot of the last saved draft. Used by Cancel to revert in-flight
// edits, and as the source-of-truth when entering edit mode.
const draftSnapshotRef = React.useRef<Record<string, unknown> | null>(null);
// Last successful save timestamp — surfaced as "Saved HH:MM" indicator
// next to the icon-only Save button.
const [lastSavedAt, setLastSavedAt] = React.useState<Date | null>(null);
// Auto-save toggle, persisted per-browser. Defaults to on for an
// "it just works" experience; users can disable it from the toolbar.
const [autoSaveEnabled, setAutoSaveEnabled] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return true;
try {
const v = window.localStorage.getItem('metadata-admin:autosave');
return v === null ? true : v === '1';
} catch {
return true;
}
});
React.useEffect(() => {
try {
window.localStorage.setItem('metadata-admin:autosave', autoSaveEnabled ? '1' : '0');
} catch {
/* ignore */
}
}, [autoSaveEnabled]);
// Tracks the last draft snapshot we attempted to auto-save, so a
// validation failure does not loop on the same payload — auto-save
// only retries once the user mutates the draft again.
const lastAutoSaveSnapshotRef = React.useRef<string | null>(null);
// Prefetch object name list once — fuels the `ref:object` widget.
// We don't block render on it; the widget shows a "Loading…" state.
const [objectNames, setObjectNames] = React.useState<string[]>([]);
const [objectsLoading, setObjectsLoading] = React.useState(true);
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const list = (await client.list('object')) as Array<{ name?: string }>;
if (cancelled) return;
setObjectNames(
list.map((x) => x?.name).filter((n): n is string => !!n).sort(),
);
} catch {
if (!cancelled) setObjectNames([]);
} finally {
if (!cancelled) setObjectsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [client]);
// Field catalog of the draft's bound/source object — fuels field-picker
// widgets (e.g. the interface-page filter-mode selector). For a page the
// source is `interfaceConfig.source` (interface mode) or the bound
// `object`; other types fall back to their own `object`/`objectName`.
const sourceObjectName: string | undefined =
((draft as any)?.interfaceConfig?.source as string | undefined) ||
((draft as any)?.object as string | undefined) ||
((draft as any)?.objectName as string | undefined);
const [objectFields, setObjectFields] = React.useState<Array<{ name: string; label?: string; type?: string }>>([]);
const [objectFieldsLoading, setObjectFieldsLoading] = React.useState(false);
// Action catalog of the source object — fuels the `action-multi` picker so
// interface-page `buttons` reference the object's real actions.
const [objectActions, setObjectActions] = React.useState<Array<{ name: string; label?: string; locations?: string[] }>>([]);
React.useEffect(() => {
let cancelled = false;
if (!sourceObjectName) { setObjectFields([]); setObjectActions([]); return; }
setObjectFieldsLoading(true);
(async () => {
try {
const obj = (await client.get('object', sourceObjectName)) as { fields?: Record<string, any> | Array<any> } | null;
if (cancelled) return;
const raw = obj?.fields;
const list = Array.isArray(raw)
? raw.map((f: any) => ({ name: f?.name, label: f?.label, type: f?.type }))
: raw && typeof raw === 'object'
? Object.entries(raw).map(([name, f]: [string, any]) => ({ name, label: f?.label, type: f?.type }))
: [];
setObjectFields(list.filter((f) => !!f.name));
const rawActions = (obj as any)?.actions;
const acts = Array.isArray(rawActions)
? rawActions.map((a: any) => ({ name: a?.name, label: a?.label, locations: a?.locations })).filter((a: any) => !!a.name)
: [];
if (!cancelled) setObjectActions(acts);
} catch {
if (!cancelled) { setObjectFields([]); setObjectActions([]); }
} finally {
if (!cancelled) setObjectFieldsLoading(false);
}
})();
return () => { cancelled = true; };
}, [client, sourceObjectName]);
// View catalog of the source object — fuels the `view-ref` picker for
// `interfaceConfig.sourceView` so the author chooses an existing view
// instead of typing (and mistyping) a name. Views are standalone metadata
// keyed to their object via `objectName`/`object`; the LIST endpoint returns
// name + label, which is all the picker needs.
const [objectViews, setObjectViews] = React.useState<Array<{ name: string; label?: string }>>([]);
const [objectViewsLoading, setObjectViewsLoading] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
if (!sourceObjectName) { setObjectViews([]); return; }
setObjectViewsLoading(true);
(async () => {
try {
const all = (await client.list('view')) as Array<Record<string, any>>;
if (cancelled) return;
const forObject = (all || []).filter((v) => {
const obj = v?.objectName ?? v?.object ?? v?.object_name;
return obj === sourceObjectName;
});
const seen = new Set<string>();
const list = forObject
.map((v) => ({ name: v?.name as string, label: (v?.label as string) || undefined }))
.filter((v) => !!v.name && !seen.has(v.name) && seen.add(v.name));
setObjectViews(list);
} catch {
if (!cancelled) setObjectViews([]);
} finally {
if (!cancelled) setObjectViewsLoading(false);
}
})();
return () => { cancelled = true; };
}, [client, sourceObjectName]);
const widgetContext = React.useMemo(
() => ({ objectNames, objectsLoading, objectFields, objectFieldsLoading, objectViews, objectViewsLoading, objectActions }),
[objectNames, objectsLoading, objectFields, objectFieldsLoading, objectViews, objectViewsLoading, objectActions],
);
// Load layered view + initial draft.
React.useEffect(() => {
if (createMode) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
setLoadFailed(false);
(async () => {
try {
const scope = ownerPackageId ? { packageId: ownerPackageId } : {};
const [lay, draftResp] = await Promise.all([
client.layered<any>(type, name, scope),
// Draft reads are best-effort — a 404/error must not block
// the page; readers without overlay-write permission still
// see the published item.
client.getDraft<any>(type, name, scope).catch(() => null),
]);
if (cancelled) return;
setLayered(lay);
// Surface server-computed load-time validation errors as inline
// SchemaForm issues — operators see what's wrong with the
// saved metadata immediately, not just on the next Save round-trip.
const loadDiag = (lay as any)?._diagnostics as
| { valid: boolean; errors?: Array<{ path: string; message: string }> }
| undefined;
if (loadDiag && loadDiag.valid === false && Array.isArray(loadDiag.errors)) {
setIssues(
loadDiag.errors.map((e) => ({
path: e.path || '',
message: e.message,
})),
);
} else {
setIssues([]);
}
// Draft envelope from the framework is `{ type, name, item }`;
// an empty/missing item means "no pending draft".
const draftReal = extractDraftBody(draftResp);
// Prefer the pending draft as the editing baseline — the
// operator is mid-flight on this item and should see their
// own in-progress state, not the last published version.
// A pending draft overlay can carry only the edited fields, so using
// it wholesale would drop inherited fields that were never touched —
// notably `type`, which section-level `visibleOn` predicates depend on
// (ADR-0047 hides Data Context / Layout when `data.type == 'list'`).
// Merge the draft over the effective baseline so those fields survive;
// the draft still wins for anything it does carry.
const baseline = (lay.effective ?? lay.code ?? {}) as Record<string, unknown>;
const rawInitial: Record<string, unknown> = draftReal
? { ...baseline, ...(draftReal as Record<string, unknown>) }
: baseline;
// Normalise the wire shape into the editor's draft shape (e.g.
// `view` unwraps an expanded ViewItem's `config` into a
// `{ list | form }` family key). No-op for types without a hook.
const initial = config.toDraft ? config.toDraft(rawInitial) : rawInitial;
setDraft(initial);
draftSnapshotRef.current = initial;
setHasDraft(!!draftReal);
setLoading(false);
} catch (err: any) {
if (!cancelled) {
// A failed fetch is a LOAD error, not a validation error: flag it
// so the diagnostics banner suppresses the spurious required-field
// issues the empty-default form would otherwise produce, and make
// the top error banner explicit about what actually went wrong.
setLoadFailed(true);
setError(
tFormat('engine.edit.loadFailed', locale, {
type,
name: name ?? '',
message: err?.message ?? String(err),
}),
);
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [client, type, name, ownerPackageId, createMode, reloadKey, locale]);
// Lazy-load references the first time the References sheet opens.
const [refsLoading, setRefsLoading] = React.useState(false);
async function loadReferences() {
if (refs != null || refsLoading) return;
setRefsLoading(true);
try {
const r = await client.references(type, name);
setRefs(r);
} catch (err: any) {
// Surface as empty list; non-blocking.
setRefs([]);
console.error('references() failed', err);
} finally {
setRefsLoading(false);
}
}
// Related drawer state. `null` = closed. We avoid querystring round-
// trips on every keystroke; URL state is best-effort sync via effect
// below.
const [relatedTarget, setRelatedTarget] = React.useState<RelatedTarget | null>(null);
const hasAnchors = React.useMemo(
() => !createMode && !embedded && listAnchorsFor(type).length > 0,
[type, createMode, embedded],
);
// Read ?tab and ?open on first mount so deep-links work. Embedded
// items are not deep-linkable (they live in the parent body and need
// the parent payload to materialise) so we only restore metadata
// targets here.
const initialTabRef = React.useRef<string | null>(null);
const [openSheet, setOpenSheet] =
React.useState<'layers' | 'references' | 'related' | 'history' | 'audit' | 'review' | null>(null);
// ADR-0033 Phase B — `?review=1` arrival (from the chat's "Review N change(s)"
// affordance). The AI may have drafted this item *after* the page mounted, so
// we first force a fresh fetch, then — once the draft is loaded — open the
// generic review/diff sheet and consume the query param (so a refresh/back
// doesn't re-trigger it). The same-item-already-open case is covered by the
// reload bump (the load effect keys off `reloadKey`, not the search string).
const reviewParam = searchParams.get('review');
const reviewBumpedRef = React.useRef(false);
React.useEffect(() => {
if (reviewParam !== '1' || createMode) return;
if (!reviewBumpedRef.current) {
reviewBumpedRef.current = true;
setReloadKey((k) => k + 1);
return; // wait for the reload to settle before reading hasDraft
}
if (!loading) {
if (hasDraft) setOpenSheet('review');
const next = new URLSearchParams(searchParams);
next.delete('review');
setSearchParams(next, { replace: true });
reviewBumpedRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reviewParam, createMode, loading, hasDraft]);
// Inspector tabs: properties form vs raw JSON source view. Source view
// is for power users who need to edit fields the form doesn't expose
// (e.g. nested arrays). Tracked locally — not persisted between
// navigations since most users live in the form 99% of the time.
const [inspectorTab, setInspectorTab] =
React.useState<'properties' | 'source'>('properties');
// When the References sheet opens, lazy-load the data (idempotent).
// Also keep the URL `?tab=` query in sync so deep-links round-trip.
React.useEffect(() => {
if (openSheet === 'references') {
void loadReferences();
}
if (typeof window !== 'undefined' && !embedded) {
const url = new URL(window.location.href);
if (openSheet) url.searchParams.set('tab', openSheet);
else url.searchParams.delete('tab');
window.history.replaceState({}, '', url.toString());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openSheet, embedded]);
// Designer-style split-panel state. The inspector (right form panel)
// can collapse to give the preview the full canvas. The collapsed
// state is persisted in localStorage so the user's preference sticks
// across navigations.
const inspectorStorageKey = 'metadata-edit:inspector-collapsed';
const inspectorSizeStorageKey = 'metadata-edit:inspector-size';
const [inspectorCollapsed, setInspectorCollapsed] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem(inspectorStorageKey) === '1';
});
// Remember the user's preferred inspector size so collapsing then
// re-expanding restores it instead of leaving a sliver. react-resizable-
// panels' built-in expand() returns to the size right before collapse
// which is often near 0, hence the explicit memory.
const lastInspectorSizeRef = React.useRef<number>(38);
// Hydrate from localStorage on mount.
React.useEffect(() => {
if (typeof window === 'undefined') return;
const v = Number(window.localStorage.getItem(inspectorSizeStorageKey));
if (Number.isFinite(v) && v >= 22 && v <= 80) {
lastInspectorSizeRef.current = v;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const inspectorPanelRef = React.useRef<any>(null);
const toggleInspector = React.useCallback(() => {
setInspectorCollapsed((prev) => {
const next = !prev;
if (typeof window !== 'undefined') {
window.localStorage.setItem(inspectorStorageKey, next ? '1' : '0');
}
return next;
});
}, []);
// Drive the imperative panel resize from a state-change effect rather
// than inside the setter — the latter runs before React has committed
// the new state and react-resizable-panels can race with its own
// onResize observer, producing tiny re-expanded sizes.
// ⚠️ resize() treats numeric values as **pixels**; pass a string to
// get a percentage. resize(38) → 38px (~2.7%); resize('38%') → 38%.
React.useEffect(() => {
const handle = inspectorPanelRef.current;
if (!handle) return;
if (inspectorCollapsed) {
handle.resize?.('0%');
} else {
const target = lastInspectorSizeRef.current || 38;
handle.resize?.(`${target}%`);
}
}, [inspectorCollapsed]);
// Canvas-local UX state — preview-only view (hides design chrome
// without dropping dirty edits) and fullscreen (canvas takes over the
// viewport so designers can focus). Both are session-scoped.
const [previewOnly, setPreviewOnly] = React.useState(false);
const [isFullscreen, setIsFullscreen] = React.useState(false);
// Lock body scroll while fullscreen so the underlying page can't peek
// through and the user's scroll position is preserved on exit.
React.useEffect(() => {
if (typeof document === 'undefined') return;
if (!isFullscreen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}, [isFullscreen]);
// Escape exits fullscreen.
React.useEffect(() => {
if (typeof window === 'undefined' || !isFullscreen) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
setIsFullscreen(false);
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isFullscreen]);
// Auto-enable design mode for designer-capable types. We do this once
// per (type,name) navigation so the user lands in the productive
// state instead of having to click "Edit". Truly read-only types
// (canWrite=false) keep the old behavior. The check happens inside
// the effect to avoid hook-order issues with the early `loading`
// return below.
const designerAutoOnRef = React.useRef<string | null>(null);
React.useEffect(() => {
designerAutoOnRef.current = null;
}, [type, name]);
React.useEffect(() => {
if (createMode || embedded || loading) return;
const key = `${type}/${name ?? ''}`;
if (designerAutoOnRef.current === key) return;
const PC = getMetadataPreview(type);
if (!PC) return;
// See `isArtifactItem` below — a `sys_metadata`-tagged code layer is a
// published org object, NOT a packaged artifact, so it stays editable.
const isArtifact =
layered?.code != null
&& (layered.code as { _packageId?: string } | null)?._packageId !== 'sys_metadata';
const cw = isArtifact
? !!entry?.allowOrgOverride
: !!(entry?.allowOrgOverride || entry?.allowRuntimeCreate);
if (!cw) return;
designerAutoOnRef.current = key;
setEditing(true);
}, [type, name, createMode, embedded, loading, entry, layered]);
// Keyboard shortcut: Cmd/Ctrl+\ toggles the inspector. This is the
// designer convention shared by Figma, VS Code (Cmd+B), Sketch — `\`
// sits next to Return so it's reachable one-handed.
React.useEffect(() => {
if (typeof window === 'undefined' || embedded) return;
function onKey(e: KeyboardEvent) {
const mod = e.metaKey || e.ctrlKey;
if (!mod || e.shiftKey || e.altKey) return;
if (e.key !== '\\') return;
// Ignore when typing in an editor (textarea / contenteditable).
const t = e.target as HTMLElement | null;
if (t && (t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
e.preventDefault();
toggleInspector();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [embedded, toggleInspector]);
React.useEffect(() => {
if (typeof window === 'undefined' || embedded) return;
const sp = new URLSearchParams(window.location.search);
const tab = sp.get('tab');
if (tab === 'layers' || tab === 'references' || tab === 'related' || tab === 'audit') {
setOpenSheet(tab);
}
initialTabRef.current = tab;
const open = sp.get('open');
if (open && open.includes(':')) {
const [t, n] = open.split(':', 2);
if (t && n) setRelatedTarget({ kind: 'metadata', type: t, name: n });
}
// intentionally empty deps — first mount only
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Reflect drawer target into the URL so refresh/share works.
React.useEffect(() => {
if (typeof window === 'undefined' || embedded) return;
const url = new URL(window.location.href);
if (relatedTarget?.kind === 'metadata') {
url.searchParams.set('open', `${relatedTarget.type}:${relatedTarget.name}`);
} else {
url.searchParams.delete('open');
}
window.history.replaceState({}, '', url.toString());
}, [relatedTarget, embedded]);
function labelForIssuePath(path: string): string {
const key = path.split('.')[0];
if (!key) return path;
// Resolve the human label for the HEAD segment from the form/schema.
const headLabel = ((): string => {
const formForLabels = (createMode && config.createSchema ? undefined : (entry?.form as any));
const sections = Array.isArray(formForLabels?.sections) ? formForLabels.sections : [];
for (const section of sections) {
const fields = Array.isArray(section?.fields) ? section.fields : [];
for (const field of fields) {
if (typeof field === 'string') {
if (field === key) return field;
} else if (field?.field === key) {
return String(field.label ?? key);
}
}
}
const props = (schema?.properties ?? {}) as Record<string, any>;
return String(props[key]?.title ?? key);
})();
// For a NESTED path (e.g. `widgets.2.layout`) append a readable trail naming
// the offending element + sub-field, so a terse "Widgets: Invalid input"
// becomes "Widgets → priority_split → layout".
return describeIssuePath(headLabel, path, draft);
}
async function doSave(force: boolean) {
setSaving(true);
setError(null);
setIssues([]);
try {
// Ensure identity is set on create, and that any `createDefaults`
// / `createBuildBody` shape (e.g. `{ fields: {} }` for object,
// or `{ list: { data: { object } } }` for view) is present so
// the saved body satisfies its JSONSchema. User-supplied values
// always win over the defaults.
let builtBody = createMode
? (config.createBuildBody
? config.createBuildBody(draft)
: { ...(config.createDefaults ?? {}), ...draft })
// Edit mode: serialise the editor draft back to the wire shape
// (inverse of `toDraft` — e.g. `view` folds the `{ list | form }`
// family key back into the ViewItem `config` wrapper).
: (config.fromDraft ? config.fromDraft(draft) : draft);
// Async create-time augmentation (e.g. seed a record page's regions from
// the bound object's synthesized default). Best-effort — a failure leaves
// the un-augmented body. User/builder-supplied keys win over the seed.
if (createMode && config.createSeed) {
try {
const seeded = await config.createSeed(draft, { client });
if (seeded && typeof seeded === 'object') {
// Seed wins over the empty defaults (`builtBody` already folded the
// user's draft in, which only carries default-empty `regions`).
builtBody = { ...(builtBody as Record<string, unknown>), ...seeded };
}
} catch { /* seed is best-effort; proceed with the un-augmented body */ }
}
const savedName = String(
(builtBody as Record<string, unknown>)[identityField] ?? draft[identityField] ?? name,
);
const itemToSave = createMode
? { ...builtBody, [identityField]: savedName }
: builtBody;
if (!savedName) {
setError(t('engine.validation.nameRequired', locale));
setSaving(false);
return;
}
// Save lands in the draft buffer — the runtime keeps serving the
// last published version until the operator clicks Publish. The
// backend defaults to publish mode for backward-compatibility, so
// Studio must opt into draft explicitly.
// Bind to the active software package (sys_metadata.package_id) when a
// real package scope is carried in the URL (`?package=`). The backend
// stamps it on create and preserves an existing binding on update, so
// env-local overlays (no `?package=`) are unaffected.
const activePackage = (() => {
try {
const p = new URLSearchParams(window.location.search).get('package');
return p && p !== 'all' ? p : undefined;
} catch {
return undefined;