@object-ui/app-shell 6.2.0 → 6.2.2

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/console/AppContent.js +30 -1
  3. package/dist/console/ai/AiChatPage.js +19 -22
  4. package/dist/layout/ConsoleFloatingChatbot.js +44 -24
  5. package/dist/services/builtinComponents.js +10 -7
  6. package/dist/views/metadata-admin/DesignerEditorWrapper.d.ts +11 -1
  7. package/dist/views/metadata-admin/DesignerEditorWrapper.js +21 -3
  8. package/dist/views/metadata-admin/DirectoryPage.js +1 -1
  9. package/dist/views/metadata-admin/EmbeddedItemEditor.d.ts +15 -0
  10. package/dist/views/metadata-admin/EmbeddedItemEditor.js +194 -0
  11. package/dist/views/metadata-admin/MetadataDetailDrawer.d.ts +13 -0
  12. package/dist/views/metadata-admin/MetadataDetailDrawer.js +28 -0
  13. package/dist/views/metadata-admin/PageShell.js +9 -2
  14. package/dist/views/metadata-admin/QuickFind.js +2 -2
  15. package/dist/views/metadata-admin/RelatedPanel.d.ts +37 -0
  16. package/dist/views/metadata-admin/RelatedPanel.js +173 -0
  17. package/dist/views/metadata-admin/ResourceEditPage.d.ts +9 -3
  18. package/dist/views/metadata-admin/ResourceEditPage.js +105 -14
  19. package/dist/views/metadata-admin/ResourceHistoryPage.d.ts +3 -3
  20. package/dist/views/metadata-admin/ResourceHistoryPage.js +6 -3
  21. package/dist/views/metadata-admin/ResourceListPage.d.ts +2 -2
  22. package/dist/views/metadata-admin/ResourceListPage.js +6 -4
  23. package/dist/views/metadata-admin/SchemaForm.js +10 -1
  24. package/dist/views/metadata-admin/anchors.d.ts +1 -0
  25. package/dist/views/metadata-admin/anchors.js +235 -0
  26. package/dist/views/metadata-admin/index.d.ts +7 -3
  27. package/dist/views/metadata-admin/index.js +14 -2
  28. package/dist/views/metadata-admin/preview-registry.d.ts +43 -0
  29. package/dist/views/metadata-admin/preview-registry.js +18 -0
  30. package/dist/views/metadata-admin/previews/AppPreview.d.ts +2 -0
  31. package/dist/views/metadata-admin/previews/AppPreview.js +101 -0
  32. package/dist/views/metadata-admin/previews/DashboardPreview.d.ts +2 -0
  33. package/dist/views/metadata-admin/previews/DashboardPreview.js +25 -0
  34. package/dist/views/metadata-admin/previews/EmailTemplatePreview.d.ts +2 -0
  35. package/dist/views/metadata-admin/previews/EmailTemplatePreview.js +65 -0
  36. package/dist/views/metadata-admin/previews/ObjectPreview.d.ts +2 -0
  37. package/dist/views/metadata-admin/previews/ObjectPreview.js +36 -0
  38. package/dist/views/metadata-admin/previews/PagePreview.d.ts +2 -0
  39. package/dist/views/metadata-admin/previews/PagePreview.js +26 -0
  40. package/dist/views/metadata-admin/previews/PreviewShell.d.ts +40 -0
  41. package/dist/views/metadata-admin/previews/PreviewShell.js +49 -0
  42. package/dist/views/metadata-admin/previews/ReportPreview.d.ts +2 -0
  43. package/dist/views/metadata-admin/previews/ReportPreview.js +27 -0
  44. package/dist/views/metadata-admin/previews/ViewPreview.d.ts +2 -0
  45. package/dist/views/metadata-admin/previews/ViewPreview.js +113 -0
  46. package/dist/views/metadata-admin/previews/index.d.ts +1 -0
  47. package/dist/views/metadata-admin/previews/index.js +26 -0
  48. package/dist/views/metadata-admin/registry.d.ts +124 -0
  49. package/dist/views/metadata-admin/registry.js +58 -0
  50. package/package.json +26 -26
