@object-ui/app-shell 6.2.1 → 6.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/console/ai/AiChatPage.js +19 -22
  3. package/dist/layout/ConsoleFloatingChatbot.js +44 -24
  4. package/dist/views/metadata-admin/EmbeddedItemEditor.d.ts +15 -0
  5. package/dist/views/metadata-admin/EmbeddedItemEditor.js +194 -0
  6. package/dist/views/metadata-admin/MetadataDetailDrawer.js +2 -26
  7. package/dist/views/metadata-admin/RelatedPanel.d.ts +4 -0
  8. package/dist/views/metadata-admin/RelatedPanel.js +2 -0
  9. package/dist/views/metadata-admin/ResourceEditPage.js +50 -5
  10. package/dist/views/metadata-admin/anchors.js +6 -0
  11. package/dist/views/metadata-admin/index.d.ts +2 -0
  12. package/dist/views/metadata-admin/index.js +6 -0
  13. package/dist/views/metadata-admin/preview-registry.d.ts +43 -0
  14. package/dist/views/metadata-admin/preview-registry.js +18 -0
  15. package/dist/views/metadata-admin/previews/AppPreview.d.ts +2 -0
  16. package/dist/views/metadata-admin/previews/AppPreview.js +101 -0
  17. package/dist/views/metadata-admin/previews/DashboardPreview.d.ts +2 -0
  18. package/dist/views/metadata-admin/previews/DashboardPreview.js +25 -0
  19. package/dist/views/metadata-admin/previews/EmailTemplatePreview.d.ts +2 -0
  20. package/dist/views/metadata-admin/previews/EmailTemplatePreview.js +65 -0
  21. package/dist/views/metadata-admin/previews/ObjectPreview.d.ts +2 -0
  22. package/dist/views/metadata-admin/previews/ObjectPreview.js +36 -0
  23. package/dist/views/metadata-admin/previews/PagePreview.d.ts +2 -0
  24. package/dist/views/metadata-admin/previews/PagePreview.js +26 -0
  25. package/dist/views/metadata-admin/previews/PreviewShell.d.ts +40 -0
  26. package/dist/views/metadata-admin/previews/PreviewShell.js +49 -0
  27. package/dist/views/metadata-admin/previews/ReportPreview.d.ts +2 -0
  28. package/dist/views/metadata-admin/previews/ReportPreview.js +27 -0
  29. package/dist/views/metadata-admin/previews/ViewPreview.d.ts +2 -0
  30. package/dist/views/metadata-admin/previews/ViewPreview.js +113 -0
  31. package/dist/views/metadata-admin/previews/index.d.ts +1 -0
  32. package/dist/views/metadata-admin/previews/index.js +26 -0
  33. package/dist/views/metadata-admin/registry.d.ts +17 -0
  34. package/package.json +26 -26
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 6.2.2
4
+
5
+ ### Patch Changes
6
+
7
+ - c5821ce: `AiChatPage` no longer PATCHes a client-side title-from-first-message
8
+ on the freshly-created conversation. The server (`@objectstack/service-ai`
9
+ ≥ next minor) now generates a concise LLM-summarised title fire-and-forget
10
+ after the first assistant turn lands, and a client-side truncated title
11
+ would race that and win — pinning every conversation row to a 40-char
12
+ substring of the first user message instead of a real summary.
13
+
14
+ Drop the PATCH; bump the sidebar list a couple of times (2.5 s + 6 s)
15
+ to pick up the LLM title whenever the model finally responds.
16
+
17
+ - 3b35084: Fix: floating chatbot now replays persisted conversation history on mount.
18
+
19
+ The right-corner floating chatbot (`ConsoleFloatingChatbot`) was passing only
20
+ `conversationId` to its inner `useObjectChat`, dropping the `initialMessages`
21
+ returned by `useChatConversation`. Backend persistence already worked — the
22
+ server-side `ai_conversation` + `ai_message` rows were created and survived a
23
+ page refresh — but the UI started each session with just the static "welcome"
24
+ bubble, making users believe their history had been lost.
25
+
26
+ Now matches the `/ai/:conversationId` full-page chat: history is hydrated
27
+ into the chat surface, and the welcome bubble is suppressed when prior turns
28
+ exist (showing it above real user/assistant turns is confusing).
29
+
30
+ - Updated dependencies [a66f788]
31
+ - @object-ui/react@6.2.2
32
+ - @object-ui/components@6.2.2
33
+ - @object-ui/fields@6.2.2
34
+ - @object-ui/layout@6.2.2
35
+ - @object-ui/plugin-editor@6.2.2
36
+ - @object-ui/types@6.2.2
37
+ - @object-ui/core@6.2.2
38
+ - @object-ui/i18n@6.2.2
39
+ - @object-ui/data-objectstack@6.2.2
40
+ - @object-ui/auth@6.2.2
41
+ - @object-ui/permissions@6.2.2
42
+ - @object-ui/collaboration@6.2.2
43
+ - @object-ui/providers@6.2.2
44
+
3
45
  ## 6.2.1
