@object-ui/app-shell 6.2.0 → 6.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,8 +20,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
20
20
  * EditPage via `registerMetadataResource()`.
21
21
  */
22
22
  import * as React from 'react';
23
- import { useNavigate } from 'react-router-dom';
24
- import { Save, RotateCcw, History, Link2, Loader2, AlertTriangle, } from 'lucide-react';
23
+ import { useNavigate, useParams } from 'react-router-dom';
24
+ import { Save, RotateCcw, History, Link2, Loader2, AlertTriangle, Layers3, } from 'lucide-react';
25
25
  import { Button } from '@object-ui/components';
26
26
  import { Badge } from '@object-ui/components';
27
27
  import { Tabs, TabsContent, TabsList, TabsTrigger, } from '@object-ui/components';
@@ -31,8 +31,13 @@ import { PageShell } from './PageShell';
31
31
  import { LayeredDiff } from './LayeredDiff';
32
32
  import { SchemaForm } from './SchemaForm';
33
33
  import { useMetadataClient, useMetadataTypes, } from './useMetadata';
34
- import { getMetadataResource, resolveResourceConfig, } from './registry';
35
- export function MetadataResourceEditPage({ type, name, createMode = false, }) {
34
+ import { getMetadataResource, resolveResourceConfig, listAnchorsFor, } from './registry';
35
+ import { RelatedPanel } from './RelatedPanel';
36
+ import { MetadataDetailDrawer } from './MetadataDetailDrawer';
37
+ export function MetadataResourceEditPage({ type: typeProp, name: nameProp, createMode = false, embedded = false, }) {
38
+ const params = useParams();
39
+ const type = typeProp ?? params.type ?? '';
40
+ const name = nameProp ?? params.name ?? '';
36
41
  const navigate = useNavigate();
37
42
  const client = useMetadataClient();
38
43
  const { entries } = useMetadataTypes(client);
@@ -134,6 +139,45 @@ export function MetadataResourceEditPage({ type, name, createMode = false, }) {
134
139
  setRefsLoading(false);
135
140
  }
136
141
  }
142
+ // Related drawer state. `null` = closed. We avoid querystring round-
143
+ // trips on every keystroke; URL state is best-effort sync via effect
144
+ // below.
145
+ const [relatedTarget, setRelatedTarget] = React.useState(null);
146
+ const hasAnchors = React.useMemo(() => !createMode && !embedded && listAnchorsFor(type).length > 0, [type, createMode, embedded]);
147
+ // Read ?tab and ?open on first mount so deep-links work. Embedded
148
+ // items are not deep-linkable (they live in the parent body and need
149
+ // the parent payload to materialise) so we only restore metadata
150
+ // targets here.
151
+ const initialTabRef = React.useRef(null);
152
+ React.useEffect(() => {
153
+ if (typeof window === 'undefined' || embedded)
154
+ return;
155
+ const sp = new URLSearchParams(window.location.search);
156
+ const tab = sp.get('tab');
157
+ if (tab)
158
+ initialTabRef.current = tab;
159
+ const open = sp.get('open');
160
+ if (open && open.includes(':')) {
161
+ const [t, n] = open.split(':', 2);
162
+ if (t && n)
163
+ setRelatedTarget({ kind: 'metadata', type: t, name: n });
164
+ }
165
+ // intentionally empty deps — first mount only
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps
167
+ }, []);
168
+ // Reflect drawer target into the URL so refresh/share works.
169
+ React.useEffect(() => {
170
+ if (typeof window === 'undefined' || embedded)
171
+ return;
172
+ const url = new URL(window.location.href);
173
+ if (relatedTarget?.kind === 'metadata') {
174
+ url.searchParams.set('open', `${relatedTarget.type}:${relatedTarget.name}`);
175
+ }
176
+ else {
177
+ url.searchParams.delete('open');
178
+ }
179
+ window.history.replaceState({}, '', url.toString());
180
+ }, [relatedTarget, embedded]);
137
181
  async function doSave(force) {
138
182
  setSaving(true);
139
183
  setError(null);
@@ -157,13 +201,7 @@ export function MetadataResourceEditPage({ type, name, createMode = false, }) {
157
201
  setDestructiveIssues(null);
158
202
  setPendingItem(null);
159
203
  if (createMode) {
160
- // Use absolute path — react-router resolves `../` against the
161
- // matched route pattern (`/apps/:app/component/:ns/:name/*`),
162
- // which overshoots and lands at `/apps/:app`. Anchor on the
163
- // location's pathname for predictable behaviour.
164
- const here = window.location.pathname; // .../resource/new
165
- const parent = here.replace(/\/new\/?$/, '');
166
- navigate(`${parent}/${encodeURIComponent(savedName)}?type=${encodeURIComponent(type)}`);
204
+ navigate(`../${encodeURIComponent(savedName)}`);
167
205
  }
168
206
  }
169
207
  catch (err) {
@@ -238,7 +276,15 @@ export function MetadataResourceEditPage({ type, name, createMode = false, }) {
238
276
  const schema = entry?.schema ??
239
277
  config.defaultSchema;
240
278
  const readOnly = !entry?.allowOrgOverride && !createMode;
241
- return (_jsxs(PageShell, { entry: entry ?? { type, label: type }, itemName: createMode ? '(new)' : name, subtitle: createMode ? 'Create new' : 'Edit overlay', actions: _jsxs(_Fragment, { children: [!createMode && entry?.allowOrgOverride && (_jsxs(Button, { variant: "ghost", size: "sm", onClick: doReset, disabled: saving, children: [_jsx(RotateCcw, { className: "h-4 w-4 mr-1" }), "Reset overlay"] })), !createMode && (_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => navigate(`./history?type=${encodeURIComponent(type)}`), children: [_jsx(History, { className: "h-4 w-4 mr-1" }), "History"] })), entry?.allowOrgOverride && (_jsxs(Button, { size: "sm", onClick: () => doSave(false), disabled: saving, children: [saving ? (_jsx(Loader2, { className: "h-4 w-4 mr-1 animate-spin" })) : (_jsx(Save, { className: "h-4 w-4 mr-1" })), "Save"] }))] }), children: [_jsxs("div", { className: "p-6 space-y-6 max-w-7xl", children: [error && (_jsx("div", { className: "text-sm text-destructive border border-destructive/30 rounded p-3 bg-destructive/5", children: error })), readOnly && (_jsxs("div", { className: "text-xs text-amber-800 border border-amber-300 bg-amber-50 rounded p-3", children: ["This type is read-only. To enable runtime editing, set", ' ', _jsx("code", { className: "font-mono", children: "OBJECTSTACK_METADATA_WRITABLE" }), " to include ", _jsx("code", { className: "font-mono", children: type }), ", or flip", ' ', _jsx("code", { className: "font-mono", children: "allowOrgOverride" }), " in the registry."] })), _jsxs(Tabs, { defaultValue: "form", className: "w-full", children: [_jsxs(TabsList, { children: [_jsx(TabsTrigger, { value: "form", children: "Form" }), !createMode && (_jsxs(TabsTrigger, { value: "layers", children: ["Layers", layered?.overlay && (_jsx(Badge, { className: "ml-1.5 text-[10px] bg-emerald-600 text-emerald-50", children: "overlay" }))] })), !createMode && (_jsxs(TabsTrigger, { value: "references", onClick: loadReferences, children: [_jsx(Link2, { className: "h-3.5 w-3.5 mr-1" }), "References", refs && (_jsx(Badge, { variant: "outline", className: "ml-1.5 text-[10px]", children: refs.length }))] }))] }), _jsx(TabsContent, { value: "form", className: "mt-4", children: _jsx(SchemaForm, { schema: schema, form: entry?.form, value: draft, onChange: setDraft, issues: issues, hiddenFields: config.hiddenFields, fieldOrder: config.fieldOrder, readOnly: readOnly, createMode: createMode, widgetContext: widgetContext }) }), !createMode && (_jsx(TabsContent, { value: "layers", className: "mt-4", children: _jsx(LayeredDiff, { layered: layered }) })), !createMode && (_jsx(TabsContent, { value: "references", className: "mt-4", children: _jsx(ReferencesPanel, { refs: refs, loading: refsLoading }) }))] })] }), _jsx(Dialog, { open: destructiveIssues != null, onOpenChange: (open) => {
279
+ const DesignerTab = !createMode ? customConfig?.DesignerTab : undefined;
280
+ const designerTabLabel = customConfig?.designerTabLabel ?? 'Designer';
281
+ return (_jsxs(PageShell, { entry: entry ?? { type, label: type }, itemName: createMode ? '(new)' : name, subtitle: createMode ? 'Create new' : 'Edit overlay', actions: _jsxs(_Fragment, { children: [!createMode && entry?.allowOrgOverride && (_jsxs(Button, { variant: "ghost", size: "sm", onClick: doReset, disabled: saving, children: [_jsx(RotateCcw, { className: "h-4 w-4 mr-1" }), "Reset overlay"] })), !createMode && (_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => navigate(`./history`), children: [_jsx(History, { className: "h-4 w-4 mr-1" }), "History"] })), entry?.allowOrgOverride && (_jsxs(Button, { size: "sm", onClick: () => doSave(false), disabled: saving, children: [saving ? (_jsx(Loader2, { className: "h-4 w-4 mr-1 animate-spin" })) : (_jsx(Save, { className: "h-4 w-4 mr-1" })), "Save"] }))] }), children: [_jsxs("div", { className: "p-6 space-y-6 max-w-7xl", children: [error && (_jsx("div", { className: "text-sm text-destructive border border-destructive/30 rounded p-3 bg-destructive/5", children: error })), readOnly && (_jsxs("div", { className: "text-xs text-amber-800 border border-amber-300 bg-amber-50 rounded p-3", children: ["This type is read-only. To enable runtime editing, set", ' ', _jsx("code", { className: "font-mono", children: "OBJECTSTACK_METADATA_WRITABLE" }), " to include ", _jsx("code", { className: "font-mono", children: type }), ", or flip", ' ', _jsx("code", { className: "font-mono", children: "allowOrgOverride" }), " in the registry."] })), _jsxs(Tabs, { defaultValue: initialTabRef.current ?? (DesignerTab ? 'designer' : 'form'), className: "w-full", onValueChange: (v) => {
282
+ if (typeof window === 'undefined' || embedded)
283
+ return;
284
+ const url = new URL(window.location.href);
285
+ url.searchParams.set('tab', v);
286
+ window.history.replaceState({}, '', url.toString());
287
+ }, children: [_jsxs(TabsList, { children: [DesignerTab && (_jsx(TabsTrigger, { value: "designer", children: designerTabLabel })), _jsx(TabsTrigger, { value: "form", children: "Form" }), !createMode && (_jsxs(TabsTrigger, { value: "layers", children: ["Layers", layered?.overlay && (_jsx(Badge, { className: "ml-1.5 text-[10px] bg-emerald-600 text-emerald-50", children: "overlay" }))] })), !createMode && (_jsxs(TabsTrigger, { value: "references", onClick: loadReferences, children: [_jsx(Link2, { className: "h-3.5 w-3.5 mr-1" }), "References", refs && (_jsx(Badge, { variant: "outline", className: "ml-1.5 text-[10px]", children: refs.length }))] })), hasAnchors && (_jsxs(TabsTrigger, { value: "related", children: [_jsx(Layers3, { className: "h-3.5 w-3.5 mr-1" }), "Related"] }))] }), DesignerTab && (_jsx(TabsContent, { value: "designer", className: "mt-4", children: _jsx(DesignerTab, { type: type, name: name }) })), _jsx(TabsContent, { value: "form", className: "mt-4", children: _jsx(SchemaForm, { schema: schema, form: entry?.form, value: draft, onChange: setDraft, issues: issues, hiddenFields: config.hiddenFields, fieldOrder: config.fieldOrder, readOnly: readOnly, createMode: createMode, widgetContext: widgetContext }) }), !createMode && (_jsx(TabsContent, { value: "layers", className: "mt-4", children: _jsx(LayeredDiff, { layered: layered }) })), !createMode && (_jsx(TabsContent, { value: "references", className: "mt-4", children: _jsx(ReferencesPanel, { refs: refs, loading: refsLoading }) })), hasAnchors && (_jsx(TabsContent, { value: "related", className: "mt-4", children: _jsx(RelatedPanel, { type: type, name: name, parentItem: draft, onOpen: (t) => setRelatedTarget(t) }) }))] })] }), _jsx(MetadataDetailDrawer, { target: relatedTarget, onClose: () => setRelatedTarget(null), parentContext: { type, name } }), _jsx(Dialog, { open: destructiveIssues != null, onOpenChange: (open) => {
242
288
  if (!open) {
243
289
  setDestructiveIssues(null);
244
290
  setPendingItem(null);
@@ -1,5 +1,5 @@
1
1
  export interface MetadataResourceHistoryPageProps {
2
- type: string;
3
- name: string;
2
+ type?: string;
3
+ name?: string;
4
4
  }
5
- export declare function MetadataResourceHistoryPage({ type, name, }: MetadataResourceHistoryPageProps): import("react/jsx-runtime").JSX.Element;
5
+ export declare function MetadataResourceHistoryPage({ type: typeProp, name: nameProp, }: MetadataResourceHistoryPageProps): import("react/jsx-runtime").JSX.Element;
@@ -15,14 +15,17 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
15
15
  * once admins ask for it.
16
16
  */
17
17
  import * as React from 'react';
18
- import { useNavigate } from 'react-router-dom';
18
+ import { useNavigate, useParams } from 'react-router-dom';
19
19
  import { ArrowLeft, RefreshCw, Loader2 } from 'lucide-react';
20
20
  import { Button } from '@object-ui/components';
21
21
  import { Badge } from '@object-ui/components';
22
22
  import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
23
23
  import { PageShell } from './PageShell';
24
24
  import { useMetadataClient, useMetadataTypes, } from './useMetadata';
25
- export function MetadataResourceHistoryPage({ type, name, }) {
25
+ export function MetadataResourceHistoryPage({ type: typeProp, name: nameProp, }) {
26
+ const params = useParams();
27
+ const type = typeProp ?? params.type ?? '';
28
+ const name = nameProp ?? params.name ?? '';
26
29
  const navigate = useNavigate();
27
30
  const client = useMetadataClient();
28
31
  const { entries } = useMetadataTypes(client);
@@ -62,7 +65,7 @@ export function MetadataResourceHistoryPage({ type, name, }) {
62
65
  cancelled = true;
63
66
  };
64
67
  }, [client, type, name, refreshKey]);
65
- return (_jsx(PageShell, { entry: entry ?? { type, label: type }, itemName: name, subtitle: "Version history", stats: [{ label: 'Events', value: events.length }], actions: _jsxs(_Fragment, { children: [_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => navigate(`../${encodeURIComponent(name)}?type=${encodeURIComponent(type)}`), children: [_jsx(ArrowLeft, { className: "h-4 w-4 mr-1" }), "Back to item"] }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setRefreshKey((k) => k + 1), children: _jsx(RefreshCw, { className: "h-4 w-4" }) })] }), children: _jsxs("div", { className: "p-6 max-w-4xl", children: [loading && (_jsxs("div", { className: "text-sm text-muted-foreground flex items-center gap-2", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), " Loading history\u2026"] })), error && (_jsx("div", { className: "text-sm text-destructive border border-destructive/30 rounded p-3 bg-destructive/5", children: error })), !loading && !error && events.length === 0 && (_jsxs(Empty, { children: [_jsx(EmptyTitle, { children: "No history yet" }), _jsx(EmptyDescription, { children: "This item has never been edited via an overlay. The first save will create the initial history record." })] })), !loading && events.length > 0 && (_jsx("ol", { className: "space-y-2 relative pl-6 border-l", children: events.map((ev, i) => {
68
+ return (_jsx(PageShell, { entry: entry ?? { type, label: type }, itemName: name, subtitle: "Version history", stats: [{ label: 'Events', value: events.length }], actions: _jsxs(_Fragment, { children: [_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => navigate(`../${encodeURIComponent(name)}`), children: [_jsx(ArrowLeft, { className: "h-4 w-4 mr-1" }), "Back to item"] }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setRefreshKey((k) => k + 1), children: _jsx(RefreshCw, { className: "h-4 w-4" }) })] }), children: _jsxs("div", { className: "p-6 max-w-4xl", children: [loading && (_jsxs("div", { className: "text-sm text-muted-foreground flex items-center gap-2", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), " Loading history\u2026"] })), error && (_jsx("div", { className: "text-sm text-destructive border border-destructive/30 rounded p-3 bg-destructive/5", children: error })), !loading && !error && events.length === 0 && (_jsxs(Empty, { children: [_jsx(EmptyTitle, { children: "No history yet" }), _jsx(EmptyDescription, { children: "This item has never been edited via an overlay. The first save will create the initial history record." })] })), !loading && events.length > 0 && (_jsx("ol", { className: "space-y-2 relative pl-6 border-l", children: events.map((ev, i) => {
66
69
  const isOpen = expanded === i;
67
70
  return (_jsxs("li", { className: "relative", children: [_jsx("span", { className: "absolute -left-[27px] top-2 w-3 h-3 rounded-full bg-primary ring-4 ring-background" }), _jsxs("div", { role: "button", tabIndex: 0, onClick: () => setExpanded(isOpen ? null : i), onKeyDown: (e) => {
68
71
  if (e.key === 'Enter' || e.key === ' ') {
@@ -1,4 +1,4 @@
1
1
  export interface MetadataResourceListPageProps {
2
- type: string;
2
+ type?: string;
3
3
  }
4
- export declare function MetadataResourceListPage({ type }: MetadataResourceListPageProps): import("react/jsx-runtime").JSX.Element;
4
+ export declare function MetadataResourceListPage({ type: typeProp }: MetadataResourceListPageProps): import("react/jsx-runtime").JSX.Element;
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
12
12
  * per type, well under the threshold where it'd matter.
13
13
  */
14
14
  import * as React from 'react';
15
- import { Link, useNavigate } from 'react-router-dom';
15
+ import { Link, useNavigate, useParams } from 'react-router-dom';
16
16
  import { Plus, Search, RefreshCw } from 'lucide-react';
17
17
  import { Button } from '@object-ui/components';
18
18
  import { Input } from '@object-ui/components';
@@ -23,7 +23,9 @@ import { PageShell } from './PageShell';
23
23
  import { useMetadataClient, useMetadataTypes, matchesQuery, } from './useMetadata';
24
24
  import { getMetadataResource, resolveResourceConfig, } from './registry';
25
25
  import { t, detectLocale } from './i18n';
26
- export function MetadataResourceListPage({ type }) {
26
+ export function MetadataResourceListPage({ type: typeProp }) {
27
+ const params = useParams();
28
+ const type = typeProp ?? params.type ?? '';
27
29
  const navigate = useNavigate();
28
30
  const client = useMetadataClient();
29
31
  const { entries: typesEntries } = useMetadataTypes(client);
@@ -101,7 +103,7 @@ export function MetadataResourceListPage({ type }) {
101
103
  return (_jsx(PageShell, { entry: entry ?? { type, label: type }, stats: [
102
104
  { label: t('engine.list.items', locale), value: items.length },
103
105
  { label: t('engine.list.filtered', locale), value: filtered.length },
104
- ], actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setRefreshKey((k) => k + 1), title: t('engine.list.refresh', locale), children: _jsx(RefreshCw, { className: "h-4 w-4" }) }), entry?.allowOrgOverride && (_jsxs(Button, { size: "sm", onClick: () => navigate(`./new?type=${encodeURIComponent(type)}`), children: [_jsx(Plus, { className: "h-4 w-4 mr-1" }), t('engine.list.create', locale)] }))] }), children: _jsxs("div", { className: "p-6 space-y-4", children: [_jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsxs("div", { className: "relative flex-1 min-w-[200px] max-w-md", children: [_jsx(Search, { className: "absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" }), _jsx(Input, { className: "pl-8", placeholder: t('engine.list.search', locale), value: query, onChange: (e) => setQuery(e.target.value) })] }), _jsxs(Select, { value: sourceFilter, onValueChange: setSourceFilter, children: [_jsx(SelectTrigger, { className: "w-[180px]", children: _jsx(SelectValue, {}) }), _jsxs(SelectContent, { children: [_jsxs(SelectItem, { value: "all", children: [t('engine.list.allSources', locale), " (", sourceCounts.all, ")"] }), _jsxs(SelectItem, { value: "code", children: ["Code (", sourceCounts.code, ")"] }), _jsxs(SelectItem, { value: "overlay", children: ["Overlay (", sourceCounts.overlay, ")"] }), _jsxs(SelectItem, { value: "effective", children: ["Effective (", sourceCounts.effective, ")"] })] })] })] }), loading && (_jsxs("div", { className: "text-sm text-muted-foreground", children: [t('engine.edit.loading', locale), " ", type, "\u2026"] })), error && (_jsx("div", { className: "text-sm text-destructive border border-destructive/30 rounded p-3 bg-destructive/5", children: error })), !loading && !error && filtered.length === 0 && (_jsxs(Empty, { children: [_jsx(EmptyTitle, { children: items.length === 0
106
+ ], actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setRefreshKey((k) => k + 1), title: t('engine.list.refresh', locale), children: _jsx(RefreshCw, { className: "h-4 w-4" }) }), entry?.allowOrgOverride && (_jsxs(Button, { size: "sm", onClick: () => navigate(`./new`), children: [_jsx(Plus, { className: "h-4 w-4 mr-1" }), t('engine.list.create', locale)] }))] }), children: _jsxs("div", { className: "p-6 space-y-4", children: [_jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsxs("div", { className: "relative flex-1 min-w-[200px] max-w-md", children: [_jsx(Search, { className: "absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" }), _jsx(Input, { className: "pl-8", placeholder: t('engine.list.search', locale), value: query, onChange: (e) => setQuery(e.target.value) })] }), _jsxs(Select, { value: sourceFilter, onValueChange: setSourceFilter, children: [_jsx(SelectTrigger, { className: "w-[180px]", children: _jsx(SelectValue, {}) }), _jsxs(SelectContent, { children: [_jsxs(SelectItem, { value: "all", children: [t('engine.list.allSources', locale), " (", sourceCounts.all, ")"] }), _jsxs(SelectItem, { value: "code", children: ["Code (", sourceCounts.code, ")"] }), _jsxs(SelectItem, { value: "overlay", children: ["Overlay (", sourceCounts.overlay, ")"] }), _jsxs(SelectItem, { value: "effective", children: ["Effective (", sourceCounts.effective, ")"] })] })] })] }), loading && (_jsxs("div", { className: "text-sm text-muted-foreground", children: [t('engine.edit.loading', locale), " ", type, "\u2026"] })), error && (_jsx("div", { className: "text-sm text-destructive border border-destructive/30 rounded p-3 bg-destructive/5", children: error })), !loading && !error && filtered.length === 0 && (_jsxs(Empty, { children: [_jsx(EmptyTitle, { children: items.length === 0
105
107
  ? `No ${type} items registered`
106
108
  : `No matches for "${query}"` }), _jsx(EmptyDescription, { children: config.emptyStateHint ??
107
109
  (entry?.allowOrgOverride
@@ -112,7 +114,7 @@ export function MetadataResourceListPage({ type }) {
112
114
  return (_jsxs("tr", { className: "hover:bg-accent/50", children: [columns.map((c, ci) => {
113
115
  const value = row.item[c.key];
114
116
  const cell = c.render ? c.render(value, row.item) : defaultCell(value);
115
- return (_jsx("td", { className: "px-3 py-2 align-top", children: ci === 0 ? (_jsx(Link, { to: `./${encodeURIComponent(name)}?type=${encodeURIComponent(type)}`, className: "text-primary hover:underline font-mono", children: cell })) : (cell) }, c.key));
117
+ return (_jsx("td", { className: "px-3 py-2 align-top", children: ci === 0 ? (_jsx(Link, { to: `./${encodeURIComponent(name)}`, className: "text-primary hover:underline font-mono", children: cell })) : (cell) }, c.key));
116
118
  }), _jsx("td", { className: "px-3 py-2 text-right align-top", children: row.source ? (_jsx(Badge, { variant: "outline", className: 'text-[10px] ' +
117
119
  (row.source === 'overlay'
118
120
  ? 'border-emerald-500 text-emerald-700'
@@ -321,7 +321,16 @@ function FieldControl({ id, schema, value, onChange, readOnly, widget, fieldSpec
321
321
  }
322
322
  // Boolean → Switch (no redundant "true/false" text; the toggle state
323
323
  // already conveys the value).
324
- if (schema?.type === 'boolean') {
324
+ //
325
+ // We must also honor `widget === 'switch'` (resolved by inferWidget from
326
+ // `fieldSpec.type === 'boolean'` / `'toggle'`), because for composite
327
+ // sub-fields the JSON schema fragment is often `{}` — the parent declares
328
+ // `additionalProperties: true` and no per-property `properties`, so
329
+ // `schema?.type` is undefined even though the form spec clearly marks
330
+ // the sub-field as boolean. Without this, capability toggles inside the
331
+ // Object editor's "Capabilities" section fell through to RawJsonEditor
332
+ // and rendered as empty textareas.
333
+ if (schema?.type === 'boolean' || widget === 'switch' || fieldSpec?.type === 'boolean' || fieldSpec?.type === 'toggle') {
325
334
  return (_jsx(Switch, { id: id, checked: !!value, onCheckedChange: (c) => onChange(c), disabled: readOnly }));
326
335
  }
327
336
  // Number / integer → numeric input with min/max from fieldSpec.
@@ -0,0 +1 @@
1
+ export declare function registerBuiltinAnchors(): void;
@@ -0,0 +1,229 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+ /**
3
+ * Built-in anchor relationships for the Related tab.
4
+ *
5
+ * Each entry tells the metadata-admin engine "items of type X belong to
6
+ * (are anchored at) items of type Y when this predicate matches". The
7
+ * Related panel on the parent's edit page uses this registry to decide
8
+ * which `client.list(type)` calls to make and how to group the results.
9
+ *
10
+ * Adding a new relationship is intentionally a one-liner — declare it
11
+ * here (or in your own plugin's bootstrap) and the UI picks it up with
12
+ * no further wiring. Predicates are kept tiny because they run client-
13
+ * side over every item returned from `list()`.
14
+ *
15
+ * Why not parse the schema and infer this? Some types nest the anchor
16
+ * deep in `data.object` or `list.data.object`; some types live on the
17
+ * parent (e.g. `fields` is embedded inside the object item itself).
18
+ * Hand-rolled declarations are clearer than schema-walking heuristics.
19
+ */
20
+ import { registerMetadataResource, anchorByField } from './registry';
21
+ export function registerBuiltinAnchors() {
22
+ // ── EMBEDDED: items stored inside the object body itself ──────────
23
+ //
24
+ // Fields, indexes, and embedded validations don't have a top-level
25
+ // metadata type; they live under `object.fields`, `object.indexes[]`,
26
+ // `object.validations[]`. We surface them with `source: 'embedded'`
27
+ // so the Related panel can list them without a separate API call.
28
+ //
29
+ // `fields` is stored as a NAME-KEYED MAP (`{ email: {...}, name: {...} }`)
30
+ // — not an array — so we adapt it into an array carrying the key as
31
+ // `name` for the panel to render.
32
+ registerMetadataResource({
33
+ type: '__object_field',
34
+ label: 'Field',
35
+ anchors: [{
36
+ anchorType: 'object',
37
+ source: 'embedded',
38
+ extract: (parent) => mapOrArrayToList(parent.fields),
39
+ groupLabel: 'Fields',
40
+ order: 5,
41
+ }],
42
+ });
43
+ registerMetadataResource({
44
+ type: '__object_index',
45
+ label: 'Index',
46
+ anchors: [{
47
+ anchorType: 'object',
48
+ source: 'embedded',
49
+ extract: (parent) => {
50
+ const raw = parent.indexes;
51
+ if (!Array.isArray(raw))
52
+ return [];
53
+ // Indexes have no `name`; synthesise one from their column list.
54
+ return raw.map((idx, i) => {
55
+ const fields = Array.isArray(idx.fields) ? idx.fields.join(',') : '';
56
+ const synthesised = fields ? `idx_${fields}` : `index_${i}`;
57
+ return { name: synthesised, ...idx };
58
+ });
59
+ },
60
+ groupLabel: 'Indexes',
61
+ order: 10,
62
+ }],
63
+ });
64
+ registerMetadataResource({
65
+ type: '__object_validation',
66
+ label: 'Embedded validation',
67
+ anchors: [{
68
+ anchorType: 'object',
69
+ source: 'embedded',
70
+ extract: (parent) => mapOrArrayToList(parent.validations),
71
+ groupLabel: 'Embedded Validations',
72
+ order: 15,
73
+ }],
74
+ });
75
+ // ── LIST: standalone child metadata types ─────────────────────────
76
+ // hook.object → object (beforeInsert / afterUpdate / …)
77
+ registerMetadataResource({
78
+ type: 'hook',
79
+ anchors: [{
80
+ anchorType: 'object',
81
+ match: anchorByField('object'),
82
+ groupLabel: 'Hooks',
83
+ order: 20,
84
+ }],
85
+ });
86
+ // approval.object → object (approval processes targeting this object)
87
+ registerMetadataResource({
88
+ type: 'approval',
89
+ anchors: [{
90
+ anchorType: 'object',
91
+ match: anchorByField('object'),
92
+ groupLabel: 'Approval Processes',
93
+ order: 70,
94
+ }],
95
+ });
96
+ // page.object → object (auto-generated record pages, etc.)
97
+ registerMetadataResource({
98
+ type: 'page',
99
+ anchors: [{
100
+ anchorType: 'object',
101
+ match: anchorByField('object'),
102
+ groupLabel: 'Pages',
103
+ order: 40,
104
+ }],
105
+ });
106
+ // view binds to an object via data.object (list view / form view variants)
107
+ registerMetadataResource({
108
+ type: 'view',
109
+ anchors: [{
110
+ anchorType: 'object',
111
+ match: anchorByField([
112
+ 'data.object',
113
+ 'list.data.object',
114
+ 'form.data.object',
115
+ 'object',
116
+ ]),
117
+ groupLabel: 'Views',
118
+ order: 30,
119
+ }],
120
+ });
121
+ // flow / workflow may reference an object at the root or under `on`
122
+ registerMetadataResource({
123
+ type: 'flow',
124
+ anchors: [{
125
+ anchorType: 'object',
126
+ match: anchorByField(['object', 'on.object', 'trigger.object']),
127
+ groupLabel: 'Flows',
128
+ order: 50,
129
+ }],
130
+ });
131
+ registerMetadataResource({
132
+ type: 'workflow',
133
+ anchors: [{
134
+ anchorType: 'object',
135
+ match: anchorByField(['object', 'on.object']),
136
+ groupLabel: 'Workflow Rules',
137
+ order: 51,
138
+ }],
139
+ });
140
+ // trigger.object → object (low-level DB-style triggers)
141
+ registerMetadataResource({
142
+ type: 'trigger',
143
+ anchors: [{
144
+ anchorType: 'object',
145
+ match: anchorByField(['object', 'on.object']),
146
+ groupLabel: 'Triggers',
147
+ order: 52,
148
+ }],
149
+ });
150
+ // validation: usually embedded in the object, but standalone variants
151
+ // do exist. Match anything whose `object` points back at us.
152
+ registerMetadataResource({
153
+ type: 'validation',
154
+ anchors: [{
155
+ anchorType: 'object',
156
+ match: anchorByField('object'),
157
+ groupLabel: 'Validations',
158
+ order: 25,
159
+ }],
160
+ });
161
+ // permission has a sparse object-keyed map under `objects`. Match by
162
+ // membership of the parent name in that map.
163
+ registerMetadataResource({
164
+ type: 'permission',
165
+ anchors: [{
166
+ anchorType: 'object',
167
+ match: (item, name) => {
168
+ const objs = item?.objects;
169
+ return !!objs && typeof objs === 'object' && name in objs;
170
+ },
171
+ groupLabel: 'Permissions',
172
+ order: 60,
173
+ }],
174
+ });
175
+ // action — both record-scoped and object-scoped actions live here.
176
+ // Schemas vary, so accept any of the three common shapes.
177
+ registerMetadataResource({
178
+ type: 'action',
179
+ anchors: [{
180
+ anchorType: 'object',
181
+ match: anchorByField([
182
+ 'object',
183
+ 'target.object',
184
+ 'on.object',
185
+ ]),
186
+ groupLabel: 'Actions',
187
+ order: 55,
188
+ }],
189
+ });
190
+ // dashboard / report — surface ones bound to a specific object.
191
+ // Many will not have an explicit anchor; those simply don't appear.
192
+ registerMetadataResource({
193
+ type: 'dashboard',
194
+ anchors: [{
195
+ anchorType: 'object',
196
+ match: anchorByField(['object', 'data.object']),
197
+ groupLabel: 'Dashboards',
198
+ order: 80,
199
+ }],
200
+ });
201
+ registerMetadataResource({
202
+ type: 'report',
203
+ anchors: [{
204
+ anchorType: 'object',
205
+ match: anchorByField(['object', 'data.object']),
206
+ groupLabel: 'Reports',
207
+ order: 81,
208
+ }],
209
+ });
210
+ }
211
+ /**
212
+ * Coerce an embedded "collection" into a list of `{ name, …rest }`.
213
+ *
214
+ * ObjectStack stores some embedded collections as name-keyed maps
215
+ * (`object.fields`) and others as arrays (`object.indexes`). Both want
216
+ * to render the same way in Related; this helper papers over the gap
217
+ * by injecting the map key as `name` when needed.
218
+ */
219
+ function mapOrArrayToList(raw) {
220
+ if (Array.isArray(raw))
221
+ return raw;
222
+ if (raw && typeof raw === 'object') {
223
+ return Object.entries(raw).map(([k, v]) => {
224
+ const body = v && typeof v === 'object' ? v : {};
225
+ return { name: k, ...body };
226
+ });
227
+ }
228
+ return [];
229
+ }
@@ -15,17 +15,19 @@ export { MetadataDirectoryPage } from './DirectoryPage';
15
15
  export { MetadataResourceRouter } from './ResourceRouter';
16
16
  export { MetadataResourceListPage } from './ResourceListPage';
17
17
  export { MetadataResourceEditPage } from './ResourceEditPage';
18
+ export { RelatedPanel } from './RelatedPanel';
19
+ export { MetadataDetailDrawer } from './MetadataDetailDrawer';
18
20
  export { MetadataResourceHistoryPage } from './ResourceHistoryPage';
19
21
  export { MetadataQuickFind } from './QuickFind';
20
22
  export { PageShell as MetadataPageShell } from './PageShell';
21
23
  export { SchemaForm } from './SchemaForm';
22
24
  export { LayeredDiff } from './LayeredDiff';
23
25
  export { PermissionMatrixEditPage } from './PermissionMatrixEditor';
24
- export { DesignerEditorWrapper } from './DesignerEditorWrapper';
26
+ export { DesignerEditorWrapper, DesignerEditorBody } from './DesignerEditorWrapper';
25
27
  export type { DesignerEditorWrapperProps } from './DesignerEditorWrapper';
26
28
  export { translateMetadataType, translateMetadataDomain, t as translateMetadataAdmin, detectLocale, } from './i18n';
27
29
  export type { SupportedLocale } from './i18n';
28
- export { registerMetadataResource, getMetadataResource, listMetadataResources, resolveResourceConfig, } from './registry';
29
- export type { MetadataResourceConfig, MetadataDomain, } from './registry';
30
+ export { registerMetadataResource, getMetadataResource, listMetadataResources, listAnchorsFor, resolveResourceConfig, anchorByField, } from './registry';
31
+ export type { MetadataResourceConfig, MetadataDomain, MetadataAnchor, } from './registry';
30
32
  export { useMetadataClient, useMetadataTypes, useTypesIndex, matchesQuery, } from './useMetadata';
31
33
  export type { RichMetadataTypeEntry } from './useMetadata';
@@ -16,15 +16,21 @@ export { MetadataDirectoryPage } from './DirectoryPage';
16
16
  export { MetadataResourceRouter } from './ResourceRouter';
17
17
  export { MetadataResourceListPage } from './ResourceListPage';
18
18
  export { MetadataResourceEditPage } from './ResourceEditPage';
19
+ export { RelatedPanel } from './RelatedPanel';
20
+ export { MetadataDetailDrawer } from './MetadataDetailDrawer';
19
21
  export { MetadataResourceHistoryPage } from './ResourceHistoryPage';
20
22
  export { MetadataQuickFind } from './QuickFind';
21
23
  export { PageShell as MetadataPageShell } from './PageShell';
22
24
  export { SchemaForm } from './SchemaForm';
23
25
  export { LayeredDiff } from './LayeredDiff';
24
26
  export { PermissionMatrixEditPage } from './PermissionMatrixEditor';
25
- export { DesignerEditorWrapper } from './DesignerEditorWrapper';
27
+ export { DesignerEditorWrapper, DesignerEditorBody } from './DesignerEditorWrapper';
26
28
  export { translateMetadataType, translateMetadataDomain, t as translateMetadataAdmin, detectLocale, } from './i18n';
27
- export { registerMetadataResource, getMetadataResource, listMetadataResources, resolveResourceConfig, } from './registry';
29
+ export { registerMetadataResource, getMetadataResource, listMetadataResources, listAnchorsFor, resolveResourceConfig, anchorByField, } from './registry';
30
+ // Side-effect: register the built-in anchor relationships so the Related
31
+ // tab works out of the box for objects (hooks, views, pages, …).
32
+ import { registerBuiltinAnchors } from './anchors';
33
+ registerBuiltinAnchors();
28
34
  // Side-effect: register fallback JSONSchemas for the 12 writable types
29
35
  // so the generic SchemaForm renders a real form (vs raw-JSON fallback)
30
36
  // until the framework wires Zod→JSONSchema generation into /meta/types.
@@ -64,11 +64,32 @@ export interface MetadataResourceConfig {
64
64
  }>;
65
65
  /**
66
66
  * Fully custom edit page. Receives `{ type, name }`.
67
+ *
68
+ * Use this when the bespoke editor replaces the JSONSchema form entirely
69
+ * (e.g. PermissionMatrix grid). For visual designers that should coexist
70
+ * with the generic Form/Layers/References tabs, prefer `DesignerTab`.
67
71
  */
68
72
  EditPage?: ComponentType<{
69
73
  type: string;
70
74
  name: string;
71
75
  }>;
76
+ /**
77
+ * Optional visual designer that renders inside its own dedicated tab on
78
+ * the generic edit page, alongside Form / Layers / References. Receives
79
+ * `{ type, name }` and should render without an outer `PageShell` — the
80
+ * generic engine owns the chrome.
81
+ *
82
+ * When both `EditPage` and `DesignerTab` are provided, `EditPage` wins
83
+ * (full takeover); the tab variant is only used when `EditPage` is unset.
84
+ */
85
+ DesignerTab?: ComponentType<{
86
+ type: string;
87
+ name: string;
88
+ }>;
89
+ /**
90
+ * Optional label for the Designer tab. Defaults to "Designer".
91
+ */
92
+ designerTabLabel?: string;
72
93
  /**
73
94
  * Fully custom create page. Receives `{ type }`.
74
95
  */
@@ -96,7 +117,80 @@ export interface MetadataResourceConfig {
96
117
  * If the server registry DOES return a `schema`, this is ignored.
97
118
  */
98
119
  defaultSchema?: Record<string, unknown>;
120
+ /**
121
+ * Declares that items of this type are "anchored" to one or more
122
+ * parent metadata types. The Related tab on the parent's edit page
123
+ * lists every item that matches the anchor predicate.
124
+ *
125
+ * For example, registering `hook` with
126
+ * `{ anchorType: 'object', match: (item, name) => item.object === name }`
127
+ * makes every `hook` whose `object` field equals `account` show up in
128
+ * the Account object's Related tab under a "Hooks" group.
129
+ *
130
+ * Multiple anchors are allowed (e.g. a field could anchor to both
131
+ * `object` and `view`). The Related panel groups by anchored type.
132
+ */
133
+ anchors?: MetadataAnchor[];
99
134
  }
135
+ /**
136
+ * Anchor declaration — "items of type X are children of items of type Y
137
+ * when this predicate matches". Used to power the Related tab.
138
+ *
139
+ * The predicate runs in the client against the unwrapped item shape
140
+ * returned by `client.list(type)`. Keep it cheap (just property reads);
141
+ * we evaluate it once per item per render.
142
+ */
143
+ export interface MetadataAnchor {
144
+ /** The parent metadata type whose Related tab should surface this. */
145
+ anchorType: string;
146
+ /**
147
+ * Where the anchored items live.
148
+ * - 'list' (default): query `client.list(childType)` and filter with
149
+ * `match()`. Used for first-class metadata types (hooks, views, …).
150
+ * - 'embedded': items are stored inside the parent body itself
151
+ * (e.g. `object.fields[]`). `extract()` plucks the array; no
152
+ * network call is made.
153
+ */
154
+ source?: 'list' | 'embedded';
155
+ /**
156
+ * Returns true when the child `item` belongs to the parent identified
157
+ * by `anchorName`. The default helper `byField('object')` covers the
158
+ * common case of an explicit `object: 'foo'` reference. Required when
159
+ * `source === 'list'` (the default); ignored for `'embedded'`.
160
+ */
161
+ match?: (item: Record<string, unknown>, anchorName: string) => boolean;
162
+ /**
163
+ * For `source: 'embedded'` only. Returns the embedded items array
164
+ * given the fully-loaded parent body. Items should already carry a
165
+ * `name` (or be normalised by the renderer).
166
+ */
167
+ extract?: (parentItem: Record<string, unknown>) => Array<Record<string, unknown>>;
168
+ /**
169
+ * Optional override for the group label shown on the Related panel.
170
+ * Defaults to the child type's resolved label.
171
+ */
172
+ groupLabel?: string;
173
+ /**
174
+ * Optional icon override for the group header. Falls back to the
175
+ * child type's registered icon.
176
+ */
177
+ iconName?: string;
178
+ /**
179
+ * Optional explicit ordering hint inside the Related panel. Lower
180
+ * numbers float to the top. Defaults to 100 if unset.
181
+ */
182
+ order?: number;
183
+ }
184
+ /**
185
+ * Helper that returns a `match` reading a (possibly dotted) field path
186
+ * from the item and comparing it to the anchor name.
187
+ *
188
+ * anchorByField('object') // item.object === name
189
+ * anchorByField('data.object') // item.data?.object === name
190
+ * anchorByField(['list.data.object',
191
+ * 'form.data.object']) // either path matches
192
+ */
193
+ export declare function anchorByField(paths: string | string[]): MetadataAnchor['match'];
100
194
  /**
101
195
  * Register (or merge) an entry. Idempotent — re-registering with the
102
196
  * same `type` merges the new fields with any existing entry so that
@@ -109,6 +203,19 @@ export declare function registerMetadataResource(config: MetadataResourceConfig)
109
203
  export declare function getMetadataResource(type: string): MetadataResourceConfig | undefined;
110
204
  /** Snapshot of all registered entries (diagnostics; directory page). */
111
205
  export declare function listMetadataResources(): MetadataResourceConfig[];
206
+ /**
207
+ * Build the list of child types whose items can be "anchored to" a
208
+ * parent type. Used by the Related tab to decide which `client.list`
209
+ * calls to make.
210
+ *
211
+ * Returns an array of `{ type, anchor }` pairs, sorted by `anchor.order`
212
+ * (lower first) and then by child type label for stable rendering.
213
+ */
214
+ export declare function listAnchorsFor(parentType: string): Array<{
215
+ type: string;
216
+ config: MetadataResourceConfig;
217
+ anchor: MetadataAnchor;
218
+ }>;
112
219
  /**
113
220
  * Merge a registered config (if any) with server-side defaults from
114
221
  * `/meta/types`. Server fields win for label/description/domain