@object-ui/app-shell 6.1.0 → 6.2.0
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 +110 -0
- package/README.md +10 -1
- package/dist/console/AppContent.js +24 -2
- package/dist/console/ai/AiChatPage.d.ts +8 -0
- package/dist/console/ai/AiChatPage.js +188 -0
- package/dist/console/ai/ConversationsSidebar.d.ts +7 -0
- package/dist/console/ai/ConversationsSidebar.js +111 -0
- package/dist/console/auth/LoginPage.js +19 -2
- package/dist/console/auth/RegisterPage.js +30 -1
- package/dist/console/marketplace/MarketplaceAccessDenied.js +3 -1
- package/dist/console/marketplace/MarketplaceInstalledPage.js +11 -3
- package/dist/console/marketplace/MarketplacePackagePage.js +57 -19
- package/dist/console/marketplace/MarketplacePage.js +55 -18
- package/dist/console/marketplace/marketplaceApi.d.ts +20 -0
- package/dist/console/marketplace/usePackageL10n.d.ts +38 -0
- package/dist/console/marketplace/usePackageL10n.js +110 -0
- package/dist/console/organizations/CreateWorkspaceDialog.js +29 -1
- package/dist/console/organizations/OrganizationsPage.js +24 -3
- package/dist/context/FavoritesProvider.d.ts +40 -2
- package/dist/context/FavoritesProvider.js +201 -20
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useChatConversation.d.ts +7 -0
- package/dist/hooks/useChatConversation.js +37 -5
- package/dist/hooks/useConversationList.d.ts +25 -0
- package/dist/hooks/useConversationList.js +131 -0
- package/dist/hooks/useNavPins.d.ts +11 -4
- package/dist/hooks/useNavPins.js +104 -53
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/layout/AppHeader.js +2 -2
- package/dist/layout/AppSidebar.js +20 -1
- package/dist/layout/UnifiedSidebar.js +1 -1
- package/dist/providers/ExpressionProvider.d.ts +11 -1
- package/dist/providers/ExpressionProvider.js +11 -6
- package/dist/services/builtinComponents.d.ts +1 -0
- package/dist/services/builtinComponents.js +166 -0
- package/dist/services/componentRegistry.d.ts +63 -0
- package/dist/services/componentRegistry.js +36 -0
- package/dist/views/ComponentNavView.d.ts +6 -0
- package/dist/views/ComponentNavView.js +26 -0
- package/dist/views/RecordDetailView.js +66 -6
- package/dist/views/RecordFormPage.js +15 -3
- package/dist/views/SearchResultsPage.js +4 -0
- package/dist/views/metadata-admin/DesignerEditorWrapper.d.ts +58 -0
- package/dist/views/metadata-admin/DesignerEditorWrapper.js +140 -0
- package/dist/views/metadata-admin/DirectoryPage.d.ts +1 -0
- package/dist/views/metadata-admin/DirectoryPage.js +135 -0
- package/dist/views/metadata-admin/LayeredDiff.d.ts +6 -0
- package/dist/views/metadata-admin/LayeredDiff.js +26 -0
- package/dist/views/metadata-admin/PageShell.d.ts +34 -0
- package/dist/views/metadata-admin/PageShell.js +33 -0
- package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +5 -0
- package/dist/views/metadata-admin/PermissionMatrixEditor.js +288 -0
- package/dist/views/metadata-admin/QuickFind.d.ts +5 -0
- package/dist/views/metadata-admin/QuickFind.js +152 -0
- package/dist/views/metadata-admin/ResourceEditPage.d.ts +7 -0
- package/dist/views/metadata-admin/ResourceEditPage.js +256 -0
- package/dist/views/metadata-admin/ResourceHistoryPage.d.ts +5 -0
- package/dist/views/metadata-admin/ResourceHistoryPage.js +97 -0
- package/dist/views/metadata-admin/ResourceListPage.d.ts +4 -0
- package/dist/views/metadata-admin/ResourceListPage.js +144 -0
- package/dist/views/metadata-admin/ResourceRouter.d.ts +10 -0
- package/dist/views/metadata-admin/ResourceRouter.js +47 -0
- package/dist/views/metadata-admin/SchemaForm.d.ts +99 -0
- package/dist/views/metadata-admin/SchemaForm.js +556 -0
- package/dist/views/metadata-admin/default-schemas.d.ts +6 -0
- package/dist/views/metadata-admin/default-schemas.js +207 -0
- package/dist/views/metadata-admin/i18n.d.ts +33 -0
- package/dist/views/metadata-admin/i18n.js +303 -0
- package/dist/views/metadata-admin/index.d.ts +31 -0
- package/dist/views/metadata-admin/index.js +33 -0
- package/dist/views/metadata-admin/predicate.d.ts +31 -0
- package/dist/views/metadata-admin/predicate.js +150 -0
- package/dist/views/metadata-admin/registry.d.ts +125 -0
- package/dist/views/metadata-admin/registry.js +48 -0
- package/dist/views/metadata-admin/useMetadata.d.ts +37 -0
- package/dist/views/metadata-admin/useMetadata.js +96 -0
- package/dist/views/metadata-admin/widgets.d.ts +68 -0
- package/dist/views/metadata-admin/widgets.js +287 -0
- package/package.json +27 -26
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
3
|
+
/**
|
|
4
|
+
* MetadataResourceEditPage — generic AutoForm-driven editor (Phase 3c).
|
|
5
|
+
*
|
|
6
|
+
* What it does:
|
|
7
|
+
* 1. Fetches the layered view (`?layers=true`) so the user sees code
|
|
8
|
+
* vs overlay vs effective.
|
|
9
|
+
* 2. Renders a SchemaForm against the JSONSchema in the type's
|
|
10
|
+
* `/meta/types` registry row.
|
|
11
|
+
* 3. Save → PUT, with automatic destructive-change handling: a 409
|
|
12
|
+
* `destructive_change` response opens a confirmation dialog
|
|
13
|
+
* listing the issues, and on confirm we retry with `?force=true`.
|
|
14
|
+
* 4. Reset overlay → DELETE (overlay only).
|
|
15
|
+
* 5. References tab → calls `client.references()` and lists
|
|
16
|
+
* back-pointers so admins know what will break before deleting.
|
|
17
|
+
*
|
|
18
|
+
* Works for any of the 27 metadata types — bespoke editors (Object,
|
|
19
|
+
* Field, View, Permission Matrix) opt out by registering a custom
|
|
20
|
+
* EditPage via `registerMetadataResource()`.
|
|
21
|
+
*/
|
|
22
|
+
import * as React from 'react';
|
|
23
|
+
import { useNavigate } from 'react-router-dom';
|
|
24
|
+
import { Save, RotateCcw, History, Link2, Loader2, AlertTriangle, } from 'lucide-react';
|
|
25
|
+
import { Button } from '@object-ui/components';
|
|
26
|
+
import { Badge } from '@object-ui/components';
|
|
27
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger, } from '@object-ui/components';
|
|
28
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@object-ui/components';
|
|
29
|
+
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
|
|
30
|
+
import { PageShell } from './PageShell';
|
|
31
|
+
import { LayeredDiff } from './LayeredDiff';
|
|
32
|
+
import { SchemaForm } from './SchemaForm';
|
|
33
|
+
import { useMetadataClient, useMetadataTypes, } from './useMetadata';
|
|
34
|
+
import { getMetadataResource, resolveResourceConfig, } from './registry';
|
|
35
|
+
export function MetadataResourceEditPage({ type, name, createMode = false, }) {
|
|
36
|
+
const navigate = useNavigate();
|
|
37
|
+
const client = useMetadataClient();
|
|
38
|
+
const { entries } = useMetadataTypes(client);
|
|
39
|
+
const entry = entries.find((t) => t.type === type);
|
|
40
|
+
const config = resolveResourceConfig(type, entry);
|
|
41
|
+
// Custom editor takes over.
|
|
42
|
+
const customConfig = getMetadataResource(type);
|
|
43
|
+
if (customConfig?.EditPage && !createMode) {
|
|
44
|
+
const Custom = customConfig.EditPage;
|
|
45
|
+
return _jsx(Custom, { type: type, name: name });
|
|
46
|
+
}
|
|
47
|
+
if (customConfig?.CreatePage && createMode) {
|
|
48
|
+
const Custom = customConfig.CreatePage;
|
|
49
|
+
return _jsx(Custom, { type: type });
|
|
50
|
+
}
|
|
51
|
+
const [layered, setLayered] = React.useState(null);
|
|
52
|
+
const [draft, setDraft] = React.useState(createMode ? { name: '' } : {});
|
|
53
|
+
const [refs, setRefs] = React.useState(null);
|
|
54
|
+
const [loading, setLoading] = React.useState(!createMode);
|
|
55
|
+
const [saving, setSaving] = React.useState(false);
|
|
56
|
+
const [error, setError] = React.useState(null);
|
|
57
|
+
const [issues, setIssues] = React.useState([]);
|
|
58
|
+
const [destructiveIssues, setDestructiveIssues] = React.useState(null);
|
|
59
|
+
const [pendingItem, setPendingItem] = React.useState(null);
|
|
60
|
+
// Prefetch object name list once — fuels the `ref:object` widget.
|
|
61
|
+
// We don't block render on it; the widget shows a "Loading…" state.
|
|
62
|
+
const [objectNames, setObjectNames] = React.useState([]);
|
|
63
|
+
const [objectsLoading, setObjectsLoading] = React.useState(true);
|
|
64
|
+
React.useEffect(() => {
|
|
65
|
+
let cancelled = false;
|
|
66
|
+
(async () => {
|
|
67
|
+
try {
|
|
68
|
+
const list = (await client.list('object'));
|
|
69
|
+
if (cancelled)
|
|
70
|
+
return;
|
|
71
|
+
setObjectNames(list.map((x) => x?.name).filter((n) => !!n).sort());
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
if (!cancelled)
|
|
75
|
+
setObjectNames([]);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
if (!cancelled)
|
|
79
|
+
setObjectsLoading(false);
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
return () => {
|
|
83
|
+
cancelled = true;
|
|
84
|
+
};
|
|
85
|
+
}, [client]);
|
|
86
|
+
const widgetContext = React.useMemo(() => ({ objectNames, objectsLoading }), [objectNames, objectsLoading]);
|
|
87
|
+
// Load layered view + initial draft.
|
|
88
|
+
React.useEffect(() => {
|
|
89
|
+
if (createMode) {
|
|
90
|
+
setLoading(false);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
let cancelled = false;
|
|
94
|
+
setLoading(true);
|
|
95
|
+
setError(null);
|
|
96
|
+
(async () => {
|
|
97
|
+
try {
|
|
98
|
+
const lay = await client.layered(type, name);
|
|
99
|
+
if (cancelled)
|
|
100
|
+
return;
|
|
101
|
+
setLayered(lay);
|
|
102
|
+
// Initial draft = effective if available, otherwise code.
|
|
103
|
+
const initial = (lay.effective ?? lay.code ?? {});
|
|
104
|
+
setDraft(initial);
|
|
105
|
+
setLoading(false);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
if (!cancelled) {
|
|
109
|
+
setError(err?.message ?? String(err));
|
|
110
|
+
setLoading(false);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
return () => {
|
|
115
|
+
cancelled = true;
|
|
116
|
+
};
|
|
117
|
+
}, [client, type, name, createMode]);
|
|
118
|
+
// Lazy-load references when the tab is opened.
|
|
119
|
+
const [refsLoading, setRefsLoading] = React.useState(false);
|
|
120
|
+
async function loadReferences() {
|
|
121
|
+
if (refs != null || refsLoading)
|
|
122
|
+
return;
|
|
123
|
+
setRefsLoading(true);
|
|
124
|
+
try {
|
|
125
|
+
const r = await client.references(type, name);
|
|
126
|
+
setRefs(r);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
// Surface as empty list; non-blocking.
|
|
130
|
+
setRefs([]);
|
|
131
|
+
console.error('references() failed', err);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
setRefsLoading(false);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function doSave(force) {
|
|
138
|
+
setSaving(true);
|
|
139
|
+
setError(null);
|
|
140
|
+
setIssues([]);
|
|
141
|
+
try {
|
|
142
|
+
// Ensure `name` is set on create.
|
|
143
|
+
const itemToSave = createMode
|
|
144
|
+
? { ...draft, name: String(draft.name ?? name) }
|
|
145
|
+
: draft;
|
|
146
|
+
const savedName = String(itemToSave.name ?? name);
|
|
147
|
+
if (!savedName) {
|
|
148
|
+
setError('A name is required.');
|
|
149
|
+
setSaving(false);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const result = await client.save(type, savedName, itemToSave, { force });
|
|
153
|
+
// Refresh layered after save.
|
|
154
|
+
const lay = await client.layered(type, savedName);
|
|
155
|
+
setLayered(lay);
|
|
156
|
+
setDraft((lay.effective ?? itemToSave));
|
|
157
|
+
setDestructiveIssues(null);
|
|
158
|
+
setPendingItem(null);
|
|
159
|
+
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)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
// Map destructive change → confirmation dialog.
|
|
171
|
+
if (err?.status === 409 && err?.code === 'destructive_change') {
|
|
172
|
+
const i = err?.body?.issues ?? [];
|
|
173
|
+
setDestructiveIssues(Array.isArray(i) ? i : []);
|
|
174
|
+
setPendingItem(draft);
|
|
175
|
+
}
|
|
176
|
+
// Map schema validation → inline field errors.
|
|
177
|
+
else if (err?.status === 422 || err?.code === 'invalid_metadata' || err?.code === 'invalid_payload') {
|
|
178
|
+
const i = err?.body?.issues ?? [];
|
|
179
|
+
let mapped = (Array.isArray(i) ? i : []).map((x) => ({
|
|
180
|
+
path: Array.isArray(x.path) ? x.path.join('.') : String(x.path ?? ''),
|
|
181
|
+
message: String(x.message ?? 'Invalid'),
|
|
182
|
+
}));
|
|
183
|
+
// Backend's invalid_metadata sometimes returns a flat string like
|
|
184
|
+
// "<type>/<name> failed spec validation: <path>: <message>".
|
|
185
|
+
// Parse it into a single inline issue + summary so users see the
|
|
186
|
+
// real problem instead of "0 issues".
|
|
187
|
+
const raw = String(err?.body?.error ?? err?.message ?? '');
|
|
188
|
+
if (mapped.length === 0 && raw) {
|
|
189
|
+
const m = raw.match(/failed spec validation:\s*(.+?):\s*(.+)$/);
|
|
190
|
+
if (m) {
|
|
191
|
+
mapped = [{ path: m[1].trim(), message: m[2].trim() }];
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
mapped = [{ path: '', message: raw }];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
setIssues(mapped);
|
|
198
|
+
if (mapped.length === 1 && !mapped[0].path) {
|
|
199
|
+
setError(mapped[0].message);
|
|
200
|
+
}
|
|
201
|
+
else if (mapped.length === 1) {
|
|
202
|
+
setError(`${mapped[0].path}: ${mapped[0].message}`);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
setError(`Validation failed (${mapped.length} issues).`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
setError(err?.message ?? String(err));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
setSaving(false);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function doReset() {
|
|
217
|
+
if (!confirm(`Reset overlay for ${type}/${name}? Code-level value will be restored.`)) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
setSaving(true);
|
|
221
|
+
setError(null);
|
|
222
|
+
try {
|
|
223
|
+
await client.reset(type, name);
|
|
224
|
+
const lay = await client.layered(type, name);
|
|
225
|
+
setLayered(lay);
|
|
226
|
+
setDraft((lay.effective ?? lay.code ?? {}));
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
setError(err?.message ?? String(err));
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
setSaving(false);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (loading) {
|
|
236
|
+
return (_jsx(PageShell, { entry: entry, itemName: name, children: _jsxs("div", { className: "p-6 text-sm text-muted-foreground flex items-center gap-2", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), " Loading ", type, "/", name, "\u2026"] }) }));
|
|
237
|
+
}
|
|
238
|
+
const schema = entry?.schema ??
|
|
239
|
+
config.defaultSchema;
|
|
240
|
+
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) => {
|
|
242
|
+
if (!open) {
|
|
243
|
+
setDestructiveIssues(null);
|
|
244
|
+
setPendingItem(null);
|
|
245
|
+
}
|
|
246
|
+
}, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsxs(DialogTitle, { className: "flex items-center gap-2", children: [_jsx(AlertTriangle, { className: "h-5 w-5 text-amber-600" }), "Destructive change detected"] }), _jsx(DialogDescription, { children: "The framework refused this save because it would drop or narrow data already in use. Review the issues and confirm to override." })] }), _jsx("div", { className: "max-h-[300px] overflow-auto space-y-2 my-2", children: destructiveIssues?.map((i, idx) => (_jsxs("div", { className: "rounded border bg-amber-50 border-amber-200 p-2 text-xs", children: [_jsx("div", { className: "font-mono text-amber-900", children: i.kind ?? 'change' }), i.path && (_jsx("div", { className: "text-amber-800 font-mono mt-0.5", children: i.path })), _jsx("div", { className: "text-amber-900 mt-1", children: i.message })] }, idx))) }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "ghost", onClick: () => setDestructiveIssues(null), children: "Cancel" }), _jsx(Button, { variant: "destructive", onClick: () => doSave(true), disabled: saving, children: saving ? 'Forcing…' : 'Force save' })] })] }) })] }));
|
|
247
|
+
}
|
|
248
|
+
function ReferencesPanel({ refs, loading, }) {
|
|
249
|
+
if (loading || refs == null) {
|
|
250
|
+
return (_jsxs("div", { className: "text-sm text-muted-foreground flex items-center gap-2", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), " Scanning references\u2026"] }));
|
|
251
|
+
}
|
|
252
|
+
if (refs.length === 0) {
|
|
253
|
+
return (_jsxs(Empty, { children: [_jsx(EmptyTitle, { children: "No references found" }), _jsx(EmptyDescription, { children: "Nothing in the metadata graph points at this item. Safe to delete." })] }));
|
|
254
|
+
}
|
|
255
|
+
return (_jsx("div", { className: "border rounded-lg overflow-hidden", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "bg-muted/40 text-xs uppercase tracking-wider text-muted-foreground", children: _jsxs("tr", { children: [_jsx("th", { className: "px-3 py-2 text-left", children: "From type" }), _jsx("th", { className: "px-3 py-2 text-left", children: "From name" }), _jsx("th", { className: "px-3 py-2 text-left", children: "Path" })] }) }), _jsx("tbody", { className: "divide-y", children: refs.map((r, i) => (_jsxs("tr", { className: "hover:bg-accent/50", children: [_jsx("td", { className: "px-3 py-2", children: _jsx(Badge, { variant: "outline", className: "text-[10px] font-mono", children: r.fromType }) }), _jsx("td", { className: "px-3 py-2 font-mono text-xs", children: r.fromName }), _jsx("td", { className: "px-3 py-2 font-mono text-xs text-muted-foreground", children: r.path })] }, i))) })] }) }));
|
|
256
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
3
|
+
/**
|
|
4
|
+
* MetadataResourceHistoryPage — durable change log for one item
|
|
5
|
+
* (Phase 3c).
|
|
6
|
+
*
|
|
7
|
+
* Calls `client.history(type, name)`. The framework returns
|
|
8
|
+
* `{ events: MetadataEvent[] }` where each event records who saved
|
|
9
|
+
* what, with a monotonic `seq`. We render a vertical timeline; clicking
|
|
10
|
+
* an event expands its payload diff.
|
|
11
|
+
*
|
|
12
|
+
* Rollback is intentionally not exposed in MVP — restoring a previous
|
|
13
|
+
* version is just `client.save(type, name, oldPayload)` which already
|
|
14
|
+
* works, but a one-click revert needs a confirmation flow we'll add
|
|
15
|
+
* once admins ask for it.
|
|
16
|
+
*/
|
|
17
|
+
import * as React from 'react';
|
|
18
|
+
import { useNavigate } from 'react-router-dom';
|
|
19
|
+
import { ArrowLeft, RefreshCw, Loader2 } from 'lucide-react';
|
|
20
|
+
import { Button } from '@object-ui/components';
|
|
21
|
+
import { Badge } from '@object-ui/components';
|
|
22
|
+
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
|
|
23
|
+
import { PageShell } from './PageShell';
|
|
24
|
+
import { useMetadataClient, useMetadataTypes, } from './useMetadata';
|
|
25
|
+
export function MetadataResourceHistoryPage({ type, name, }) {
|
|
26
|
+
const navigate = useNavigate();
|
|
27
|
+
const client = useMetadataClient();
|
|
28
|
+
const { entries } = useMetadataTypes(client);
|
|
29
|
+
const entry = entries.find((t) => t.type === type);
|
|
30
|
+
const [events, setEvents] = React.useState([]);
|
|
31
|
+
const [loading, setLoading] = React.useState(true);
|
|
32
|
+
const [error, setError] = React.useState(null);
|
|
33
|
+
const [refreshKey, setRefreshKey] = React.useState(0);
|
|
34
|
+
const [expanded, setExpanded] = React.useState(null);
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
let cancelled = false;
|
|
37
|
+
setLoading(true);
|
|
38
|
+
setError(null);
|
|
39
|
+
(async () => {
|
|
40
|
+
try {
|
|
41
|
+
const result = await client.history(type, name, { limit: 100 });
|
|
42
|
+
if (cancelled)
|
|
43
|
+
return;
|
|
44
|
+
const list = Array.isArray(result)
|
|
45
|
+
? result
|
|
46
|
+
: Array.isArray(result?.events)
|
|
47
|
+
? result.events
|
|
48
|
+
: [];
|
|
49
|
+
// Reverse chronological — most recent first.
|
|
50
|
+
const sorted = [...list].sort((a, b) => (b.seq ?? 0) - (a.seq ?? 0));
|
|
51
|
+
setEvents(sorted);
|
|
52
|
+
setLoading(false);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (!cancelled) {
|
|
56
|
+
setError(err?.message ?? String(err));
|
|
57
|
+
setLoading(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
return () => {
|
|
62
|
+
cancelled = true;
|
|
63
|
+
};
|
|
64
|
+
}, [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) => {
|
|
66
|
+
const isOpen = expanded === i;
|
|
67
|
+
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
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
69
|
+
setExpanded(isOpen ? null : i);
|
|
70
|
+
}
|
|
71
|
+
}, className: "border rounded-lg p-3 hover:border-primary/50 cursor-pointer transition-colors", children: [_jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsxs(Badge, { variant: "outline", className: "text-[10px] font-mono", children: ["seq ", ev.seq ?? '–'] }), ev.kind && (_jsx(Badge, { className: 'text-[10px] ' +
|
|
72
|
+
(ev.kind === 'delete' || ev.kind === 'tombstone'
|
|
73
|
+
? 'bg-red-100 text-red-800 hover:bg-red-100'
|
|
74
|
+
: ev.kind === 'create'
|
|
75
|
+
? 'bg-blue-100 text-blue-800 hover:bg-blue-100'
|
|
76
|
+
: 'bg-emerald-100 text-emerald-800 hover:bg-emerald-100'), children: String(ev.kind) })), ev.actor && (_jsxs("span", { className: "text-xs text-muted-foreground", children: ["by", ' ', _jsx("span", { className: "font-mono", children: String(ev.actor) })] })), ev.at && (_jsx("span", { className: "text-xs text-muted-foreground ml-auto", children: formatWhen(ev.at) }))] }), isOpen && (_jsx("pre", { className: "mt-2 text-xs font-mono bg-muted/30 rounded p-2 overflow-auto max-h-[280px]", children: safeStringify(ev) }))] })] }, `${ev.seq ?? i}-${i}`));
|
|
77
|
+
}) }))] }) }));
|
|
78
|
+
}
|
|
79
|
+
function formatWhen(at) {
|
|
80
|
+
try {
|
|
81
|
+
const d = typeof at === 'number' ? new Date(at) : new Date(at);
|
|
82
|
+
if (Number.isNaN(d.getTime()))
|
|
83
|
+
return String(at);
|
|
84
|
+
return d.toLocaleString();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return String(at);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function safeStringify(v) {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.stringify(v, null, 2);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return String(v);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
3
|
+
/**
|
|
4
|
+
* MetadataResourceListPage — generic list of items for a metadata type
|
|
5
|
+
* (Phase 3c).
|
|
6
|
+
*
|
|
7
|
+
* Reads `/meta/:type`, applies registry-driven columns + search +
|
|
8
|
+
* source/overlay filters, and renders an ObjectGrid-like table.
|
|
9
|
+
* Each row links to its EditPage at `./:name?type=…`.
|
|
10
|
+
*
|
|
11
|
+
* No virtualisation in MVP — metadata lists are typically < 200 items
|
|
12
|
+
* per type, well under the threshold where it'd matter.
|
|
13
|
+
*/
|
|
14
|
+
import * as React from 'react';
|
|
15
|
+
import { Link, useNavigate } from 'react-router-dom';
|
|
16
|
+
import { Plus, Search, RefreshCw } from 'lucide-react';
|
|
17
|
+
import { Button } from '@object-ui/components';
|
|
18
|
+
import { Input } from '@object-ui/components';
|
|
19
|
+
import { Badge } from '@object-ui/components';
|
|
20
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@object-ui/components';
|
|
21
|
+
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
|
|
22
|
+
import { PageShell } from './PageShell';
|
|
23
|
+
import { useMetadataClient, useMetadataTypes, matchesQuery, } from './useMetadata';
|
|
24
|
+
import { getMetadataResource, resolveResourceConfig, } from './registry';
|
|
25
|
+
import { t, detectLocale } from './i18n';
|
|
26
|
+
export function MetadataResourceListPage({ type }) {
|
|
27
|
+
const navigate = useNavigate();
|
|
28
|
+
const client = useMetadataClient();
|
|
29
|
+
const { entries: typesEntries } = useMetadataTypes(client);
|
|
30
|
+
const entry = typesEntries.find((t) => t.type === type);
|
|
31
|
+
const config = resolveResourceConfig(type, entry);
|
|
32
|
+
// If a fully custom ListPage is registered, render it and bail.
|
|
33
|
+
const customConfig = getMetadataResource(type);
|
|
34
|
+
if (customConfig?.ListPage) {
|
|
35
|
+
const Custom = customConfig.ListPage;
|
|
36
|
+
return _jsx(Custom, { type: type });
|
|
37
|
+
}
|
|
38
|
+
const [items, setItems] = React.useState([]);
|
|
39
|
+
const [loading, setLoading] = React.useState(true);
|
|
40
|
+
const [error, setError] = React.useState(null);
|
|
41
|
+
const [query, setQuery] = React.useState('');
|
|
42
|
+
const [sourceFilter, setSourceFilter] = React.useState('all');
|
|
43
|
+
const [refreshKey, setRefreshKey] = React.useState(0);
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
let cancelled = false;
|
|
46
|
+
setLoading(true);
|
|
47
|
+
setError(null);
|
|
48
|
+
(async () => {
|
|
49
|
+
try {
|
|
50
|
+
const list = await client.list(type);
|
|
51
|
+
if (cancelled)
|
|
52
|
+
return;
|
|
53
|
+
const rows = (list ?? []).map((raw) => {
|
|
54
|
+
const item = (raw && typeof raw === 'object' && 'item' in raw ? raw.item : raw) ?? {};
|
|
55
|
+
return {
|
|
56
|
+
raw,
|
|
57
|
+
item,
|
|
58
|
+
source: raw?.source,
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
setItems(rows);
|
|
62
|
+
setLoading(false);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (!cancelled) {
|
|
66
|
+
setError(err?.message ?? String(err));
|
|
67
|
+
setLoading(false);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
return () => {
|
|
72
|
+
cancelled = true;
|
|
73
|
+
};
|
|
74
|
+
}, [client, type, refreshKey]);
|
|
75
|
+
const searchableFields = config.searchableFields ?? ['name', 'label', 'description'];
|
|
76
|
+
const filtered = items.filter((row) => {
|
|
77
|
+
if (!matchesQuery(row.item, query, searchableFields))
|
|
78
|
+
return false;
|
|
79
|
+
if (sourceFilter !== 'all' && row.source && row.source !== sourceFilter)
|
|
80
|
+
return false;
|
|
81
|
+
return true;
|
|
82
|
+
});
|
|
83
|
+
// Compute source counts for the filter dropdown.
|
|
84
|
+
const sourceCounts = React.useMemo(() => {
|
|
85
|
+
const c = { all: items.length, code: 0, overlay: 0, effective: 0 };
|
|
86
|
+
for (const r of items) {
|
|
87
|
+
if (r.source && c[r.source] !== undefined)
|
|
88
|
+
c[r.source]++;
|
|
89
|
+
}
|
|
90
|
+
return c;
|
|
91
|
+
}, [items]);
|
|
92
|
+
const columns = config.listColumns ?? defaultColumns(config.primaryKey ?? 'name');
|
|
93
|
+
const locale = detectLocale();
|
|
94
|
+
// Localise default column labels — registered columns keep their
|
|
95
|
+
// hand-authored labels (consumers may want bespoke wording).
|
|
96
|
+
const localizeColumnLabel = (col) => {
|
|
97
|
+
const tryKey = `engine.list.col.${col.key}`;
|
|
98
|
+
const translated = t(tryKey, locale);
|
|
99
|
+
return translated === tryKey ? col.label : translated;
|
|
100
|
+
};
|
|
101
|
+
return (_jsx(PageShell, { entry: entry ?? { type, label: type }, stats: [
|
|
102
|
+
{ label: t('engine.list.items', locale), value: items.length },
|
|
103
|
+
{ 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
|
|
105
|
+
? `No ${type} items registered`
|
|
106
|
+
: `No matches for "${query}"` }), _jsx(EmptyDescription, { children: config.emptyStateHint ??
|
|
107
|
+
(entry?.allowOrgOverride
|
|
108
|
+
? `Click "New" above to create the first ${type}.`
|
|
109
|
+
: `This type is read-only — instances are defined by code artifacts in packages.`) })] })), !loading && filtered.length > 0 && (_jsx("div", { className: "border rounded-lg overflow-hidden", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "bg-muted/40 text-xs uppercase tracking-wider text-muted-foreground", children: _jsxs("tr", { children: [columns.map((c) => (_jsx("th", { className: "px-3 py-2 text-left font-medium", style: c.width ? { width: c.width } : undefined, children: localizeColumnLabel(c) }, c.key))), _jsx("th", { className: "px-3 py-2 text-right font-medium w-[80px]", children: t('engine.list.col.source', locale) })] }) }), _jsx("tbody", { className: "divide-y", children: filtered.map((row, i) => {
|
|
110
|
+
const pk = config.primaryKey ?? 'name';
|
|
111
|
+
const name = String(row.item[pk] ?? `(unnamed-${i})`);
|
|
112
|
+
return (_jsxs("tr", { className: "hover:bg-accent/50", children: [columns.map((c, ci) => {
|
|
113
|
+
const value = row.item[c.key];
|
|
114
|
+
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));
|
|
116
|
+
}), _jsx("td", { className: "px-3 py-2 text-right align-top", children: row.source ? (_jsx(Badge, { variant: "outline", className: 'text-[10px] ' +
|
|
117
|
+
(row.source === 'overlay'
|
|
118
|
+
? 'border-emerald-500 text-emerald-700'
|
|
119
|
+
: ''), children: row.source })) : (_jsx("span", { className: "text-muted-foreground text-xs", children: "\u2014" })) })] }, name + i));
|
|
120
|
+
}) })] }) }))] }) }));
|
|
121
|
+
}
|
|
122
|
+
function defaultColumns(primaryKey) {
|
|
123
|
+
return [
|
|
124
|
+
{ key: primaryKey, label: primaryKey, width: '30%' },
|
|
125
|
+
{ key: 'label', label: 'Label', width: '30%' },
|
|
126
|
+
{ key: 'description', label: 'Description' },
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
function defaultCell(value) {
|
|
130
|
+
if (value == null || value === '') {
|
|
131
|
+
return _jsx("span", { className: "text-muted-foreground", children: "\u2014" });
|
|
132
|
+
}
|
|
133
|
+
if (typeof value === 'boolean')
|
|
134
|
+
return value ? '✓' : '✗';
|
|
135
|
+
if (typeof value === 'object') {
|
|
136
|
+
try {
|
|
137
|
+
return (_jsx("code", { className: "font-mono text-xs", children: JSON.stringify(value).slice(0, 60) }));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return String(value);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return String(value);
|
|
144
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Props forwarded by `ComponentNavView` — it merges URL query-string
|
|
3
|
+
* into props, so we accept both `type` (from `?type=…`) and route
|
|
4
|
+
* nav `params: { type: 'object' }` style.
|
|
5
|
+
*/
|
|
6
|
+
export interface MetadataResourceRouterProps {
|
|
7
|
+
/** Singular metadata type, e.g. 'view'. */
|
|
8
|
+
type?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function MetadataResourceRouter({ type }: MetadataResourceRouterProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLocation, useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
|
|
4
|
+
import { MetadataResourceListPage } from './ResourceListPage';
|
|
5
|
+
import { MetadataResourceEditPage } from './ResourceEditPage';
|
|
6
|
+
import { MetadataResourceHistoryPage } from './ResourceHistoryPage';
|
|
7
|
+
export function MetadataResourceRouter({ type }) {
|
|
8
|
+
const location = useLocation();
|
|
9
|
+
const [search] = useSearchParams();
|
|
10
|
+
const resolvedType = type ?? search.get('type') ?? '';
|
|
11
|
+
if (!resolvedType) {
|
|
12
|
+
return (_jsx("div", { className: "p-8 h-full flex items-center justify-center", children: _jsxs(Empty, { children: [_jsx(EmptyTitle, { children: "Missing metadata type" }), _jsxs(EmptyDescription, { children: ["This page expects a ", _jsx("code", { className: "font-mono", children: "?type=" }), " query param (e.g. ", _jsx("code", { className: "font-mono", children: "?type=view" }), ") or a nav-metadata ", _jsxs("code", { className: "font-mono", children: ["params: ", `{ type: 'view' }`] }), "."] })] }) }));
|
|
13
|
+
}
|
|
14
|
+
// Parse the trailing wildcard. The parent route consumed
|
|
15
|
+
// `/apps/:appName/component/metadata/resource`, so what's left in
|
|
16
|
+
// location.pathname AFTER that prefix is our sub-path. We don't have
|
|
17
|
+
// the prefix exactly, but we can find `/resource/` and slice — it's
|
|
18
|
+
// unambiguous because `:ns/:name` are colon-joined → "metadata/resource".
|
|
19
|
+
const subPath = extractSubPath(location.pathname);
|
|
20
|
+
const segments = subPath.split('/').filter(Boolean);
|
|
21
|
+
// No sub-path → list page.
|
|
22
|
+
if (segments.length === 0) {
|
|
23
|
+
return _jsx(MetadataResourceListPage, { type: resolvedType });
|
|
24
|
+
}
|
|
25
|
+
// /new → create page.
|
|
26
|
+
if (segments[0] === 'new' && segments.length === 1) {
|
|
27
|
+
return (_jsx(MetadataResourceEditPage, { type: resolvedType, name: "", createMode: true }, `${resolvedType}:__new__`));
|
|
28
|
+
}
|
|
29
|
+
// /:name/history → history.
|
|
30
|
+
if (segments.length === 2 && segments[1] === 'history') {
|
|
31
|
+
return (_jsx(MetadataResourceHistoryPage, { type: resolvedType, name: decodeURIComponent(segments[0]) }));
|
|
32
|
+
}
|
|
33
|
+
// /:name → edit.
|
|
34
|
+
if (segments.length === 1) {
|
|
35
|
+
return (_jsx(MetadataResourceEditPage, { type: resolvedType, name: decodeURIComponent(segments[0]) }, `${resolvedType}:${segments[0]}`));
|
|
36
|
+
}
|
|
37
|
+
// Unknown sub-path.
|
|
38
|
+
return (_jsx("div", { className: "p-8 h-full flex items-center justify-center", children: _jsxs(Empty, { children: [_jsx(EmptyTitle, { children: "Unknown sub-path" }), _jsxs(EmptyDescription, { children: ["The path ", _jsx("code", { className: "font-mono", children: subPath }), " isn't recognized. Valid forms are ", _jsx("code", { className: "font-mono", children: "/" }), " (list),", ' ', _jsx("code", { className: "font-mono", children: "/new" }), " (create),", ' ', _jsx("code", { className: "font-mono", children: "/:name" }), " (edit),", ' ', _jsx("code", { className: "font-mono", children: "/:name/history" }), "."] })] }) }));
|
|
39
|
+
}
|
|
40
|
+
function extractSubPath(pathname) {
|
|
41
|
+
// Find the literal `/metadata/resource` prefix and return what follows.
|
|
42
|
+
const marker = '/metadata/resource';
|
|
43
|
+
const idx = pathname.indexOf(marker);
|
|
44
|
+
if (idx === -1)
|
|
45
|
+
return '';
|
|
46
|
+
return pathname.slice(idx + marker.length); // includes leading '/' if any
|
|
47
|
+
}
|