4
46
 
5
47
  ### Patch Changes
@@ -15,7 +15,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
15
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
16
  import { useNavigate, useParams } from 'react-router-dom';
17
17
  import { useAuth } from '@object-ui/auth';
18
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@object-ui/components';
18
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Button, ShareDialog, } from '@object-ui/components';
19
+ import { Share2 } from 'lucide-react';
19
20
  import { ChatbotEnhanced, useAgents, useObjectChat, useHitlInChat, } from '@object-ui/plugin-chatbot';
20
21
  import { AppHeader } from '../../layout/AppHeader';
21
22
  import { useNavigationContext } from '../../context/NavigationContext';
@@ -63,6 +64,8 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
63
64
  activeId: urlConversationId,
64
65
  });
65
66
  const [refreshKey, setRefreshKey] = useState(0);
67
+ const [shareOpen, setShareOpen] = useState(false);
68
+ const restApiBase = useMemo(() => apiBase.replace(/\/v1\/ai$/, '').replace(/\/ai$/, '') || '/api', [apiBase]);
66
69
  // After the hook resolves a real id for a fresh `/ai` visit, mirror it into
67
70
  // the URL so the sidebar's active-row + share/refresh both work.
68
71
  useEffect(() => {
@@ -81,32 +84,26 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
81
84
  const handleSent = useCallback((firstUserMessage) => {
82
85
  // New user turn → bump sidebar list so the row's preview/timestamp refreshes.
83
86
  setRefreshKey((k) => k + 1);
84
- // Auto-title the conversation from the first user message. Server only
85
- // tracks `title` when we PATCH it; without this every row shows the same
86
- // "New conversation" placeholder. We mark `conversationId` as titled in a
87
- // ref so subsequent turns don't re-rename and clobber a manual rename.
87
+ // Server now generates a concise LLM-summarised title fire-and-forget
88
+ // after the first assistant turn lands (see service-ai
89
+ // `summarizeConversation`). We don't PATCH a truncated preview from the
90
+ // client anymore that races the LLM and wins, which would block the
91
+ // real title. Instead, bump the sidebar a couple of times so the new
92
+ // title is picked up whenever the model finally responds.
88
93
  if (!firstUserMessage || !conversationId)
89
94
  return;
90
95
  if (titledRef.current.has(conversationId))
91
96
  return;
92
97
  titledRef.current.add(conversationId);
93
- const text = firstUserMessage.trim();
94
- if (!text)
95
- return;
96
- const truncated = text.length > 40 ? `${text.slice(0, 40)}…` : text;
97
- fetch(`${apiBase}/conversations/${encodeURIComponent(conversationId)}`, {
98
- method: 'PATCH',
99
- credentials: 'include',
100
- headers: { 'Content-Type': 'application/json' },
101
- body: JSON.stringify({ title: truncated }),
102
- })
103
- .then(() => setRefreshKey((k) => k + 1))
104
- .catch(() => {
105
- // Leave it untitled; user can rename manually.
106
- titledRef.current.delete(conversationId);
107
- });
108
- }, [apiBase, conversationId]);
109
- return (_jsxs("div", { className: "flex h-svh w-full flex-col bg-background", "data-testid": "ai-chat-page", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "home" }) }), _jsxs("div", { className: "flex flex-1 min-h-0 w-full", children: [_jsx(ConversationsSidebar, { userId: userId, apiBase: apiBase, refreshKey: refreshKey, className: "hidden w-72 shrink-0 border-r md:flex" }), _jsx("main", { className: "flex flex-1 min-w-0 flex-col", children: _jsx(ChatPane, { agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, onAgentChange: setActiveAgent, chatApi: chatApi, apiBase: apiBase, conversationId: conversationId, initialMessages: initialMessages, hydrating: convoLoading, onSent: handleSent }, `${chatApi ?? 'local'}:${conversationId ?? 'pending'}`) })] })] }));
98
+ const bump = () => setRefreshKey((k) => k + 1);
99
+ const t1 = setTimeout(bump, 2500);
100
+ const t2 = setTimeout(bump, 6000);
101
+ // Best-effort: if the component unmounts before the bumps fire, the
102
+ // setRefreshKey call is a no-op so we don't bother tracking the timers.
103
+ void t1;
104
+ void t2;
105
+ }, [conversationId]);
106
+ return (_jsxs("div", { className: "flex h-svh w-full flex-col bg-background", "data-testid": "ai-chat-page", children: [_jsxs("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: [_jsx(AppHeader, { variant: "home" }), _jsx("div", { className: "ml-auto flex items-center gap-2", children: _jsxs(Button, { variant: "outline", size: "sm", className: "h-8 gap-1.5", onClick: () => setShareOpen(true), disabled: !conversationId, "data-testid": "ai-chat-share-button", title: conversationId ? 'Share this conversation' : 'Start chatting to enable sharing', children: [_jsx(Share2, { className: "h-3.5 w-3.5" }), "Share"] }) })] }), conversationId && (_jsx(ShareDialog, { open: shareOpen, onOpenChange: setShareOpen, objectName: "ai_conversations", recordId: conversationId, recordLabel: "this conversation", apiBase: restApiBase })), _jsxs("div", { className: "flex flex-1 min-h-0 w-full", children: [_jsx(ConversationsSidebar, { userId: userId, apiBase: apiBase, refreshKey: refreshKey, className: "hidden w-72 shrink-0 border-r md:flex" }), _jsx("main", { className: "flex flex-1 min-w-0 flex-col", children: _jsx(ChatPane, { agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, onAgentChange: setActiveAgent, chatApi: chatApi, apiBase: apiBase, conversationId: conversationId, initialMessages: initialMessages, hydrating: convoLoading, onSent: handleSent }, `${chatApi ?? 'local'}:${conversationId ?? 'pending'}`) })] })] }));
110
107
  }
111
108
  function ChatPane({ agents, agentsLoading, agentsError, activeAgent, onAgentChange, chatApi, apiBase, conversationId, initialMessages, hydrating, onSent, }) {
112
109
  const activeAgentLabel = useMemo(() => {
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * ConsoleFloatingChatbot
4
4
  *
@@ -13,7 +13,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
13
  */
14
14
  import React from 'react';
15
15
  import { FloatingChatbot, useObjectChat, useAgents, useHitlInChat, } from '@object-ui/plugin-chatbot';
16
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@object-ui/components';
16
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Button, ShareDialog, } from '@object-ui/components';
17
+ import { Share2 } from 'lucide-react';
17
18
  import { useChatConversation } from '../hooks';
18
19
  const DEFAULT_AI_PATH = '/api/v1/ai';
19
20
  function resolveApiBase(explicit) {
@@ -26,8 +27,19 @@ function resolveApiBase(explicit) {
26
27
  const serverUrl = env.VITE_SERVER_URL ?? '';
27
28
  return `${serverUrl.replace(/\/$/, '')}${DEFAULT_AI_PATH}`;
28
29
  }
29
- function ChatbotInner({ appLabel, objects, agents, agentsLoading, agentsError, activeAgent, onAgentChange, chatApi, apiBase, defaultOpen = false, conversationId, }) {
30
+ function ChatbotInner({ appLabel, objects, agents, agentsLoading, agentsError, activeAgent, onAgentChange, chatApi, apiBase, defaultOpen = false, conversationId, initialMessages: persistedMessages, }) {
30
31
  const objectNames = objects.map((o) => o.label || o.name).join(', ');
32
+ // Replay persisted history when present. Suppress the static "welcome"
33
+ // bubble in that case — showing it above real prior turns is confusing.
34
+ const hydratedHistory = React.useMemo(() => {
35
+ if (!persistedMessages || persistedMessages.length === 0)
36
+ return [];
37
+ return persistedMessages.map((m) => ({
38
+ id: m.id,
39
+ role: m.role,
40
+ content: m.parts.map((p) => p.text).join(''),
41
+ }));
42
+ }, [persistedMessages]);
31
43
  const activeAgentLabel = React.useMemo(() => {
32
44
  const found = agents.find((a) => a.name === activeAgent);
33
45
  return found?.label ?? activeAgent ?? appLabel;
@@ -45,13 +57,15 @@ function ChatbotInner({ appLabel, objects, agents, agentsLoading, agentsError, a
45
57
  agentName: activeAgent,
46
58
  },
47
59
  },
48
- initialMessages: [
49
- {
50
- id: 'welcome',
51
- role: 'assistant',
52
- content: welcomeContent,
53
- },
54
- ],
60
+ initialMessages: hydratedHistory.length > 0
61
+ ? hydratedHistory
62
+ : [
63
+ {
64
+ id: 'welcome',
65
+ role: 'assistant',
66
+ content: welcomeContent,
67
+ },
68
+ ],
55
69
  // Local-mode fallback: only used when `chatApi` is undefined (no agent
56
70
  // resolved yet, or no backend available). Keeps the UI usable.
57
71
  autoResponse: !chatApi,
@@ -73,18 +87,24 @@ function ChatbotInner({ appLabel, objects, agents, agentsLoading, agentsError, a
73
87
  },
74
88
  });
75
89
  const headerExtra = agents.length > 0 ? (_jsxs(Select, { value: activeAgent, onValueChange: onAgentChange, disabled: agentsLoading, children: [_jsx(SelectTrigger, { className: "h-7 w-[180px] text-xs", "data-testid": "floating-chatbot-agent-picker", children: _jsx(SelectValue, { placeholder: "Choose agent..." }) }), _jsx(SelectContent, { align: "end", children: agents.map((agent) => (_jsxs(SelectItem, { value: agent.name, className: "text-xs", children: [_jsx("span", { className: "font-medium", children: agent.label }), agent.description ? (_jsx("span", { className: "block text-muted-foreground text-[10px] truncate max-w-[220px]", children: agent.description })) : null] }, agent.name))) })] })) : null;
76
- return (_jsx(FloatingChatbot, { floatingConfig: {
77
- position: 'bottom-right',
78
- defaultOpen,
79
- panelWidth: 420,
80
- panelHeight: 560,
81
- title: `${appLabel} Assistant`,
82
- triggerSize: 56,
83
- }, headerExtra: headerExtra, messages: messages, placeholder: activeAgent
84
- ? `Ask ${activeAgentLabel}...`
85
- : agentsLoading
86
- ? 'Loading agents...'
87
- : 'Ask anything...', onSendMessage: (content) => sendMessage(content), onClear: clear, onStop: isLoading ? stop : undefined, onReload: reload, isLoading: isLoading, error: error, enableMarkdown: true, onToolApprove: hitl.decide, toolDecisions: hitl.decisions, toolApproveLabel: "Approve & run", toolDenyLabel: "Reject", toolDenyReason: "Operator rejected from chat" }));
90
+ // Share-link control. Sits to the left of the panel's built-in
91
+ // fullscreen / close buttons so users can mint a public link without
92
+ // jumping out to the full `/ai/:id` page.
93
+ const [shareOpen, setShareOpen] = React.useState(false);
94
+ const restApiBase = React.useMemo(() => apiBase.replace(/\/v1\/ai$/, '').replace(/\/ai$/, '') || '/api', [apiBase]);
95
+ const headerActions = conversationId ? (_jsx(Button, { variant: "ghost", size: "icon", className: "h-7 w-7", "aria-label": "Share conversation", "data-testid": "floating-chatbot-share", onClick: () => setShareOpen(true), children: _jsx(Share2, { className: "h-4 w-4" }) })) : null;
96
+ return (_jsxs(_Fragment, { children: [_jsx(FloatingChatbot, { floatingConfig: {
97
+ position: 'bottom-right',
98
+ defaultOpen,
99
+ panelWidth: 420,
100
+ panelHeight: 560,
101
+ title: `${appLabel} Assistant`,
102
+ triggerSize: 56,
103
+ }, headerExtra: headerExtra, headerActions: headerActions, messages: messages, placeholder: activeAgent
104
+ ? `Ask ${activeAgentLabel}...`
105
+ : agentsLoading
106
+ ? 'Loading agents...'
107
+ : 'Ask anything...', onSendMessage: (content) => sendMessage(content), onClear: clear, onStop: isLoading ? stop : undefined, onReload: reload, isLoading: isLoading, error: error, enableMarkdown: true, onToolApprove: hitl.decide, toolDecisions: hitl.decisions, toolApproveLabel: "Approve & run", toolDenyLabel: "Reject", toolDenyReason: "Operator rejected from chat" }), conversationId && (_jsx(ShareDialog, { open: shareOpen, onOpenChange: setShareOpen, objectName: "ai_conversations", recordId: conversationId, recordLabel: "this conversation", apiBase: restApiBase }))] }));
88
108
  }
89
109
  export default function ConsoleFloatingChatbot({ appLabel, objects, apiBase: apiBaseProp, defaultAgent: defaultAgentProp, defaultOpen = false, userId, }) {
90
110
  const apiBase = React.useMemo(() => resolveApiBase(apiBaseProp), [apiBaseProp]);
@@ -105,7 +125,7 @@ export default function ConsoleFloatingChatbot({ appLabel, objects, apiBase: api
105
125
  // Server-backed conversation. Scoped by agent so each agent gets its own
106
126
  // persistent history. Hook is inert until `userId` is provided; without it
107
127
  // the FAB continues to work in local-only mode (no persistence).
108
- const { conversationId } = useChatConversation({
128
+ const { conversationId, initialMessages } = useChatConversation({
109
129
  userId,
110
130
  scope: activeAgent,
111
131
  apiBase,
@@ -113,5 +133,5 @@ export default function ConsoleFloatingChatbot({ appLabel, objects, apiBase: api
113
133
  // `key` forces a clean remount whenever the chat endpoint OR the resolved
114
134
  // conversation id changes — required because `useObjectChat` locks its mode
115
135
  // (api vs local) and its `conversationId` on first render.
116
- return (_jsx(ChatbotInner, { appLabel: appLabel, objects: objects, agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, onAgentChange: setActiveAgent, chatApi: chatApi, apiBase: apiBase, defaultOpen: defaultOpen, conversationId: conversationId }, `${chatApi ?? 'local'}:${conversationId ?? 'pending'}`));
136
+ return (_jsx(ChatbotInner, { appLabel: appLabel, objects: objects, agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, onAgentChange: setActiveAgent, chatApi: chatApi, apiBase: apiBase, defaultOpen: defaultOpen, conversationId: conversationId, initialMessages: initialMessages }, `${chatApi ?? 'local'}:${conversationId ?? 'pending'}`));
117
137
  }
@@ -0,0 +1,15 @@
1
+ export interface EmbeddedItemEditorProps {
2
+ parentType: string;
3
+ parentName: string;
4
+ /** Dotted path inside parent body where the collection lives. */
5
+ embeddedPath?: string;
6
+ /** Key of the item within the collection (e.g. field name). */
7
+ itemName: string;
8
+ /** Metadata type whose schema/form drives the form. */
9
+ editAs?: string;
10
+ /** Snapshot of the item at the moment of opening (initial draft). */
11
+ initialRaw: Record<string, unknown>;
12
+ /** Called after a successful save with the freshly-saved item. */
13
+ onSaved?: (item: Record<string, unknown>) => void;
14
+ }
15
+ export declare function EmbeddedItemEditor({ parentType, parentName, embeddedPath, itemName, editAs, initialRaw, onSaved, }: EmbeddedItemEditorProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,194 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
3
+ /**
4
+ * EmbeddedItemEditor — full-form editor for items that live INSIDE a
5
+ * parent metadata body (e.g. `object.fields.email`).
6
+ *
7
+ * Embedded items don't have their own HTTP endpoint (`PUT /meta/field/email`
8
+ * does NOT exist for object-scoped fields) — so we:
9
+ * 1. Re-fetch the parent's effective body.
10
+ * 2. Render a SchemaForm using the registered sub-type's schema / form
11
+ * (e.g. `field` for `object.fields`).
12
+ * 3. On save: deep-clone the parent, splice the modified item back
13
+ * under `parent.<embeddedPath>.<itemName>`, and PUT the parent.
14
+ *
15
+ * If the sub-type isn't registered (e.g. `index` has no `editAs`), we
16
+ * fall back to a raw-JSON editor so users can still hand-edit and save.
17
+ */
18
+ import * as React from 'react';
19
+ import { Loader2, Save, AlertTriangle } from 'lucide-react';
20
+ import { Button } from '@object-ui/components';
21
+ import { SchemaForm } from './SchemaForm';
22
+ import { useMetadataClient, useMetadataTypes } from './useMetadata';
23
+ export function EmbeddedItemEditor({ parentType, parentName, embeddedPath, itemName, editAs, initialRaw, onSaved, }) {
24
+ const client = useMetadataClient();
25
+ const { entries } = useMetadataTypes(client);
26
+ const subEntry = editAs ? entries.find((e) => e.type === editAs) : undefined;
27
+ // Fallback inline schemas for sub-types the framework registers without
28
+ // a JSON-Schema (validation / index live in object.body but the
29
+ // registry only exposes their metadata, not their shape). The
30
+ // schemas below mirror the framework's Zod definitions and the
31
+ // shapes we see on disk under packages/data-objectstack fixtures.
32
+ const fallback = !subEntry?.schema && editAs ? FALLBACK_SCHEMAS[editAs] : undefined;
33
+ const schema = subEntry?.schema ?? fallback?.schema;
34
+ const form = subEntry?.form ?? fallback?.form;
35
+ const [draft, setDraft] = React.useState(initialRaw);
36
+ const [saving, setSaving] = React.useState(false);
37
+ const [error, setError] = React.useState(null);
38
+ const [issues, setIssues] = React.useState([]);
39
+ const [savedAt, setSavedAt] = React.useState(null);
40
+ // Resync if a different item is opened in the drawer.
41
+ React.useEffect(() => {
42
+ setDraft(initialRaw);
43
+ setError(null);
44
+ setIssues([]);
45
+ setSavedAt(null);
46
+ }, [initialRaw, itemName, parentType, parentName]);
47
+ const readOnly = subEntry != null && !subEntry.allowOrgOverride;
48
+ async function doSave() {
49
+ if (!embeddedPath) {
50
+ setError('Cannot save: this item has no embeddedPath registered.');
51
+ return;
52
+ }
53
+ setSaving(true);
54
+ setError(null);
55
+ setIssues([]);
56
+ try {
57
+ // 1. Re-fetch parent to avoid clobbering concurrent edits.
58
+ const layered = await client.layered(parentType, parentName);
59
+ const parent = (layered.effective ?? layered.code ?? {});
60
+ // 2. Splice modified item back into the parent collection.
61
+ const updated = spliceEmbedded(parent, embeddedPath, itemName, draft);
62
+ // 3. PUT the parent.
63
+ await client.save(parentType, parentName, updated);
64
+ setSavedAt(Date.now());
65
+ onSaved?.(draft);
66
+ }
67
+ catch (err) {
68
+ // Validation issues from the parent save apply to the embedded
69
+ // path. Try to scope them back to this item.
70
+ if (err?.status === 422 || err?.code === 'invalid_metadata' || err?.code === 'invalid_payload') {
71
+ const raw = err?.body?.issues ?? [];
72
+ const mapped = (Array.isArray(raw) ? raw : []).map((x) => {
73
+ const fullPath = Array.isArray(x.path) ? x.path.join('.') : String(x.path ?? '');
74
+ // Trim the `<embeddedPath>.<itemName>.` prefix so issues
75
+ // align with the field they reference inside the sub-form.
76
+ const prefix = `${embeddedPath}.${itemName}.`;
77
+ const trimmed = fullPath.startsWith(prefix)
78
+ ? fullPath.slice(prefix.length)
79
+ : fullPath;
80
+ return { path: trimmed, message: String(x.message ?? 'Invalid') };
81
+ });
82
+ setIssues(mapped);
83
+ setError(`Validation failed (${mapped.length} issue${mapped.length === 1 ? '' : 's'}).`);
84
+ }
85
+ else {
86
+ setError(err?.message ?? String(err));
87
+ }
88
+ }
89
+ finally {
90
+ setSaving(false);
91
+ }
92
+ }
93
+ // No schema registered for this sub-type: fall back to JSON editing.
94
+ if (!schema) {
95
+ return (_jsxs("div", { className: "p-4 space-y-3", children: [_jsxs("div", { className: "text-xs text-muted-foreground", children: ["No form schema is registered for", ' ', _jsx("code", { className: "font-mono", children: editAs ?? 'this item' }), ". Edit the raw JSON below; saving will splice it back into", ' ', _jsxs("span", { className: "font-mono", children: [parentType, "/", parentName, ".", embeddedPath, ".", itemName] }), "."] }), _jsx("textarea", { className: "w-full h-[60vh] font-mono text-xs border rounded p-3 bg-muted/30", value: JSON.stringify(draft, null, 2), onChange: (e) => {
96
+ try {
97
+ setDraft(JSON.parse(e.target.value));
98
+ setError(null);
99
+ }
100
+ catch (err) {
101
+ setError(`Invalid JSON: ${err.message}`);
102
+ }
103
+ } }), error && (_jsxs("div", { className: "text-sm text-destructive border border-destructive/30 rounded p-2 bg-destructive/5", children: [_jsx(AlertTriangle, { className: "h-4 w-4 inline mr-1" }), " ", error] })), _jsx("div", { className: "flex justify-end", children: _jsxs(Button, { onClick: doSave, disabled: saving || !embeddedPath, children: [saving ? (_jsx(Loader2, { className: "h-4 w-4 mr-1 animate-spin" })) : (_jsx(Save, { className: "h-4 w-4 mr-1" })), "Save into parent"] }) })] }));
104
+ }
105
+ return (_jsxs("div", { className: "p-4 space-y-4", children: [error && (_jsx("div", { className: "text-sm text-destructive border border-destructive/30 rounded p-3 bg-destructive/5", children: error })), savedAt != null && !error && (_jsx("div", { className: "text-sm text-emerald-700 border border-emerald-300 bg-emerald-50 rounded p-2", children: "Saved." })), readOnly && (_jsx("div", { className: "text-xs text-amber-800 border border-amber-300 bg-amber-50 rounded p-2", children: "The parent type is read-only \u2014 saving will still attempt a PUT and may be refused by the server." })), _jsx(SchemaForm, { schema: schema, form: form, value: draft, onChange: setDraft, issues: issues }), _jsx("div", { className: "flex justify-end gap-2 pt-2 border-t", children: _jsxs(Button, { onClick: doSave, disabled: saving || !embeddedPath, children: [saving ? (_jsx(Loader2, { className: "h-4 w-4 mr-1 animate-spin" })) : (_jsx(Save, { className: "h-4 w-4 mr-1" })), "Save into ", parentType] }) }), !embeddedPath && (_jsxs("div", { className: "text-xs text-muted-foreground border rounded p-3", children: [_jsx("div", { className: "font-medium", children: "Read-only" }), _jsx("div", { children: "No embedded path registered \u2014 cannot determine where to write this item back." })] }))] }));
106
+ }
107
+ /**
108
+ * Return a NEW parent object with the embedded item replaced. The
109
+ * collection at `path` may be either a name-keyed map (`object.fields`)
110
+ * or an array of `{ name, … }` entries (`object.indexes`); we keep the
111
+ * existing shape.
112
+ */
113
+ function spliceEmbedded(parent, path, itemName, item) {
114
+ const segs = path.split('.');
115
+ const next = structuredClone(parent);
116
+ let cur = next;
117
+ for (let i = 0; i < segs.length - 1; i++) {
118
+ if (cur[segs[i]] == null)
119
+ cur[segs[i]] = {};
120
+ cur = cur[segs[i]];
121
+ }
122
+ const leaf = segs[segs.length - 1];
123
+ const existing = cur[leaf];
124
+ if (Array.isArray(existing)) {
125
+ const idx = existing.findIndex((x) => x && typeof x === 'object' && x.name === itemName);
126
+ if (idx >= 0) {
127
+ existing[idx] = item;
128
+ }
129
+ else {
130
+ existing.push(item);
131
+ }
132
+ }
133
+ else if (existing && typeof existing === 'object') {
134
+ // Strip the synthetic `name` we injected when extracting the map.
135
+ const { name: _n, ...rest } = item;
136
+ existing[itemName] = rest;
137
+ }
138
+ else {
139
+ // Path didn't exist — initialise as a map keyed by item name.
140
+ const { name: _n, ...rest } = item;
141
+ cur[leaf] = { [itemName]: rest };
142
+ }
143
+ return next;
144
+ }
145
+ /**
146
+ * Inline JSONSchema + form-spec fallbacks for sub-types that the
147
+ * framework's `/meta` registry doesn't expose a `schema`/`form` for.
148
+ *
149
+ * `index` is the only one we still need to ship here: it's an
150
+ * embedded-only sub-type (lives inside `object.indexes[]`) and is not
151
+ * registered as a standalone metadata type, so the server has no slot
152
+ * to publish a schema for it. Once / if the framework grows an
153
+ * embedded-sub-type registry, this can move server-side too.
154
+ *
155
+ * `validation` used to be here but is now published by the server via
156
+ * `HAND_CRAFTED_SCHEMAS.validation` in `objectql/protocol.ts`.
157
+ */
158
+ const FALLBACK_SCHEMAS = {
159
+ index: {
160
+ schema: {
161
+ type: 'object',
162
+ required: ['fields'],
163
+ properties: {
164
+ name: {
165
+ type: 'string',
166
+ title: 'Name',
167
+ description: 'Synthesised from columns if omitted (e.g. idx_email).',
168
+ },
169
+ fields: {
170
+ type: 'array',
171
+ title: 'Fields',
172
+ description: 'Columns to index, in order.',
173
+ items: { type: 'string' },
174
+ },
175
+ type: {
176
+ type: 'string',
177
+ title: 'Algorithm',
178
+ enum: ['btree', 'hash', 'gin', 'gist', 'brin'],
179
+ default: 'btree',
180
+ },
181
+ unique: {
182
+ type: 'boolean',
183
+ title: 'Unique',
184
+ description: 'Enforce uniqueness across the indexed columns.',
185
+ },
186
+ where: {
187
+ type: 'string',
188
+ title: 'Partial-index predicate',
189
+ description: 'Optional WHERE clause for a partial index.',
190
+ },
191
+ },
192
+ },
193
+ },
194
+ };
@@ -1,23 +1,9 @@
1
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
- * MetadataDetailDrawer — slide-over editor for a related metadata item.
5
- *
6
- * Opens from the parent's Related tab without taking the user away
7
- * from the parent's context. Internally we mount the same
8
- * `MetadataResourceEditPage` used by the full-page route, so all the
9
- * Save / Reset / Validate behaviour is shared. The drawer just frames
10
- * it and adds a "Open full page ↗" affordance.
11
- *
12
- * Width is wide enough for forms (max 1100px) but capped at 92vw to
13
- * leave a thin strip of the parent visible behind, reinforcing the
14
- * "still in the same object" feel.
15
- */
16
- import * as React from 'react';
17
2
  import { useNavigate } from 'react-router-dom';
18
3
  import { ExternalLink } from 'lucide-react';
19
4
  import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, Button, Badge, cn, } from '@object-ui/components';
20
5
  import { MetadataResourceEditPage } from './ResourceEditPage';
6
+ import { EmbeddedItemEditor } from './EmbeddedItemEditor';
21
7
  export function MetadataDetailDrawer({ target, onClose, parentContext, }) {
22
8
  const navigate = useNavigate();
23
9
  const isMetadata = target?.kind === 'metadata';
@@ -38,15 +24,5 @@ export function MetadataDetailDrawer({ target, onClose, parentContext, }) {
38
24
  }, children: _jsxs(SheetContent, { side: "right", className: cn('w-[92vw] sm:max-w-[1100px] p-0 flex flex-col gap-0'), children: [_jsxs(SheetHeader, { className: "px-4 py-3 border-b space-y-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "font-mono text-[10px]", children: headerType }), _jsx(SheetTitle, { className: "font-mono text-base truncate", children: headerName }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: isMetadata && (_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => {
39
25
  navigate(`../../${encodeURIComponent(target.type)}/${encodeURIComponent(target.name)}`);
40
26
  onClose();
41
- }, title: "Open in full page", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5 mr-1" }), "Open full page"] })) })] }), parentContext && (_jsxs(SheetDescription, { className: "text-xs", children: [isEmbedded ? 'Embedded in ' : 'Related to ', _jsxs("span", { className: "font-mono", children: [parentContext.type, "/", parentContext.name] })] }))] }), _jsxs("div", { className: "flex-1 min-h-0 overflow-auto", children: [isMetadata && (_jsx(MetadataResourceEditPage, { type: target.type, name: target.name, embedded: true }, `${target.type}/${target.name}`)), isEmbedded && _jsx(EmbeddedItemView, { raw: target.raw })] })] }) }));
42
- }
43
- /**
44
- * Read-only JSON preview for embedded items (fields, indexes, embedded
45
- * validations). Editing happens via the parent's Form tab; jumping
46
- * straight to that field is a future enhancement — for now the user
47
- * can inspect the spec here and click "Edit in Form tab" in the panel.
48
- */
49
- function EmbeddedItemView({ raw }) {
50
- const json = React.useMemo(() => JSON.stringify(raw, null, 2), [raw]);
51
- return (_jsxs("div", { className: "p-4 space-y-3", children: [_jsxs("div", { className: "text-xs text-muted-foreground", children: ["This item lives inside its parent's body. Edit it in the parent's", ' ', _jsx("span", { className: "font-medium", children: "Form" }), " tab."] }), _jsx("pre", { className: "text-xs font-mono bg-muted/40 border rounded p-3 overflow-auto whitespace-pre-wrap break-all", children: json })] }));
27
+ }, title: "Open in full page", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5 mr-1" }), "Open full page"] })) })] }), parentContext && (_jsxs(SheetDescription, { className: "text-xs", children: [isEmbedded ? 'Embedded in ' : 'Related to ', _jsxs("span", { className: "font-mono", children: [parentContext.type, "/", parentContext.name] })] }))] }), _jsxs("div", { className: "flex-1 min-h-0 overflow-auto", children: [isMetadata && (_jsx(MetadataResourceEditPage, { type: target.type, name: target.name, embedded: true }, `${target.type}/${target.name}`)), isEmbedded && (_jsx(EmbeddedItemEditor, { parentType: target.parentType, parentName: target.parentName, embeddedPath: target.embeddedPath, itemName: target.itemName, editAs: target.editAs, initialRaw: target.raw }, `${target.parentType}/${target.parentName}/${target.embeddedPath}/${target.itemName}`))] })] }) }));
52
28
  }
@@ -29,5 +29,9 @@ export type RelatedTarget = {
29
29
  groupLabel: string;
30
30
  itemName: string;
31
31
  raw: Record<string, unknown>;
32
+ /** Metadata type whose schema drives the editor (e.g. 'field'). */
33
+ editAs?: string;
34
+ /** Dotted path inside parent where this collection lives (e.g. 'fields'). */
35
+ embeddedPath?: string;
32
36
  };
33
37
  export declare function RelatedPanel({ type, name, parentItem, onOpen, }: RelatedPanelProps): import("react/jsx-runtime").JSX.Element;
@@ -142,6 +142,8 @@ export function RelatedPanel({ type, name, parentItem, onOpen, }) {
142
142
  groupLabel: g.anchor.groupLabel ?? g.childType,
143
143
  itemName: it.name,
144
144
  raw: it.raw,
145
+ editAs: g.anchor.editAs,
146
+ embeddedPath: g.anchor.embeddedPath,
145
147
  }
146
148
  : {
147
149
  kind: 'metadata',