@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.
- package/CHANGELOG.md +61 -0
- package/dist/console/AppContent.js +30 -1
- package/dist/console/ai/AiChatPage.js +19 -22
- package/dist/layout/ConsoleFloatingChatbot.js +44 -24
- package/dist/services/builtinComponents.js +10 -7
- package/dist/views/metadata-admin/DesignerEditorWrapper.d.ts +11 -1
- package/dist/views/metadata-admin/DesignerEditorWrapper.js +21 -3
- package/dist/views/metadata-admin/DirectoryPage.js +1 -1
- package/dist/views/metadata-admin/EmbeddedItemEditor.d.ts +15 -0
- package/dist/views/metadata-admin/EmbeddedItemEditor.js +194 -0
- package/dist/views/metadata-admin/MetadataDetailDrawer.d.ts +13 -0
- package/dist/views/metadata-admin/MetadataDetailDrawer.js +28 -0
- package/dist/views/metadata-admin/PageShell.js +9 -2
- package/dist/views/metadata-admin/QuickFind.js +2 -2
- package/dist/views/metadata-admin/RelatedPanel.d.ts +37 -0
- package/dist/views/metadata-admin/RelatedPanel.js +173 -0
- package/dist/views/metadata-admin/ResourceEditPage.d.ts +9 -3
- package/dist/views/metadata-admin/ResourceEditPage.js +105 -14
- package/dist/views/metadata-admin/ResourceHistoryPage.d.ts +3 -3
- package/dist/views/metadata-admin/ResourceHistoryPage.js +6 -3
- package/dist/views/metadata-admin/ResourceListPage.d.ts +2 -2
- package/dist/views/metadata-admin/ResourceListPage.js +6 -4
- package/dist/views/metadata-admin/SchemaForm.js +10 -1
- package/dist/views/metadata-admin/anchors.d.ts +1 -0
- package/dist/views/metadata-admin/anchors.js +235 -0
- package/dist/views/metadata-admin/index.d.ts +7 -3
- package/dist/views/metadata-admin/index.js +14 -2
- package/dist/views/metadata-admin/preview-registry.d.ts +43 -0
- package/dist/views/metadata-admin/preview-registry.js +18 -0
- package/dist/views/metadata-admin/previews/AppPreview.d.ts +2 -0
- package/dist/views/metadata-admin/previews/AppPreview.js +101 -0
- package/dist/views/metadata-admin/previews/DashboardPreview.d.ts +2 -0
- package/dist/views/metadata-admin/previews/DashboardPreview.js +25 -0
- package/dist/views/metadata-admin/previews/EmailTemplatePreview.d.ts +2 -0
- package/dist/views/metadata-admin/previews/EmailTemplatePreview.js +65 -0
- package/dist/views/metadata-admin/previews/ObjectPreview.d.ts +2 -0
- package/dist/views/metadata-admin/previews/ObjectPreview.js +36 -0
- package/dist/views/metadata-admin/previews/PagePreview.d.ts +2 -0
- package/dist/views/metadata-admin/previews/PagePreview.js +26 -0
- package/dist/views/metadata-admin/previews/PreviewShell.d.ts +40 -0
- package/dist/views/metadata-admin/previews/PreviewShell.js +49 -0
- package/dist/views/metadata-admin/previews/ReportPreview.d.ts +2 -0
- package/dist/views/metadata-admin/previews/ReportPreview.js +27 -0
- package/dist/views/metadata-admin/previews/ViewPreview.d.ts +2 -0
- package/dist/views/metadata-admin/previews/ViewPreview.js +113 -0
- package/dist/views/metadata-admin/previews/index.d.ts +1 -0
- package/dist/views/metadata-admin/previews/index.js +26 -0
- package/dist/views/metadata-admin/registry.d.ts +124 -0
- package/dist/views/metadata-admin/registry.js +58 -0
- 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
|
-
|
|
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}/
|
|
126
|
+
navigate(`${base}/metadata/${encodeURIComponent(r.entry.type)}`);
|
|
127
127
|
}
|
|
128
128
|
else {
|
|
129
|
-
navigate(`${base}/
|
|
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
|
|
3
|
-
name
|
|
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;
|