@@ -0,0 +1,194 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
3
+ /**
4
+ * EmbeddedItemEditor — full-form editor for items that live INSIDE a
5
+ * parent metadata body (e.g. `object.fields.email`).
6
+ *
7
+ * Embedded items don't have their own HTTP endpoint (`PUT /meta/field/email`
8
+ * does NOT exist for object-scoped fields) — so we:
9
+ * 1. Re-fetch the parent's effective body.
10
+ * 2. Render a SchemaForm using the registered sub-type's schema / form
11
+ * (e.g. `field` for `object.fields`).
12
+ * 3. On save: deep-clone the parent, splice the modified item back
13
+ * under `parent.<embeddedPath>.<itemName>`, and PUT the parent.
14
+ *
15
+ * If the sub-type isn't registered (e.g. `index` has no `editAs`), we
16
+ * fall back to a raw-JSON editor so users can still hand-edit and save.
17
+ */
18
+ import * as React from 'react';
19
+ import { Loader2, Save, AlertTriangle } from 'lucide-react';
20
+ import { Button } from '@object-ui/components';
21
+ import { SchemaForm } from './SchemaForm';
22
+ import { useMetadataClient, useMetadataTypes } from './useMetadata';
23
+ export function EmbeddedItemEditor({ parentType, parentName, embeddedPath, itemName, editAs, initialRaw, onSaved, }) {
24
+ const client = useMetadataClient();
25
+ const { entries } = useMetadataTypes(client);
26
+ const subEntry = editAs ? entries.find((e) => e.type === editAs) : undefined;
27
+ // Fallback inline schemas for sub-types the framework registers without
28
+ // a JSON-Schema (validation / index live in object.body but the
29
+ // registry only exposes their metadata, not their shape). The
30
+ // schemas below mirror the framework's Zod definitions and the
31
+ // shapes we see on disk under packages/data-objectstack fixtures.
32
+ const fallback = !subEntry?.schema && editAs ? FALLBACK_SCHEMAS[editAs] : undefined;
33
+ const schema = subEntry?.schema ?? fallback?.schema;
34
+ const form = subEntry?.form ?? fallback?.form;
35
+ const [draft, setDraft] = React.useState(initialRaw);
36
+ const [saving, setSaving] = React.useState(false);
37
+ const [error, setError] = React.useState(null);
38
+ const [issues, setIssues] = React.useState([]);
39
+ const [savedAt, setSavedAt] = React.useState(null);
40
+ // Resync if a different item is opened in the drawer.
41
+ React.useEffect(() => {
42
+ setDraft(initialRaw);
43
+ setError(null);
44
+ setIssues([]);
45
+ setSavedAt(null);
46
+ }, [initialRaw, itemName, parentType, parentName]);
47
+ const readOnly = subEntry != null && !subEntry.allowOrgOverride;
48
+ async function doSave() {
49
+ if (!embeddedPath) {
50
+ setError('Cannot save: this item has no embeddedPath registered.');
51
+ return;
52
+ }
53
+ setSaving(true);
54
+ setError(null);
55
+ setIssues([]);
56
+ try {
57
+ // 1. Re-fetch parent to avoid clobbering concurrent edits.
58
+ const layered = await client.layered(parentType, parentName);
59
+ const parent = (layered.effective ?? layered.code ?? {});
60
+ // 2. Splice modified item back into the parent collection.
61
+ const updated = spliceEmbedded(parent, embeddedPath, itemName, draft);
62
+ // 3. PUT the parent.
63
+ await client.save(parentType, parentName, updated);
64
+ setSavedAt(Date.now());
65
+ onSaved?.(draft);
66
+ }
67
+ catch (err) {
68
+ // Validation issues from the parent save apply to the embedded
69
+ // path. Try to scope them back to this item.
70
+ if (err?.status === 422 || err?.code === 'invalid_metadata' || err?.code === 'invalid_payload') {
71
+ const raw = err?.body?.issues ?? [];
72
+ const mapped = (Array.isArray(raw) ? raw : []).map((x) => {
73
+ const fullPath = Array.isArray(x.path) ? x.path.join('.') : String(x.path ?? '');
74
+ // Trim the `<embeddedPath>.<itemName>.` prefix so issues
75
+ // align with the field they reference inside the sub-form.
76
+ const prefix = `${embeddedPath}.${itemName}.`;
77
+ const trimmed = fullPath.startsWith(prefix)
78
+ ? fullPath.slice(prefix.length)
79
+ : fullPath;
80
+ return { path: trimmed, message: String(x.message ?? 'Invalid') };
81
+ });
82
+ setIssues(mapped);
83
+ setError(`Validation failed (${mapped.length} issue${mapped.length === 1 ? '' : 's'}).`);
84
+ }
85
+ else {
86
+ setError(err?.message ?? String(err));
87
+ }
88
+ }
89
+ finally {
90
+ setSaving(false);
91
+ }
92
+ }
93
+ // No schema registered for this sub-type: fall back to JSON editing.
94
+ if (!schema) {
95
+ return (_jsxs("div", { className: "p-4 space-y-3", children: [_jsxs("div", { className: "text-xs text-muted-foreground", children: ["No form schema is registered for", ' ', _jsx("code", { className: "font-mono", children: editAs ?? 'this item' }), ". Edit the raw JSON below; saving will splice it back into", ' ', _jsxs("span", { className: "font-mono", children: [parentType, "/", parentName, ".", embeddedPath, ".", itemName] }), "."] }), _jsx("textarea", { className: "w-full h-[60vh] font-mono text-xs border rounded p-3 bg-muted/30", value: JSON.stringify(draft, null, 2), onChange: (e) => {
96
+ try {
97
+ setDraft(JSON.parse(e.target.value));
98
+ setError(null);
99
+ }
100
+ catch (err) {
101
+ setError(`Invalid JSON: ${err.message}`);
102
+ }
103
+ } }), error && (_jsxs("div", { className: "text-sm text-destructive border border-destructive/30 rounded p-2 bg-destructive/5", children: [_jsx(AlertTriangle, { className: "h-4 w-4 inline mr-1" }), " ", error] })), _jsx("div", { className: "flex justify-end", children: _jsxs(Button, { onClick: doSave, disabled: saving || !embeddedPath, children: [saving ? (_jsx(Loader2, { className: "h-4 w-4 mr-1 animate-spin" })) : (_jsx(Save, { className: "h-4 w-4 mr-1" })), "Save into parent"] }) })] }));
104
+ }
105
+ return (_jsxs("div", { className: "p-4 space-y-4", children: [error && (_jsx("div", { className: "text-sm text-destructive border border-destructive/30 rounded p-3 bg-destructive/5", children: error })), savedAt != null && !error && (_jsx("div", { className: "text-sm text-emerald-700 border border-emerald-300 bg-emerald-50 rounded p-2", children: "Saved." })), readOnly && (_jsx("div", { className: "text-xs text-amber-800 border border-amber-300 bg-amber-50 rounded p-2", children: "The parent type is read-only \u2014 saving will still attempt a PUT and may be refused by the server." })), _jsx(SchemaForm, { schema: schema, form: form, value: draft, onChange: setDraft, issues: issues }), _jsx("div", { className: "flex justify-end gap-2 pt-2 border-t", children: _jsxs(Button, { onClick: doSave, disabled: saving || !embeddedPath, children: [saving ? (_jsx(Loader2, { className: "h-4 w-4 mr-1 animate-spin" })) : (_jsx(Save, { className: "h-4 w-4 mr-1" })), "Save into ", parentType] }) }), !embeddedPath && (_jsxs("div", { className: "text-xs text-muted-foreground border rounded p-3", children: [_jsx("div", { className: "font-medium", children: "Read-only" }), _jsx("div", { children: "No embedded path registered \u2014 cannot determine where to write this item back." })] }))] }));
106
+ }
107
+ /**
108
+ * Return a NEW parent object with the embedded item replaced. The
109
+ * collection at `path` may be either a name-keyed map (`object.fields`)
110
+ * or an array of `{ name, … }` entries (`object.indexes`); we keep the
111
+ * existing shape.
112
+ */
113
+ function spliceEmbedded(parent, path, itemName, item) {
114
+ const segs = path.split('.');
115
+ const next = structuredClone(parent);
116
+ let cur = next;
117
+ for (let i = 0; i < segs.length - 1; i++) {
118
+ if (cur[segs[i]] == null)
119
+ cur[segs[i]] = {};
120
+ cur = cur[segs[i]];
121
+ }
122
+ const leaf = segs[segs.length - 1];
123
+ const existing = cur[leaf];
124
+ if (Array.isArray(existing)) {
125
+ const idx = existing.findIndex((x) => x && typeof x === 'object' && x.name === itemName);
126
+ if (idx >= 0) {
127
+ existing[idx] = item;
128
+ }
129
+ else {
130
+ existing.push(item);
131
+ }
132
+ }
133
+ else if (existing && typeof existing === 'object') {
134
+ // Strip the synthetic `name` we injected when extracting the map.
135
+ const { name: _n, ...rest } = item;
136
+ existing[itemName] = rest;
137
+ }
138
+ else {
139
+ // Path didn't exist — initialise as a map keyed by item name.
140
+ const { name: _n, ...rest } = item;
141
+ cur[leaf] = { [itemName]: rest };
142
+ }
143
+ return next;
144
+ }
145
+ /**
146
+ * Inline JSONSchema + form-spec fallbacks for sub-types that the
147
+ * framework's `/meta` registry doesn't expose a `schema`/`form` for.
148
+ *
149
+ * `index` is the only one we still need to ship here: it's an
150
+ * embedded-only sub-type (lives inside `object.indexes[]`) and is not
151
+ * registered as a standalone metadata type, so the server has no slot
152
+ * to publish a schema for it. Once / if the framework grows an
153
+ * embedded-sub-type registry, this can move server-side too.
154
+ *
155
+ * `validation` used to be here but is now published by the server via
156
+ * `HAND_CRAFTED_SCHEMAS.validation` in `objectql/protocol.ts`.
157
+ */
158
+ const FALLBACK_SCHEMAS = {
159
+ index: {
160
+ schema: {
161
+ type: 'object',
162
+ required: ['fields'],
163
+ properties: {
164
+ name: {
165
+ type: 'string',
166
+ title: 'Name',
167
+ description: 'Synthesised from columns if omitted (e.g. idx_email).',
168
+ },
169
+ fields: {
170
+ type: 'array',
171
+ title: 'Fields',
172
+ description: 'Columns to index, in order.',
173
+ items: { type: 'string' },
174
+ },
175
+ type: {
176
+ type: 'string',
177
+ title: 'Algorithm',
178
+ enum: ['btree', 'hash', 'gin', 'gist', 'brin'],
179
+ default: 'btree',
180
+ },
181
+ unique: {
182
+ type: 'boolean',
183
+ title: 'Unique',
184
+ description: 'Enforce uniqueness across the indexed columns.',
185
+ },
186
+ where: {
187
+ type: 'string',
188
+ title: 'Partial-index predicate',
189
+ description: 'Optional WHERE clause for a partial index.',
190
+ },
191
+ },
192
+ },
193
+ },
194
+ };
@@ -0,0 +1,13 @@
1
+ import type { RelatedTarget } from './RelatedPanel';
2
+ export interface MetadataDetailDrawerProps {
3
+ /** When non-null, drawer is open and shows this target. */
4
+ target: RelatedTarget | null;
5
+ /** Called when the drawer requests close (overlay click, esc, close btn). */
6
+ onClose: () => void;
7
+ /** Optional context: parent's type / name, shown in the title. */
8
+ parentContext?: {
9
+ type: string;
10
+ name: string;
11
+ };
12
+ }
13
+ export declare function MetadataDetailDrawer({ target, onClose, parentContext, }: MetadataDetailDrawerProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { ExternalLink } from 'lucide-react';
4
+ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, Button, Badge, cn, } from '@object-ui/components';
5
+ import { MetadataResourceEditPage } from './ResourceEditPage';
6
+ import { EmbeddedItemEditor } from './EmbeddedItemEditor';
7
+ export function MetadataDetailDrawer({ target, onClose, parentContext, }) {
8
+ const navigate = useNavigate();
9
+ const isMetadata = target?.kind === 'metadata';
10
+ const isEmbedded = target?.kind === 'embedded';
11
+ const headerType = isMetadata
12
+ ? target.type
13
+ : isEmbedded
14
+ ? target.groupLabel
15
+ : '';
16
+ const headerName = isMetadata
17
+ ? target.name
18
+ : isEmbedded
19
+ ? target.itemName
20
+ : '';
21
+ return (_jsx(Sheet, { open: target !== null, onOpenChange: (open) => {
22
+ if (!open)
23
+ onClose();
24
+ }, children: _jsxs(SheetContent, { side: "right", className: cn('w-[92vw] sm:max-w-[1100px] p-0 flex flex-col gap-0'), children: [_jsxs(SheetHeader, { className: "px-4 py-3 border-b space-y-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "font-mono text-[10px]", children: headerType }), _jsx(SheetTitle, { className: "font-mono text-base truncate", children: headerName }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: isMetadata && (_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => {
25
+ navigate(`../../${encodeURIComponent(target.type)}/${encodeURIComponent(target.name)}`);
26
+ onClose();
27
+ }, title: "Open in full page", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5 mr-1" }), "Open full page"] })) })] }), parentContext && (_jsxs(SheetDescription, { className: "text-xs", children: [isEmbedded ? 'Embedded in ' : 'Related to ', _jsxs("span", { className: "font-mono", children: [parentContext.type, "/", parentContext.name] })] }))] }), _jsxs("div", { className: "flex-1 min-h-0 overflow-auto", children: [isMetadata && (_jsx(MetadataResourceEditPage, { type: target.type, name: target.name, embedded: true }, `${target.type}/${target.name}`)), isEmbedded && (_jsx(EmbeddedItemEditor, { parentType: target.parentType, parentName: target.parentName, embeddedPath: target.embeddedPath, itemName: target.itemName, editAs: target.editAs, initialRaw: target.raw }, `${target.parentType}/${target.parentName}/${target.embeddedPath}/${target.itemName}`))] })] }) }));
28
+ }
@@ -15,7 +15,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
15
15
  * page bodies stay focused on their domain logic.
16
16
  */
17
17
  import * as React from 'react';
18
- import { Link } from 'react-router-dom';
18
+ import { Link, useLocation } from 'react-router-dom';
19
19
  import { Badge } from '@object-ui/components';
20
20
  import { ChevronRight } from 'lucide-react';
21
21
  import { detectLocale, t, translateMetadataType } from './i18n';
@@ -24,7 +24,14 @@ export function PageShell({ entry, itemName, subtitle, stats, actions, children,
24
24
  const locale = React.useMemo(() => detectLocale(), []);
25
25
  // Prefer locale-table translation over server's English label.
26
26
  const label = translateMetadataType(type, locale, entry?.label);
27
- return (_jsxs("div", { className: "flex flex-col h-full overflow-hidden", children: [_jsxs("div", { className: "px-6 pt-5 pb-4 border-b bg-background", children: [_jsxs("nav", { className: "flex items-center gap-1.5 text-xs text-muted-foreground mb-2", children: [_jsx(Link, { to: "../component/metadata/directory", className: "hover:text-foreground", children: t('engine.breadcrumb.allTypes', locale) }), _jsx(ChevronRight, { className: "h-3 w-3" }), _jsx(Link, { to: `../component/metadata/resource?type=${encodeURIComponent(type)}`, className: "hover:text-foreground", children: label }), itemName && (_jsxs(_Fragment, { children: [_jsx(ChevronRight, { className: "h-3 w-3" }), _jsx("span", { className: "text-foreground font-medium font-mono", children: itemName })] }))] }), _jsxs("div", { className: "flex items-start justify-between gap-3 flex-wrap", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx("h1", { className: "text-xl font-semibold truncate", children: label }), _jsx("code", { className: "text-xs font-mono text-muted-foreground", children: type }), entry?.domain && (_jsx(Badge, { variant: "outline", className: "text-[10px] uppercase tracking-wider", children: entry.domain })), entry?.allowOrgOverride ? (_jsx(Badge, { className: 'text-[10px] ' +
27
+ // Compute base path up to /metadata so breadcrumb links work regardless
28
+ // of how deep the current route is (list, edit, history, …).
29
+ const { pathname } = useLocation();
30
+ const metadataBase = React.useMemo(() => {
31
+ const idx = pathname.indexOf('/metadata');
32
+ return idx >= 0 ? pathname.slice(0, idx + '/metadata'.length) : '/metadata';
33
+ }, [pathname]);
34
+ return (_jsxs("div", { className: "flex flex-col h-full overflow-hidden", children: [_jsxs("div", { className: "px-6 pt-5 pb-4 border-b bg-background", children: [_jsxs("nav", { className: "flex items-center gap-1.5 text-xs text-muted-foreground mb-2", children: [_jsx(Link, { to: metadataBase, className: "hover:text-foreground", children: t('engine.breadcrumb.allTypes', locale) }), _jsx(ChevronRight, { className: "h-3 w-3" }), _jsx(Link, { to: `${metadataBase}/${encodeURIComponent(type)}`, className: "hover:text-foreground", children: label }), itemName && (_jsxs(_Fragment, { children: [_jsx(ChevronRight, { className: "h-3 w-3" }), _jsx("span", { className: "text-foreground font-medium font-mono", children: itemName })] }))] }), _jsxs("div", { className: "flex items-start justify-between gap-3 flex-wrap", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx("h1", { className: "text-xl font-semibold truncate", children: label }), _jsx("code", { className: "text-xs font-mono text-muted-foreground", children: type }), entry?.domain && (_jsx(Badge, { variant: "outline", className: "text-[10px] uppercase tracking-wider", children: entry.domain })), entry?.allowOrgOverride ? (_jsx(Badge, { className: 'text-[10px] ' +
28
35
  (entry.overrideSource === 'env'
29
36
  ? 'bg-amber-100 text-amber-800 hover:bg-amber-100'
30
37
  : 'bg-emerald-100 text-emerald-800 hover:bg-emerald-100'), title: entry.overrideSource === 'env'
@@ -123,10 +123,10 @@ export function MetadataQuickFind({ appSlug } = {}) {
123
123
  setOpen(false);
124
124
  const base = appSlug ? `/apps/${appSlug}` : '..';
125
125
  if (r.kind === 'type') {
126
- navigate(`${base}/component/metadata/resource?type=${encodeURIComponent(r.entry.type)}`);
126
+ navigate(`${base}/metadata/${encodeURIComponent(r.entry.type)}`);
127
127
  }
128
128
  else {
129
- navigate(`${base}/component/metadata/resource/${encodeURIComponent(r.name)}?type=${encodeURIComponent(r.type)}`);
129
+ navigate(`${base}/metadata/${encodeURIComponent(r.type)}/${encodeURIComponent(r.name)}`);
130
130
  }
131
131
  }
132
132
  function onKeyDown(e) {
@@ -0,0 +1,37 @@
1
+ export interface RelatedPanelProps {
2
+ /** Parent metadata type, e.g. `object`. */
3
+ type: string;
4
+ /** Parent item name (e.g. `sys_user`). */
5
+ name: string;
6
+ /**
7
+ * Effective parent body — used to feed `source: 'embedded'` anchors
8
+ * (e.g. `object.fields[]`). Optional: if absent, embedded groups are
9
+ * skipped.
10
+ */
11
+ parentItem?: Record<string, unknown> | null;
12
+ /** Invoked when the user clicks a row. Parent should open a drawer. */
13
+ onOpen: (target: RelatedTarget) => void;
14
+ }
15
+ /**
16
+ * Target opened from a Related row. For first-class metadata items
17
+ * `kind: 'metadata'` carries the addressable (type, name). For embedded
18
+ * items we hand back the raw object plus the path it came from so the
19
+ * parent can render a focused detail view.
20
+ */
21
+ export type RelatedTarget = {
22
+ kind: 'metadata';
23
+ type: string;
24
+ name: string;
25
+ } | {
26
+ kind: 'embedded';
27
+ parentType: string;
28
+ parentName: string;
29
+ groupLabel: string;
30
+ itemName: string;
31
+ raw: Record<string, unknown>;
32
+ /** Metadata type whose schema drives the editor (e.g. 'field'). */
33
+ editAs?: string;
34
+ /** Dotted path inside parent where this collection lives (e.g. 'fields'). */
35
+ embeddedPath?: string;
36
+ };
37
+ export declare function RelatedPanel({ type, name, parentItem, onOpen, }: RelatedPanelProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,173 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
3
+ /**
4
+ * RelatedPanel — lists metadata items anchored to the current parent.
5
+ *
6
+ * Driven by the anchor registry (see `anchors.ts` / `registry.ts`). For
7
+ * each child type that declares it anchors at `parentType`, we issue
8
+ * one `client.list(childType)` call in parallel, filter the result with
9
+ * the anchor's match predicate, and render the survivors in collapsible
10
+ * groups.
11
+ *
12
+ * Why client-side filter? Anchoring lives in metadata bodies that are
13
+ * not indexed on the server today, and adding a parameterised list API
14
+ * for every potential anchor field would balloon the surface area. The
15
+ * Related tab is opened by humans on a specific object, so the cost of
16
+ * pulling 100-or-so items per child type is negligible — far cheaper
17
+ * than a network round-trip per row would have been.
18
+ *
19
+ * Visual model:
20
+ * - One <details> per group (default open if non-empty).
21
+ * - Rows are kebab-y: name, optional label, optional badge.
22
+ * - Click → call `onOpen({ type, name })`; parent owns the drawer.
23
+ * - A search input filters across all groups by name/label substring.
24
+ */
25
+ import * as React from 'react';
26
+ import { useNavigate } from 'react-router-dom';
27
+ import { Loader2, Search, Plus, ChevronRight, ExternalLink } from 'lucide-react';
28
+ import { Badge, Button, Input, Empty, EmptyTitle, EmptyDescription, cn, } from '@object-ui/components';
29
+ import { useMetadataClient } from './useMetadata';
30
+ import { listAnchorsFor } from './registry';
31
+ export function RelatedPanel({ type, name, parentItem, onOpen, }) {
32
+ const client = useMetadataClient();
33
+ const navigate = useNavigate();
34
+ const anchors = React.useMemo(() => listAnchorsFor(type), [type]);
35
+ const [groups, setGroups] = React.useState([]);
36
+ const [search, setSearch] = React.useState('');
37
+ const [collapsed, setCollapsed] = React.useState({});
38
+ React.useEffect(() => {
39
+ if (anchors.length === 0) {
40
+ setGroups([]);
41
+ return;
42
+ }
43
+ let cancelled = false;
44
+ // Seed groups in loading state, ordered by registry order. Embedded
45
+ // groups resolve synchronously from `parentItem`; list groups dispatch
46
+ // a `client.list(childType)` and filter.
47
+ const initial = [...anchors]
48
+ .sort((a, b) => (a.anchor.order ?? 999) - (b.anchor.order ?? 999))
49
+ .map((entry) => {
50
+ const isEmbedded = entry.anchor.source === 'embedded';
51
+ if (isEmbedded) {
52
+ const raw = entry.anchor.extract && parentItem
53
+ ? entry.anchor.extract(parentItem)
54
+ : [];
55
+ return {
56
+ childType: entry.type,
57
+ anchor: entry.anchor,
58
+ loading: false,
59
+ error: null,
60
+ items: raw.map(normaliseItem),
61
+ };
62
+ }
63
+ return {
64
+ childType: entry.type,
65
+ anchor: entry.anchor,
66
+ loading: true,
67
+ error: null,
68
+ items: [],
69
+ };
70
+ });
71
+ setGroups(initial);
72
+ void Promise.all(initial.map(async (g) => {
73
+ if (g.anchor.source === 'embedded')
74
+ return g;
75
+ try {
76
+ const list = (await client.list(g.childType));
77
+ if (cancelled)
78
+ return g;
79
+ const matchFn = g.anchor.match ?? (() => false);
80
+ const filtered = list
81
+ .filter((item) => matchFn(item, name))
82
+ .map((item) => normaliseItem(item));
83
+ return { ...g, loading: false, items: filtered };
84
+ }
85
+ catch (err) {
86
+ if (cancelled)
87
+ return g;
88
+ return {
89
+ ...g,
90
+ loading: false,
91
+ error: err instanceof Error ? err.message : String(err),
92
+ };
93
+ }
94
+ })).then((finished) => {
95
+ if (cancelled)
96
+ return;
97
+ setGroups(finished);
98
+ });
99
+ return () => {
100
+ cancelled = true;
101
+ };
102
+ }, [client, type, name, anchors, parentItem]);
103
+ if (anchors.length === 0) {
104
+ return (_jsxs(Empty, { children: [_jsx(EmptyTitle, { children: "No related metadata" }), _jsxs(EmptyDescription, { children: ["No metadata types are configured to anchor at ", _jsx("code", { children: type }), ". You can register one via", ' ', _jsx("code", { className: "font-mono", children: "registerMetadataResource" }), "."] })] }));
105
+ }
106
+ const totalCount = groups.reduce((sum, g) => sum + g.items.length, 0);
107
+ const anyLoading = groups.some((g) => g.loading);
108
+ const q = search.trim().toLowerCase();
109
+ return (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "relative flex-1 max-w-sm", children: [_jsx(Search, { className: "absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" }), _jsx(Input, { placeholder: "Search related\u2026", value: search, onChange: (e) => setSearch(e.target.value), className: "pl-8 h-8 text-sm" })] }), _jsx("div", { className: "text-xs text-muted-foreground", children: anyLoading
110
+ ? 'Scanning…'
111
+ : `${totalCount} item${totalCount === 1 ? '' : 's'}` })] }), groups.map((g) => {
112
+ const matches = q
113
+ ? g.items.filter((it) => it.name.toLowerCase().includes(q) ||
114
+ (it.label ?? '').toLowerCase().includes(q))
115
+ : g.items;
116
+ const isCollapsed = collapsed[g.childType] ?? false;
117
+ const visible = !q || matches.length > 0;
118
+ if (!visible && !g.loading)
119
+ return null;
120
+ return (_jsxs("div", { className: "border rounded-lg overflow-hidden", children: [_jsxs("div", { className: "w-full flex items-center gap-2 px-3 py-2 bg-muted/40", children: [_jsxs("button", { type: "button", className: "flex items-center gap-2 flex-1 text-left hover:opacity-80", onClick: () => setCollapsed((s) => ({ ...s, [g.childType]: !isCollapsed })), children: [_jsx(ChevronRight, { className: cn('h-4 w-4 text-muted-foreground transition-transform', !isCollapsed && 'rotate-90') }), _jsx("div", { className: "text-sm font-medium", children: g.anchor.groupLabel ?? g.childType }), _jsx(Badge, { variant: "outline", className: "text-[10px]", children: g.loading ? '…' : matches.length })] }), _jsx(Button, { variant: "ghost", size: "sm", className: "h-7 px-2", onClick: () => {
121
+ if (g.anchor.source === 'embedded') {
122
+ // Embedded items are edited inside the parent's Form
123
+ // tab; jump there rather than to a nonexistent route.
124
+ if (typeof window !== 'undefined') {
125
+ const url = new URL(window.location.href);
126
+ url.searchParams.set('tab', 'form');
127
+ url.searchParams.delete('open');
128
+ window.location.assign(url.toString());
129
+ }
130
+ return;
131
+ }
132
+ navigate(`../../${encodeURIComponent(g.childType)}/_new?anchor=${encodeURIComponent(`${type}:${name}`)}`);
133
+ }, title: g.anchor.source === 'embedded'
134
+ ? `Edit in Form tab`
135
+ : `New ${g.childType}`, children: _jsx(Plus, { className: "h-3.5 w-3.5" }) })] }), !isCollapsed && (_jsxs("div", { className: "divide-y", children: [g.loading && (_jsxs("div", { className: "px-3 py-3 text-xs text-muted-foreground flex items-center gap-2", children: [_jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }), "Loading ", g.childType, "\u2026"] })), !g.loading && g.error && (_jsxs("div", { className: "px-3 py-3 text-xs text-destructive", children: ["Failed: ", g.error] })), !g.loading && !g.error && matches.length === 0 && (_jsx("div", { className: "px-3 py-3 text-xs text-muted-foreground", children: q ? 'No matches.' : 'Nothing here yet.' })), !g.loading &&
136
+ !g.error &&
137
+ matches.map((it, idx) => (_jsxs("button", { type: "button", onClick: () => onOpen(g.anchor.source === 'embedded'
138
+ ? {
139
+ kind: 'embedded',
140
+ parentType: type,
141
+ parentName: name,
142
+ groupLabel: g.anchor.groupLabel ?? g.childType,
143
+ itemName: it.name,
144
+ raw: it.raw,
145
+ editAs: g.anchor.editAs,
146
+ embeddedPath: g.anchor.embeddedPath,
147
+ }
148
+ : {
149
+ kind: 'metadata',
150
+ type: g.childType,
151
+ name: it.name,
152
+ }), className: "w-full text-left px-3 py-2 hover:bg-accent/50 flex items-center gap-3", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "font-mono text-xs truncate", children: it.name }), it.label && it.label !== it.name && (_jsx("div", { className: "text-xs text-muted-foreground truncate", children: it.label }))] }), _jsx(ExternalLink, { className: "h-3.5 w-3.5 text-muted-foreground" })] }, `${it.name}-${idx}`)))] }))] }, g.childType));
153
+ })] }));
154
+ }
155
+ function normaliseItem(raw) {
156
+ const nameVal = pickString(raw, ['name', 'id', 'key']) ?? '(unnamed)';
157
+ const labelVal = pickString(raw, ['label', 'title', 'displayName']);
158
+ const descVal = pickString(raw, ['description', 'summary']);
159
+ return {
160
+ name: nameVal,
161
+ label: labelVal,
162
+ description: descVal,
163
+ raw,
164
+ };
165
+ }
166
+ function pickString(obj, keys) {
167
+ for (const k of keys) {
168
+ const v = obj[k];
169
+ if (typeof v === 'string' && v.length > 0)
170
+ return v;
171
+ }
172
+ return undefined;
173
+ }
@@ -1,7 +1,13 @@
1
1
  export interface MetadataResourceEditPageProps {
2
- type: string;
3
- name: string;
2
+ type?: string;
3
+ name?: string;
4
4
  /** When true, this is the Create flow (skip initial fetch). */
5
5
  createMode?: boolean;
6
+ /**
7
+ * When true, the editor is rendered inside another surface (e.g.
8
+ * the Related drawer). Hides Related-tab and URL-sync so the inner
9
+ * page does not fight the outer page for `?tab` / `?open`.
10
+ */
11
+ embedded?: boolean;
6
12
  }
7
- export declare function MetadataResourceEditPage({ type, name, createMode, }: MetadataResourceEditPageProps): import("react/jsx-runtime").JSX.Element;
13
+ export declare function MetadataResourceEditPage({ type: typeProp, name: nameProp, createMode, embedded, }: MetadataResourceEditPageProps): import("react/jsx-runtime").JSX.Element;