@object-ui/app-shell 6.0.4 → 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 +128 -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 +83 -17
- 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 +72 -0
- 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 +29 -28
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
* MetadataQuickFind — Cmd+K palette that searches across all
|
|
5
|
+
* metadata types (Phase 3c).
|
|
6
|
+
*
|
|
7
|
+
* Behaviour:
|
|
8
|
+
* • Mounted as a side-effect via `<MetadataQuickFindMount />`.
|
|
9
|
+
* • Listens for Cmd+K / Ctrl+K globally; opens a dialog with a
|
|
10
|
+
* search input.
|
|
11
|
+
* • Free-text search hits two pools:
|
|
12
|
+
* (a) Metadata types from `/meta/types` (jump to directory entry).
|
|
13
|
+
* (b) Items of any type — lazy-fetched the first time the palette
|
|
14
|
+
* opens, cached for the session.
|
|
15
|
+
* • Selecting a result navigates to the appropriate route.
|
|
16
|
+
*
|
|
17
|
+
* Trade-offs: For the MVP we eagerly fetch the item index on first
|
|
18
|
+
* open (one `/meta/:type` per writable type). For most workspaces
|
|
19
|
+
* that's a few hundred items total — well within a fast modal load.
|
|
20
|
+
* If usage grows we'll switch to server-side search.
|
|
21
|
+
*/
|
|
22
|
+
import * as React from 'react';
|
|
23
|
+
import { useNavigate } from 'react-router-dom';
|
|
24
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@object-ui/components';
|
|
25
|
+
import { Input } from '@object-ui/components';
|
|
26
|
+
import { Badge } from '@object-ui/components';
|
|
27
|
+
import { Kbd } from '@object-ui/components';
|
|
28
|
+
import { Search, Loader2 } from 'lucide-react';
|
|
29
|
+
import { useMetadataClient, useMetadataTypes, } from './useMetadata';
|
|
30
|
+
import { detectLocale, t as tr } from './i18n';
|
|
31
|
+
export function MetadataQuickFind({ appSlug } = {}) {
|
|
32
|
+
const navigate = useNavigate();
|
|
33
|
+
const client = useMetadataClient();
|
|
34
|
+
const { entries: typeEntries } = useMetadataTypes(client);
|
|
35
|
+
const [open, setOpen] = React.useState(false);
|
|
36
|
+
const [query, setQuery] = React.useState('');
|
|
37
|
+
const [items, setItems] = React.useState(null);
|
|
38
|
+
const [itemsLoading, setItemsLoading] = React.useState(false);
|
|
39
|
+
const [activeIdx, setActiveIdx] = React.useState(0);
|
|
40
|
+
const locale = React.useMemo(() => detectLocale(), []);
|
|
41
|
+
// Global Cmd+Shift+M listener (avoids clashing with the existing
|
|
42
|
+
// CommandPalette which owns Cmd+K).
|
|
43
|
+
React.useEffect(() => {
|
|
44
|
+
function onKey(e) {
|
|
45
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'm') {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
setOpen((v) => !v);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
window.addEventListener('keydown', onKey);
|
|
51
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
52
|
+
}, []);
|
|
53
|
+
// Lazy-fetch item index when palette first opens.
|
|
54
|
+
React.useEffect(() => {
|
|
55
|
+
if (!open || items != null || itemsLoading || typeEntries.length === 0)
|
|
56
|
+
return;
|
|
57
|
+
setItemsLoading(true);
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
(async () => {
|
|
60
|
+
try {
|
|
61
|
+
// Index *all* types (read-only ones are fine — we just won't
|
|
62
|
+
// offer create/edit for them; users may still want to find code
|
|
63
|
+
// artifacts to inspect).
|
|
64
|
+
const all = [];
|
|
65
|
+
await Promise.all(typeEntries.map(async (t) => {
|
|
66
|
+
try {
|
|
67
|
+
const list = await client.list(t.type);
|
|
68
|
+
for (const raw of list ?? []) {
|
|
69
|
+
const item = (raw && typeof raw === 'object' && 'item' in raw ? raw.item : raw) ?? {};
|
|
70
|
+
const name = item?.name;
|
|
71
|
+
if (!name)
|
|
72
|
+
continue;
|
|
73
|
+
all.push({
|
|
74
|
+
kind: 'item',
|
|
75
|
+
type: t.type,
|
|
76
|
+
name: String(name),
|
|
77
|
+
label: item?.label,
|
|
78
|
+
description: item?.description,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* skip types that error */
|
|
84
|
+
}
|
|
85
|
+
}));
|
|
86
|
+
if (!cancelled)
|
|
87
|
+
setItems(all);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
if (!cancelled)
|
|
91
|
+
setItemsLoading(false);
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
return () => {
|
|
95
|
+
cancelled = true;
|
|
96
|
+
};
|
|
97
|
+
}, [open, items, itemsLoading, typeEntries, client]);
|
|
98
|
+
// Compose results: typed matches first, then item matches.
|
|
99
|
+
const results = React.useMemo(() => {
|
|
100
|
+
const q = query.trim().toLowerCase();
|
|
101
|
+
if (!q) {
|
|
102
|
+
// Empty query → show the type directory only.
|
|
103
|
+
return typeEntries.slice(0, 20).map((entry) => ({ kind: 'type', entry }));
|
|
104
|
+
}
|
|
105
|
+
const typeHits = typeEntries
|
|
106
|
+
.filter((e) => e.type.toLowerCase().includes(q) ||
|
|
107
|
+
(e.label ?? '').toLowerCase().includes(q) ||
|
|
108
|
+
(e.description ?? '').toLowerCase().includes(q))
|
|
109
|
+
.slice(0, 8)
|
|
110
|
+
.map((entry) => ({ kind: 'type', entry }));
|
|
111
|
+
const itemHits = (items ?? [])
|
|
112
|
+
.filter((i) => i.name.toLowerCase().includes(q) ||
|
|
113
|
+
(i.label ?? '').toLowerCase().includes(q) ||
|
|
114
|
+
(i.description ?? '').toLowerCase().includes(q) ||
|
|
115
|
+
i.type.toLowerCase().includes(q))
|
|
116
|
+
.slice(0, 40);
|
|
117
|
+
return [...typeHits, ...itemHits];
|
|
118
|
+
}, [query, typeEntries, items]);
|
|
119
|
+
React.useEffect(() => {
|
|
120
|
+
setActiveIdx(0);
|
|
121
|
+
}, [query, open]);
|
|
122
|
+
function navigateTo(r) {
|
|
123
|
+
setOpen(false);
|
|
124
|
+
const base = appSlug ? `/apps/${appSlug}` : '..';
|
|
125
|
+
if (r.kind === 'type') {
|
|
126
|
+
navigate(`${base}/component/metadata/resource?type=${encodeURIComponent(r.entry.type)}`);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
navigate(`${base}/component/metadata/resource/${encodeURIComponent(r.name)}?type=${encodeURIComponent(r.type)}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function onKeyDown(e) {
|
|
133
|
+
if (e.key === 'ArrowDown') {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
setActiveIdx((i) => Math.min(i + 1, results.length - 1));
|
|
136
|
+
}
|
|
137
|
+
else if (e.key === 'ArrowUp') {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
setActiveIdx((i) => Math.max(i - 1, 0));
|
|
140
|
+
}
|
|
141
|
+
else if (e.key === 'Enter') {
|
|
142
|
+
if (results[activeIdx]) {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
navigateTo(results[activeIdx]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return (_jsx(Dialog, { open: open, onOpenChange: setOpen, children: _jsxs(DialogContent, { className: "max-w-2xl p-0 overflow-hidden", children: [_jsx(DialogHeader, { className: "sr-only", children: _jsx(DialogTitle, { children: tr('engine.quickfind.title', locale) }) }), _jsxs("div", { className: "border-b px-3 py-2 flex items-center gap-2", children: [_jsx(Search, { className: "h-4 w-4 text-muted-foreground" }), _jsx(Input, { autoFocus: true, placeholder: tr('engine.quickfind.placeholder', locale), value: query, onChange: (e) => setQuery(e.target.value), onKeyDown: onKeyDown, className: "border-0 shadow-none focus-visible:ring-0 px-0" }), _jsx(Kbd, { children: "esc" })] }), _jsxs("div", { className: "max-h-[440px] overflow-auto", children: [itemsLoading && (_jsxs("div", { className: "px-3 py-4 text-xs text-muted-foreground flex items-center gap-2", children: [_jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }), tr('engine.quickfind.indexing', locale), " ", typeEntries.length, "\u2026"] })), !itemsLoading && results.length === 0 && (_jsx("div", { className: "px-3 py-6 text-xs text-muted-foreground text-center", children: tr('engine.quickfind.noMatches', locale) })), _jsx("ul", { role: "listbox", children: results.map((r, i) => (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => navigateTo(r), onMouseEnter: () => setActiveIdx(i), className: 'w-full text-left px-3 py-2 flex items-center gap-3 hover:bg-accent ' +
|
|
149
|
+
(i === activeIdx ? 'bg-accent' : ''), children: [_jsx(Badge, { variant: "outline", className: "text-[10px] font-mono shrink-0", children: r.kind === 'type' ? 'type' : r.type }), _jsx("div", { className: "min-w-0 flex-1", children: r.kind === 'type' ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-sm font-medium truncate", children: r.entry.label ?? r.entry.type }), r.entry.description && (_jsx("div", { className: "text-xs text-muted-foreground truncate", children: r.entry.description }))] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "text-sm font-mono truncate", children: [r.name, r.label && (_jsx("span", { className: "text-muted-foreground font-sans ml-2", children: r.label }))] }), r.description && (_jsx("div", { className: "text-xs text-muted-foreground truncate", children: r.description }))] })) })] }) }, r.kind === 'type'
|
|
150
|
+
? `t:${r.entry.type}`
|
|
151
|
+
: `i:${r.type}:${r.name}`))) })] }), _jsxs("div", { className: "border-t px-3 py-1.5 flex items-center gap-3 text-[10px] text-muted-foreground", children: [_jsxs("span", { children: [_jsx(Kbd, { children: "\u2191" }), " ", _jsx(Kbd, { children: "\u2193" }), " navigate"] }), _jsxs("span", { children: [_jsx(Kbd, { children: "\u21B5" }), " open"] }), _jsxs("span", { className: "ml-auto", children: [_jsx(Kbd, { children: "\u2318" }), _jsx(Kbd, { children: "\u21E7" }), _jsx(Kbd, { children: "M" }), " toggle"] })] })] }) }));
|
|
152
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface MetadataResourceEditPageProps {
|
|
2
|
+
type: string;
|
|
3
|
+
name: string;
|
|
4
|
+
/** When true, this is the Create flow (skip initial fetch). */
|
|
5
|
+
createMode?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function MetadataResourceEditPage({ type, name, createMode, }: MetadataResourceEditPageProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -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
|
+
}
|