@object-ui/app-shell 6.1.0 → 6.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +129 -0
- package/README.md +10 -1
- package/dist/console/AppContent.js +53 -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 +169 -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 +68 -0
- package/dist/views/metadata-admin/DesignerEditorWrapper.js +158 -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/MetadataDetailDrawer.d.ts +13 -0
- package/dist/views/metadata-admin/MetadataDetailDrawer.js +52 -0
- package/dist/views/metadata-admin/PageShell.d.ts +34 -0
- package/dist/views/metadata-admin/PageShell.js +40 -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/RelatedPanel.d.ts +33 -0
- package/dist/views/metadata-admin/RelatedPanel.js +171 -0
- package/dist/views/metadata-admin/ResourceEditPage.d.ts +13 -0
- package/dist/views/metadata-admin/ResourceEditPage.js +302 -0
- package/dist/views/metadata-admin/ResourceHistoryPage.d.ts +5 -0
- package/dist/views/metadata-admin/ResourceHistoryPage.js +100 -0
- package/dist/views/metadata-admin/ResourceListPage.d.ts +4 -0
- package/dist/views/metadata-admin/ResourceListPage.js +146 -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 +565 -0
- package/dist/views/metadata-admin/anchors.d.ts +1 -0
- package/dist/views/metadata-admin/anchors.js +229 -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 +33 -0
- package/dist/views/metadata-admin/index.js +39 -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 +232 -0
- package/dist/views/metadata-admin/registry.js +106 -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,171 @@
|
|
|
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
|
+
}
|
|
146
|
+
: {
|
|
147
|
+
kind: 'metadata',
|
|
148
|
+
type: g.childType,
|
|
149
|
+
name: it.name,
|
|
150
|
+
}), 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));
|
|
151
|
+
})] }));
|
|
152
|
+
}
|
|
153
|
+
function normaliseItem(raw) {
|
|
154
|
+
const nameVal = pickString(raw, ['name', 'id', 'key']) ?? '(unnamed)';
|
|
155
|
+
const labelVal = pickString(raw, ['label', 'title', 'displayName']);
|
|
156
|
+
const descVal = pickString(raw, ['description', 'summary']);
|
|
157
|
+
return {
|
|
158
|
+
name: nameVal,
|
|
159
|
+
label: labelVal,
|
|
160
|
+
description: descVal,
|
|
161
|
+
raw,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function pickString(obj, keys) {
|
|
165
|
+
for (const k of keys) {
|
|
166
|
+
const v = obj[k];
|
|
167
|
+
if (typeof v === 'string' && v.length > 0)
|
|
168
|
+
return v;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
* 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;
|
|
12
|
+
}
|
|
13
|
+
export declare function MetadataResourceEditPage({ type: typeProp, name: nameProp, createMode, embedded, }: MetadataResourceEditPageProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,302 @@
|
|
|
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, useParams } from 'react-router-dom';
|
|
24
|
+
import { Save, RotateCcw, History, Link2, Loader2, AlertTriangle, Layers3, } 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, listAnchorsFor, } from './registry';
|
|
35
|
+
import { RelatedPanel } from './RelatedPanel';
|
|
36
|
+
import { MetadataDetailDrawer } from './MetadataDetailDrawer';
|
|
37
|
+
export function MetadataResourceEditPage({ type: typeProp, name: nameProp, createMode = false, embedded = false, }) {
|
|
38
|
+
const params = useParams();
|
|
39
|
+
const type = typeProp ?? params.type ?? '';
|
|
40
|
+
const name = nameProp ?? params.name ?? '';
|
|
41
|
+
const navigate = useNavigate();
|
|
42
|
+
const client = useMetadataClient();
|
|
43
|
+
const { entries } = useMetadataTypes(client);
|
|
44
|
+
const entry = entries.find((t) => t.type === type);
|
|
45
|
+
const config = resolveResourceConfig(type, entry);
|
|
46
|
+
// Custom editor takes over.
|
|
47
|
+
const customConfig = getMetadataResource(type);
|
|
48
|
+
if (customConfig?.EditPage && !createMode) {
|
|
49
|
+
const Custom = customConfig.EditPage;
|
|
50
|
+
return _jsx(Custom, { type: type, name: name });
|
|
51
|
+
}
|
|
52
|
+
if (customConfig?.CreatePage && createMode) {
|
|
53
|
+
const Custom = customConfig.CreatePage;
|
|
54
|
+
return _jsx(Custom, { type: type });
|
|
55
|
+
}
|
|
56
|
+
const [layered, setLayered] = React.useState(null);
|
|
57
|
+
const [draft, setDraft] = React.useState(createMode ? { name: '' } : {});
|
|
58
|
+
const [refs, setRefs] = React.useState(null);
|
|
59
|
+
const [loading, setLoading] = React.useState(!createMode);
|
|
60
|
+
const [saving, setSaving] = React.useState(false);
|
|
61
|
+
const [error, setError] = React.useState(null);
|
|
62
|
+
const [issues, setIssues] = React.useState([]);
|
|
63
|
+
const [destructiveIssues, setDestructiveIssues] = React.useState(null);
|
|
64
|
+
const [pendingItem, setPendingItem] = React.useState(null);
|
|
65
|
+
// Prefetch object name list once — fuels the `ref:object` widget.
|
|
66
|
+
// We don't block render on it; the widget shows a "Loading…" state.
|
|
67
|
+
const [objectNames, setObjectNames] = React.useState([]);
|
|
68
|
+
const [objectsLoading, setObjectsLoading] = React.useState(true);
|
|
69
|
+
React.useEffect(() => {
|
|
70
|
+
let cancelled = false;
|
|
71
|
+
(async () => {
|
|
72
|
+
try {
|
|
73
|
+
const list = (await client.list('object'));
|
|
74
|
+
if (cancelled)
|
|
75
|
+
return;
|
|
76
|
+
setObjectNames(list.map((x) => x?.name).filter((n) => !!n).sort());
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
if (!cancelled)
|
|
80
|
+
setObjectNames([]);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
if (!cancelled)
|
|
84
|
+
setObjectsLoading(false);
|
|
85
|
+
}
|
|
86
|
+
})();
|
|
87
|
+
return () => {
|
|
88
|
+
cancelled = true;
|
|
89
|
+
};
|
|
90
|
+
}, [client]);
|
|
91
|
+
const widgetContext = React.useMemo(() => ({ objectNames, objectsLoading }), [objectNames, objectsLoading]);
|
|
92
|
+
// Load layered view + initial draft.
|
|
93
|
+
React.useEffect(() => {
|
|
94
|
+
if (createMode) {
|
|
95
|
+
setLoading(false);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
let cancelled = false;
|
|
99
|
+
setLoading(true);
|
|
100
|
+
setError(null);
|
|
101
|
+
(async () => {
|
|
102
|
+
try {
|
|
103
|
+
const lay = await client.layered(type, name);
|
|
104
|
+
if (cancelled)
|
|
105
|
+
return;
|
|
106
|
+
setLayered(lay);
|
|
107
|
+
// Initial draft = effective if available, otherwise code.
|
|
108
|
+
const initial = (lay.effective ?? lay.code ?? {});
|
|
109
|
+
setDraft(initial);
|
|
110
|
+
setLoading(false);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (!cancelled) {
|
|
114
|
+
setError(err?.message ?? String(err));
|
|
115
|
+
setLoading(false);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
return () => {
|
|
120
|
+
cancelled = true;
|
|
121
|
+
};
|
|
122
|
+
}, [client, type, name, createMode]);
|
|
123
|
+
// Lazy-load references when the tab is opened.
|
|
124
|
+
const [refsLoading, setRefsLoading] = React.useState(false);
|
|
125
|
+
async function loadReferences() {
|
|
126
|
+
if (refs != null || refsLoading)
|
|
127
|
+
return;
|
|
128
|
+
setRefsLoading(true);
|
|
129
|
+
try {
|
|
130
|
+
const r = await client.references(type, name);
|
|
131
|
+
setRefs(r);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
// Surface as empty list; non-blocking.
|
|
135
|
+
setRefs([]);
|
|
136
|
+
console.error('references() failed', err);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
setRefsLoading(false);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Related drawer state. `null` = closed. We avoid querystring round-
|
|
143
|
+
// trips on every keystroke; URL state is best-effort sync via effect
|
|
144
|
+
// below.
|
|
145
|
+
const [relatedTarget, setRelatedTarget] = React.useState(null);
|
|
146
|
+
const hasAnchors = React.useMemo(() => !createMode && !embedded && listAnchorsFor(type).length > 0, [type, createMode, embedded]);
|
|
147
|
+
// Read ?tab and ?open on first mount so deep-links work. Embedded
|
|
148
|
+
// items are not deep-linkable (they live in the parent body and need
|
|
149
|
+
// the parent payload to materialise) so we only restore metadata
|
|
150
|
+
// targets here.
|
|
151
|
+
const initialTabRef = React.useRef(null);
|
|
152
|
+
React.useEffect(() => {
|
|
153
|
+
if (typeof window === 'undefined' || embedded)
|
|
154
|
+
return;
|
|
155
|
+
const sp = new URLSearchParams(window.location.search);
|
|
156
|
+
const tab = sp.get('tab');
|
|
157
|
+
if (tab)
|
|
158
|
+
initialTabRef.current = tab;
|
|
159
|
+
const open = sp.get('open');
|
|
160
|
+
if (open && open.includes(':')) {
|
|
161
|
+
const [t, n] = open.split(':', 2);
|
|
162
|
+
if (t && n)
|
|
163
|
+
setRelatedTarget({ kind: 'metadata', type: t, name: n });
|
|
164
|
+
}
|
|
165
|
+
// intentionally empty deps — first mount only
|
|
166
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
167
|
+
}, []);
|
|
168
|
+
// Reflect drawer target into the URL so refresh/share works.
|
|
169
|
+
React.useEffect(() => {
|
|
170
|
+
if (typeof window === 'undefined' || embedded)
|
|
171
|
+
return;
|
|
172
|
+
const url = new URL(window.location.href);
|
|
173
|
+
if (relatedTarget?.kind === 'metadata') {
|
|
174
|
+
url.searchParams.set('open', `${relatedTarget.type}:${relatedTarget.name}`);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
url.searchParams.delete('open');
|
|
178
|
+
}
|
|
179
|
+
window.history.replaceState({}, '', url.toString());
|
|
180
|
+
}, [relatedTarget, embedded]);
|
|
181
|
+
async function doSave(force) {
|
|
182
|
+
setSaving(true);
|
|
183
|
+
setError(null);
|
|
184
|
+
setIssues([]);
|
|
185
|
+
try {
|
|
186
|
+
// Ensure `name` is set on create.
|
|
187
|
+
const itemToSave = createMode
|
|
188
|
+
? { ...draft, name: String(draft.name ?? name) }
|
|
189
|
+
: draft;
|
|
190
|
+
const savedName = String(itemToSave.name ?? name);
|
|
191
|
+
if (!savedName) {
|
|
192
|
+
setError('A name is required.');
|
|
193
|
+
setSaving(false);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const result = await client.save(type, savedName, itemToSave, { force });
|
|
197
|
+
// Refresh layered after save.
|
|
198
|
+
const lay = await client.layered(type, savedName);
|
|
199
|
+
setLayered(lay);
|
|
200
|
+
setDraft((lay.effective ?? itemToSave));
|
|
201
|
+
setDestructiveIssues(null);
|
|
202
|
+
setPendingItem(null);
|
|
203
|
+
if (createMode) {
|
|
204
|
+
navigate(`../${encodeURIComponent(savedName)}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
// Map destructive change → confirmation dialog.
|
|
209
|
+
if (err?.status === 409 && err?.code === 'destructive_change') {
|
|
210
|
+
const i = err?.body?.issues ?? [];
|
|
211
|
+
setDestructiveIssues(Array.isArray(i) ? i : []);
|
|
212
|
+
setPendingItem(draft);
|
|
213
|
+
}
|
|
214
|
+
// Map schema validation → inline field errors.
|
|
215
|
+
else if (err?.status === 422 || err?.code === 'invalid_metadata' || err?.code === 'invalid_payload') {
|
|
216
|
+
const i = err?.body?.issues ?? [];
|
|
217
|
+
let mapped = (Array.isArray(i) ? i : []).map((x) => ({
|
|
218
|
+
path: Array.isArray(x.path) ? x.path.join('.') : String(x.path ?? ''),
|
|
219
|
+
message: String(x.message ?? 'Invalid'),
|
|
220
|
+
}));
|
|
221
|
+
// Backend's invalid_metadata sometimes returns a flat string like
|
|
222
|
+
// "<type>/<name> failed spec validation: <path>: <message>".
|
|
223
|
+
// Parse it into a single inline issue + summary so users see the
|
|
224
|
+
// real problem instead of "0 issues".
|
|
225
|
+
const raw = String(err?.body?.error ?? err?.message ?? '');
|
|
226
|
+
if (mapped.length === 0 && raw) {
|
|
227
|
+
const m = raw.match(/failed spec validation:\s*(.+?):\s*(.+)$/);
|
|
228
|
+
if (m) {
|
|
229
|
+
mapped = [{ path: m[1].trim(), message: m[2].trim() }];
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
mapped = [{ path: '', message: raw }];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
setIssues(mapped);
|
|
236
|
+
if (mapped.length === 1 && !mapped[0].path) {
|
|
237
|
+
setError(mapped[0].message);
|
|
238
|
+
}
|
|
239
|
+
else if (mapped.length === 1) {
|
|
240
|
+
setError(`${mapped[0].path}: ${mapped[0].message}`);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
setError(`Validation failed (${mapped.length} issues).`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
setError(err?.message ?? String(err));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
setSaving(false);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async function doReset() {
|
|
255
|
+
if (!confirm(`Reset overlay for ${type}/${name}? Code-level value will be restored.`)) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
setSaving(true);
|
|
259
|
+
setError(null);
|
|
260
|
+
try {
|
|
261
|
+
await client.reset(type, name);
|
|
262
|
+
const lay = await client.layered(type, name);
|
|
263
|
+
setLayered(lay);
|
|
264
|
+
setDraft((lay.effective ?? lay.code ?? {}));
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
setError(err?.message ?? String(err));
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
setSaving(false);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (loading) {
|
|
274
|
+
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"] }) }));
|
|
275
|
+
}
|
|
276
|
+
const schema = entry?.schema ??
|
|
277
|
+
config.defaultSchema;
|
|
278
|
+
const readOnly = !entry?.allowOrgOverride && !createMode;
|
|
279
|
+
const DesignerTab = !createMode ? customConfig?.DesignerTab : undefined;
|
|
280
|
+
const designerTabLabel = customConfig?.designerTabLabel ?? 'Designer';
|
|
281
|
+
return (_jsxs(PageShell, { entry: entry ?? { type, label: type }, itemName: createMode ? '(new)' : name, subtitle: createMode ? 'Create new' : 'Edit overlay', actions: _jsxs(_Fragment, { children: [!createMode && entry?.allowOrgOverride && (_jsxs(Button, { variant: "ghost", size: "sm", onClick: doReset, disabled: saving, children: [_jsx(RotateCcw, { className: "h-4 w-4 mr-1" }), "Reset overlay"] })), !createMode && (_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => navigate(`./history`), children: [_jsx(History, { className: "h-4 w-4 mr-1" }), "History"] })), entry?.allowOrgOverride && (_jsxs(Button, { size: "sm", onClick: () => doSave(false), disabled: saving, children: [saving ? (_jsx(Loader2, { className: "h-4 w-4 mr-1 animate-spin" })) : (_jsx(Save, { className: "h-4 w-4 mr-1" })), "Save"] }))] }), children: [_jsxs("div", { className: "p-6 space-y-6 max-w-7xl", children: [error && (_jsx("div", { className: "text-sm text-destructive border border-destructive/30 rounded p-3 bg-destructive/5", children: error })), readOnly && (_jsxs("div", { className: "text-xs text-amber-800 border border-amber-300 bg-amber-50 rounded p-3", children: ["This type is read-only. To enable runtime editing, set", ' ', _jsx("code", { className: "font-mono", children: "OBJECTSTACK_METADATA_WRITABLE" }), " to include ", _jsx("code", { className: "font-mono", children: type }), ", or flip", ' ', _jsx("code", { className: "font-mono", children: "allowOrgOverride" }), " in the registry."] })), _jsxs(Tabs, { defaultValue: initialTabRef.current ?? (DesignerTab ? 'designer' : 'form'), className: "w-full", onValueChange: (v) => {
|
|
282
|
+
if (typeof window === 'undefined' || embedded)
|
|
283
|
+
return;
|
|
284
|
+
const url = new URL(window.location.href);
|
|
285
|
+
url.searchParams.set('tab', v);
|
|
286
|
+
window.history.replaceState({}, '', url.toString());
|
|
287
|
+
}, children: [_jsxs(TabsList, { children: [DesignerTab && (_jsx(TabsTrigger, { value: "designer", children: designerTabLabel })), _jsx(TabsTrigger, { value: "form", children: "Form" }), !createMode && (_jsxs(TabsTrigger, { value: "layers", children: ["Layers", layered?.overlay && (_jsx(Badge, { className: "ml-1.5 text-[10px] bg-emerald-600 text-emerald-50", children: "overlay" }))] })), !createMode && (_jsxs(TabsTrigger, { value: "references", onClick: loadReferences, children: [_jsx(Link2, { className: "h-3.5 w-3.5 mr-1" }), "References", refs && (_jsx(Badge, { variant: "outline", className: "ml-1.5 text-[10px]", children: refs.length }))] })), hasAnchors && (_jsxs(TabsTrigger, { value: "related", children: [_jsx(Layers3, { className: "h-3.5 w-3.5 mr-1" }), "Related"] }))] }), DesignerTab && (_jsx(TabsContent, { value: "designer", className: "mt-4", children: _jsx(DesignerTab, { type: type, name: name }) })), _jsx(TabsContent, { value: "form", className: "mt-4", children: _jsx(SchemaForm, { schema: schema, form: entry?.form, value: draft, onChange: setDraft, issues: issues, hiddenFields: config.hiddenFields, fieldOrder: config.fieldOrder, readOnly: readOnly, createMode: createMode, widgetContext: widgetContext }) }), !createMode && (_jsx(TabsContent, { value: "layers", className: "mt-4", children: _jsx(LayeredDiff, { layered: layered }) })), !createMode && (_jsx(TabsContent, { value: "references", className: "mt-4", children: _jsx(ReferencesPanel, { refs: refs, loading: refsLoading }) })), hasAnchors && (_jsx(TabsContent, { value: "related", className: "mt-4", children: _jsx(RelatedPanel, { type: type, name: name, parentItem: draft, onOpen: (t) => setRelatedTarget(t) }) }))] })] }), _jsx(MetadataDetailDrawer, { target: relatedTarget, onClose: () => setRelatedTarget(null), parentContext: { type, name } }), _jsx(Dialog, { open: destructiveIssues != null, onOpenChange: (open) => {
|
|
288
|
+
if (!open) {
|
|
289
|
+
setDestructiveIssues(null);
|
|
290
|
+
setPendingItem(null);
|
|
291
|
+
}
|
|
292
|
+
}, 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' })] })] }) })] }));
|
|
293
|
+
}
|
|
294
|
+
function ReferencesPanel({ refs, loading, }) {
|
|
295
|
+
if (loading || refs == null) {
|
|
296
|
+
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"] }));
|
|
297
|
+
}
|
|
298
|
+
if (refs.length === 0) {
|
|
299
|
+
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." })] }));
|
|
300
|
+
}
|
|
301
|
+
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))) })] }) }));
|
|
302
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
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, useParams } 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: typeProp, name: nameProp, }) {
|
|
26
|
+
const params = useParams();
|
|
27
|
+
const type = typeProp ?? params.type ?? '';
|
|
28
|
+
const name = nameProp ?? params.name ?? '';
|
|
29
|
+
const navigate = useNavigate();
|
|
30
|
+
const client = useMetadataClient();
|
|
31
|
+
const { entries } = useMetadataTypes(client);
|
|
32
|
+
const entry = entries.find((t) => t.type === type);
|
|
33
|
+
const [events, setEvents] = React.useState([]);
|
|
34
|
+
const [loading, setLoading] = React.useState(true);
|
|
35
|
+
const [error, setError] = React.useState(null);
|
|
36
|
+
const [refreshKey, setRefreshKey] = React.useState(0);
|
|
37
|
+
const [expanded, setExpanded] = React.useState(null);
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
setLoading(true);
|
|
41
|
+
setError(null);
|
|
42
|
+
(async () => {
|
|
43
|
+
try {
|
|
44
|
+
const result = await client.history(type, name, { limit: 100 });
|
|
45
|
+
if (cancelled)
|
|
46
|
+
return;
|
|
47
|
+
const list = Array.isArray(result)
|
|
48
|
+
? result
|
|
49
|
+
: Array.isArray(result?.events)
|
|
50
|
+
? result.events
|
|
51
|
+
: [];
|
|
52
|
+
// Reverse chronological — most recent first.
|
|
53
|
+
const sorted = [...list].sort((a, b) => (b.seq ?? 0) - (a.seq ?? 0));
|
|
54
|
+
setEvents(sorted);
|
|
55
|
+
setLoading(false);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (!cancelled) {
|
|
59
|
+
setError(err?.message ?? String(err));
|
|
60
|
+
setLoading(false);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
return () => {
|
|
65
|
+
cancelled = true;
|
|
66
|
+
};
|
|
67
|
+
}, [client, type, name, refreshKey]);
|
|
68
|
+
return (_jsx(PageShell, { entry: entry ?? { type, label: type }, itemName: name, subtitle: "Version history", stats: [{ label: 'Events', value: events.length }], actions: _jsxs(_Fragment, { children: [_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => navigate(`../${encodeURIComponent(name)}`), children: [_jsx(ArrowLeft, { className: "h-4 w-4 mr-1" }), "Back to item"] }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setRefreshKey((k) => k + 1), children: _jsx(RefreshCw, { className: "h-4 w-4" }) })] }), children: _jsxs("div", { className: "p-6 max-w-4xl", children: [loading && (_jsxs("div", { className: "text-sm text-muted-foreground flex items-center gap-2", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), " Loading history\u2026"] })), error && (_jsx("div", { className: "text-sm text-destructive border border-destructive/30 rounded p-3 bg-destructive/5", children: error })), !loading && !error && events.length === 0 && (_jsxs(Empty, { children: [_jsx(EmptyTitle, { children: "No history yet" }), _jsx(EmptyDescription, { children: "This item has never been edited via an overlay. The first save will create the initial history record." })] })), !loading && events.length > 0 && (_jsx("ol", { className: "space-y-2 relative pl-6 border-l", children: events.map((ev, i) => {
|
|
69
|
+
const isOpen = expanded === i;
|
|
70
|
+
return (_jsxs("li", { className: "relative", children: [_jsx("span", { className: "absolute -left-[27px] top-2 w-3 h-3 rounded-full bg-primary ring-4 ring-background" }), _jsxs("div", { role: "button", tabIndex: 0, onClick: () => setExpanded(isOpen ? null : i), onKeyDown: (e) => {
|
|
71
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
72
|
+
setExpanded(isOpen ? null : i);
|
|
73
|
+
}
|
|
74
|
+
}, 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] ' +
|
|
75
|
+
(ev.kind === 'delete' || ev.kind === 'tombstone'
|
|
76
|
+
? 'bg-red-100 text-red-800 hover:bg-red-100'
|
|
77
|
+
: ev.kind === 'create'
|
|
78
|
+
? 'bg-blue-100 text-blue-800 hover:bg-blue-100'
|
|
79
|
+
: '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}`));
|
|
80
|
+
}) }))] }) }));
|
|
81
|
+
}
|
|
82
|
+
function formatWhen(at) {
|
|
83
|
+
try {
|
|
84
|
+
const d = typeof at === 'number' ? new Date(at) : new Date(at);
|
|
85
|
+
if (Number.isNaN(d.getTime()))
|
|
86
|
+
return String(at);
|
|
87
|
+
return d.toLocaleString();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return String(at);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function safeStringify(v) {
|
|
94
|
+
try {
|
|
95
|
+
return JSON.stringify(v, null, 2);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return String(v);
|
|
99
|
+
}
|
|
100
|
+
}
|