diff --git a/.changeset/28239-studio-item-toolbar.md b/.changeset/28239-studio-item-toolbar.md new file mode 100644 index 00000000000..387f9341ae2 --- /dev/null +++ b/.changeset/28239-studio-item-toolbar.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(console): Studio's item header offers Setup's item tools — layers, references, history with rollback, audit and source — on every Data, Interfaces and Automations item + +Clause-②: no +Metadata-protocol: none + +- **Before.** An item's layers (code / overlay / effective), what references it, its version history with rollback, its audit trail and its JSON source opened only from Setup's item page. A Studio author who wanted to undo a change went to Setup to do it. +- **Now.** The open item's header in Studio's Data, Interfaces and Automations pillars carries the five as an icon group, each opening the same panel Setup's sheet holds. History and Audit are offered only to a caller the authoring-trail doors admit, as on Setup. Rollback asks the same in-app confirm, writes through the same rollback door, and the pillar re-reads the item; a read-only package shows its history without rollback. Source edits the pillar's own draft buffer, saved by its autosave, and is read-only where the item is. +- **Moved.** `LayeredDiff`, `ResourceHistoryPage` (`HistoryPanel`), `AuditPanel`, `JsonSourceEditor` and the references panel now live in `views/item-tools/`, which Setup's item page imports. `@object-ui/app-shell`'s `LayeredDiff` and `MetadataResourceHistoryPage` exports are unchanged. Setup's page behaves as before. +- **Not carried over.** Setup's autosave toggle and draft review panel: Studio keeps its own draft → publish and pending-changes review. diff --git a/ui/packages/app-shell/src/index.ts b/ui/packages/app-shell/src/index.ts index f12b870b5b1..fce84c18a99 100644 --- a/ui/packages/app-shell/src/index.ts +++ b/ui/packages/app-shell/src/index.ts @@ -437,11 +437,11 @@ export { MetadataDirectoryPage } from './views/metadata-admin/DirectoryPage.js'; export { MetadataResourceRouter } from './views/metadata-admin/ResourceRouter.js'; export { MetadataResourceListPage } from './views/metadata-admin/ResourceListPage.js'; export { MetadataResourceEditPage } from './views/metadata-admin/ResourceEditPage.js'; -export { MetadataResourceHistoryPage } from './views/metadata-admin/ResourceHistoryPage.js'; +export { MetadataResourceHistoryPage } from './views/item-tools/ResourceHistoryPage.js'; export { MetadataDiagnosticsPage } from './views/metadata-admin/DiagnosticsPage.js'; export { MetadataQuickFind } from './views/metadata-admin/QuickFind.js'; export { SchemaForm } from './views/metadata-admin/SchemaForm.js'; -export { LayeredDiff } from './views/metadata-admin/LayeredDiff.js'; +export { LayeredDiff } from './views/item-tools/LayeredDiff.js'; // objectui#7611 — the Setup catalog's URL scope (`?scope=environment`), for a // host that routes its own legacy URLs onto the catalog (the console's // `system/positions` / `system/permissions`). A dependency-free leaf. diff --git a/ui/packages/app-shell/src/views/metadata-admin/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx b/ui/packages/app-shell/src/views/item-tools/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx similarity index 99% rename from ui/packages/app-shell/src/views/metadata-admin/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx rename to ui/packages/app-shell/src/views/item-tools/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx index a08e4d0347e..ca2623a42d0 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx +++ b/ui/packages/app-shell/src/views/item-tools/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx @@ -52,7 +52,7 @@ import type { MetadataClient, } from '@object-ui/data-objectstack'; import { AuditPanel } from './AuditPanel'; -import { t, translateConsoleValue } from './i18n'; +import { t, translateConsoleValue } from '../metadata-admin/i18n'; afterEach(cleanup); diff --git a/ui/packages/app-shell/src/views/metadata-admin/AuditPanel.loadFailure.test.tsx b/ui/packages/app-shell/src/views/item-tools/AuditPanel.loadFailure.test.tsx similarity index 99% rename from ui/packages/app-shell/src/views/metadata-admin/AuditPanel.loadFailure.test.tsx rename to ui/packages/app-shell/src/views/item-tools/AuditPanel.loadFailure.test.tsx index c8413b5200f..0e13e738d6b 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/AuditPanel.loadFailure.test.tsx +++ b/ui/packages/app-shell/src/views/item-tools/AuditPanel.loadFailure.test.tsx @@ -49,7 +49,7 @@ import { render, screen, cleanup, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { MetadataAuditEntry, MetadataClient } from '@object-ui/data-objectstack'; import { AuditPanel } from './AuditPanel'; -import { t } from './i18n'; +import { t } from '../metadata-admin/i18n'; // The designer rows this suite reads in zh live in `@object-ui/i18n`'s `studio` // namespace, whose `zh` catalogue is fetched on demand (objectstack#25922). // Resident before any case, so the zh rows below read synchronously. diff --git a/ui/packages/app-shell/src/views/metadata-admin/AuditPanel.lockState.test.tsx b/ui/packages/app-shell/src/views/item-tools/AuditPanel.lockState.test.tsx similarity index 99% rename from ui/packages/app-shell/src/views/metadata-admin/AuditPanel.lockState.test.tsx rename to ui/packages/app-shell/src/views/item-tools/AuditPanel.lockState.test.tsx index aa47fd02b13..68cfdbebad0 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/AuditPanel.lockState.test.tsx +++ b/ui/packages/app-shell/src/views/item-tools/AuditPanel.lockState.test.tsx @@ -36,7 +36,7 @@ import type { MetadataClient, } from '@object-ui/data-objectstack'; import { AuditPanel } from './AuditPanel'; -import { translateConsoleValue } from './i18n'; +import { translateConsoleValue } from '../metadata-admin/i18n'; afterEach(cleanup); diff --git a/ui/packages/app-shell/src/views/metadata-admin/AuditPanel.tsx b/ui/packages/app-shell/src/views/item-tools/AuditPanel.tsx similarity index 98% rename from ui/packages/app-shell/src/views/metadata-admin/AuditPanel.tsx rename to ui/packages/app-shell/src/views/item-tools/AuditPanel.tsx index 7a43a5e994d..d76bc74b6d8 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/AuditPanel.tsx +++ b/ui/packages/app-shell/src/views/item-tools/AuditPanel.tsx @@ -29,8 +29,8 @@ import type { MetadataClient, MetadataAuditEntry, } from '@object-ui/data-objectstack'; -import { t, translateConsoleValue, type SupportedLocale } from './i18n.js'; -import { type LoadState, loadErrorMessage } from './loadState.js'; +import { t, translateConsoleValue, type SupportedLocale } from '../metadata-admin/i18n.js'; +import { type LoadState, loadErrorMessage } from '../metadata-admin/loadState.js'; export interface AuditPanelProps { type: string; diff --git a/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.fallback.test.tsx b/ui/packages/app-shell/src/views/item-tools/JsonSourceEditor.fallback.test.tsx similarity index 100% rename from ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.fallback.test.tsx rename to ui/packages/app-shell/src/views/item-tools/JsonSourceEditor.fallback.test.tsx diff --git a/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.monaco-export.test.tsx b/ui/packages/app-shell/src/views/item-tools/JsonSourceEditor.monaco-export.test.tsx similarity index 100% rename from ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.monaco-export.test.tsx rename to ui/packages/app-shell/src/views/item-tools/JsonSourceEditor.monaco-export.test.tsx diff --git a/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.tsx b/ui/packages/app-shell/src/views/item-tools/JsonSourceEditor.tsx similarity index 98% rename from ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.tsx rename to ui/packages/app-shell/src/views/item-tools/JsonSourceEditor.tsx index 7b1e1344366..9912b933148 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.tsx +++ b/ui/packages/app-shell/src/views/item-tools/JsonSourceEditor.tsx @@ -22,8 +22,8 @@ import React from 'react'; import { Skeleton } from '@object-ui/components'; import * as jsonc from 'jsonc-parser'; -import { useMetadataLocale, t } from './i18n.js'; -import { useMonacoFallback } from './useMonacoFallback.js'; +import { useMetadataLocale, t } from '../metadata-admin/i18n.js'; +import { useMonacoFallback } from '../metadata-admin/useMonacoFallback.js'; // Lazy: Monaco's React wrapper itself pulls in the editor core // (~3MB), so we keep it out of the initial app-shell chunk. diff --git a/ui/packages/app-shell/src/views/metadata-admin/LayeredDiff.overlayScope.test.tsx b/ui/packages/app-shell/src/views/item-tools/LayeredDiff.overlayScope.test.tsx similarity index 98% rename from ui/packages/app-shell/src/views/metadata-admin/LayeredDiff.overlayScope.test.tsx rename to ui/packages/app-shell/src/views/item-tools/LayeredDiff.overlayScope.test.tsx index 0161ed1820d..9ce0ab2110b 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/LayeredDiff.overlayScope.test.tsx +++ b/ui/packages/app-shell/src/views/item-tools/LayeredDiff.overlayScope.test.tsx @@ -23,7 +23,7 @@ import { render, screen, cleanup } from '@testing-library/react'; import { GetMetaItemLayeredResponseSchema } from '@objectstack/spec/api'; import type { MetadataLayered } from '@object-ui/data-objectstack'; import { LayeredDiff } from './LayeredDiff'; -import { translateConsoleValue } from './i18n'; +import { translateConsoleValue } from '../metadata-admin/i18n'; afterEach(cleanup); diff --git a/ui/packages/app-shell/src/views/metadata-admin/LayeredDiff.tsx b/ui/packages/app-shell/src/views/item-tools/LayeredDiff.tsx similarity index 99% rename from ui/packages/app-shell/src/views/metadata-admin/LayeredDiff.tsx rename to ui/packages/app-shell/src/views/item-tools/LayeredDiff.tsx index 9ec3b079707..754938a78bf 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/LayeredDiff.tsx +++ b/ui/packages/app-shell/src/views/item-tools/LayeredDiff.tsx @@ -29,7 +29,7 @@ import { import { Badge, Switch } from '@object-ui/components'; import { cn } from '@object-ui/components'; import type { MetadataLayered } from '@object-ui/data-objectstack'; -import { t, tFormat, translateConsoleValue, type SupportedLocale } from './i18n.js'; +import { t, tFormat, translateConsoleValue, type SupportedLocale } from '../metadata-admin/i18n.js'; export interface LayeredDiffProps { layered: MetadataLayered> | null; diff --git a/ui/packages/app-shell/src/views/item-tools/ReferencesPanel.tsx b/ui/packages/app-shell/src/views/item-tools/ReferencesPanel.tsx new file mode 100644 index 00000000000..d1cc9acd09c --- /dev/null +++ b/ui/packages/app-shell/src/views/item-tools/ReferencesPanel.tsx @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ReferencesPanel — what in the metadata graph points at one item, read by + * `client.references(type, name)`. One of the item tools Setup's item page + * and Studio's item header both open (objectstack#28239). + */ + +import * as React from 'react'; +import { AlertTriangle, Loader2 } from 'lucide-react'; +import { Badge, Button, Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; +import type { MetadataReference } from '@object-ui/data-objectstack'; +import { t } from '../metadata-admin/i18n.js'; +import type { useMetadataClient } from '../metadata-admin/useMetadata.js'; + +/** + * References panel data — one discriminated union, not a `refs` / `refsLoading` + * pair (objectui#5110). + * + * The pair could only spell two of the three facts this panel has to tell + * apart. `refs == null` was already overloaded as "still loading", so a failed + * `client.references()` call had nowhere to go: the catch wrote `setRefs([])`, + * which is byte-identical to a *successful* scan that found nothing — and the + * panel renders that state as "Nothing in the metadata graph points at this + * item. Safe to delete." A refused request, a dropped connection, an expired + * session and a `501 NOT_IMPLEMENTED` were therefore all shown to the operator + * as an affirmative, measured all-clear, with a `console.error` nobody reads as + * the only trace. The operator is on this panel precisely because they are + * about to delete something. + * + * `status` makes the third fact representable, and makes the wrong combination + * unrepresentable: no value of this type is both `error` and `loaded`, so no + * render path can read a failure as a measurement. The `error` arm carries the + * cause and asserts NOTHING about deletion safety — the honest answer when the + * question was never answered — and the panel offers a retry so the operator + * can ask it again. + * + * `idle` is deliberately distinct from `loading`: the sheet is lazy, and + * "never asked" is what re-enables a retry after a failure (see + * `useReferencesScan`'s re-entry guard). + * + * ADR-0110 D3 — a miss and a fault are different facts. + */ +export type ReferencesState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'loaded'; items: MetadataReference[] } + | { status: 'error'; message: string }; + +/** + * The references scan for one item, run lazily: nothing is asked until `load` + * is called (the first time the References sheet opens). + * + * Re-entry guard: a request already in flight is not duplicated, and a + * completed scan is not re-run when the sheet is reopened. `idle` and + * `error` both fall through — which is what makes the panel's Retry work + * without a second code path: it calls this same loader (objectui#5110). + */ +export function useReferencesScan( + client: ReturnType, + type: string, + name: string, +): { state: ReferencesState; load: () => Promise } { + const [state, setState] = React.useState({ status: 'idle' }); + async function load() { + if (state.status === 'loading' || state.status === 'loaded') return; + setState({ status: 'loading' }); + try { + const r = await client.references(type, name); + setState({ status: 'loaded', items: r }); + } catch (err: any) { + // NOT `{ status: 'loaded', items: [] }`: an unanswered question is not + // an answer of "nothing". The panel renders this arm as a failed check + // with a retry and says nothing about whether deleting is safe. + setState({ status: 'error', message: String(err?.message ?? err) }); + } + } + return { state, load }; +} + +/** + * Renders exactly one arm of `ReferencesState` — the union is the whole point + * (objectui#5110), so the error arm is checked FIRST and there is no path from + * a failure to the empty state's "Safe to delete." + */ +export function ReferencesPanel({ + state, + locale, + onRetry, +}: { + state: ReferencesState; + locale?: string; + onRetry: () => void; +}) { + if (state.status === 'error') { + return ( + + + + {t('engine.edit.refsErrorTitle', locale)} + + + {t('engine.edit.refsErrorDesc', locale)} + + {state.message ? ( +

+ {state.message} +

+ ) : null} + +
+ ); + } + if (state.status !== 'loaded') { + return ( +
+ {t('engine.edit.refsScanning', locale)} +
+ ); + } + const refs = state.items; + if (refs.length === 0) { + return ( + + {t('engine.edit.refsEmptyTitle', locale)} + + {t('engine.edit.refsEmptyDesc', locale)} + + + ); + } + return ( +
+ + + + + + + + + + {refs.map((r, i) => ( + + + + + + ))} + +
{t('engine.edit.refsColFromType', locale)}{t('engine.edit.refsColFromName', locale)}{t('engine.edit.refsColPath', locale)}
+ + {r.fromType} + + {r.fromName} + {r.path} +
+
+ ); +} diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.tsx b/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx similarity index 99% rename from ui/packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.tsx rename to ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx index d10bff245f4..ed329083d90 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.tsx +++ b/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx @@ -32,13 +32,13 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@object-ui/components'; -import { t, tFormat, translateConsoleValue, useMetadataLocale } from './i18n.js'; -import { PageShell } from './PageShell.js'; +import { t, tFormat, translateConsoleValue, useMetadataLocale } from '../metadata-admin/i18n.js'; +import { PageShell } from '../metadata-admin/PageShell.js'; import { useMetadataClient, useMetadataTypes, type RichMetadataTypeEntry, -} from './useMetadata.js'; +} from '../metadata-admin/useMetadata.js'; export interface MetadataResourceHistoryPageProps { type?: string; diff --git a/ui/packages/app-shell/src/views/item-tools/StudioItemTools.tsx b/ui/packages/app-shell/src/views/item-tools/StudioItemTools.tsx new file mode 100644 index 00000000000..001639c3984 --- /dev/null +++ b/ui/packages/app-shell/src/views/item-tools/StudioItemTools.tsx @@ -0,0 +1,279 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * StudioItemTools — the item toolbar of a Studio pillar's item header + * (objectstack#28239, epic objectstack#28236 rulings D-5 / D-7): the five + * tools Setup's item page opens — Layers, References, History with rollback, + * Audit and Source — as icon buttons, each opening the same panel Setup's + * sheet holds. + * + * What it keeps from Setup: + * - History and Audit are offered only to a caller the authoring-trail doors + * admit (`useCanReadAuthoringTrail`), and their sheets are not mounted for + * anyone else. + * - Rollback is offered on the gate Setup's Save asks: an authoring caller + * on an item that is not read-only. A read-only package shows its history + * without it. + * + * What it leaves to Studio: Setup's autosave toggle and draft review panel. + * Studio keeps its own draft → publish and pending-changes review. + * + * Source edits the pillar's own buffer when the pillar hands one over + * (`source.onChange`) and the item is not read-only, so an edit there is + * saved by the pillar's autosave like any other. Without a buffer it shows + * the item's effective body, read-only. + */ + +import * as React from 'react'; +import { FileCode2, History, Layers3, Link2, Loader2, ShieldCheck } from 'lucide-react'; +import { + Button, + cn, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@object-ui/components'; +import type { MetadataLayered } from '@object-ui/data-objectstack'; +import { t, tFormat } from '../metadata-admin/i18n.js'; +import { useMetadataClient } from '../metadata-admin/useMetadata.js'; +import { useCanAuthorMetadata, useCanReadAuthoringTrail } from '../../hooks/useCanAuthorMetadata.js'; +import { LayeredDiff } from './LayeredDiff.js'; +import { ReferencesPanel, useReferencesScan } from './ReferencesPanel.js'; +import { HistoryPanel } from './ResourceHistoryPage.js'; +import { AuditPanel } from './AuditPanel.js'; +import { JsonSourceEditor } from './JsonSourceEditor.js'; + +/** The tool a sheet holds. */ +export type StudioItemTool = 'layers' | 'references' | 'history' | 'audit' | 'source'; + +export interface StudioItemToolsProps { + /** The item's metadata type (`object`, `flow`, `page`, …). */ + type: string; + /** The item's machine name. */ + name: string; + /** The package the item belongs to: scopes the layers read (ADR-0048). */ + packageId?: string | null; + /** The pillar's read-only gate: no rollback, and Source is read-only. */ + readOnly?: boolean; + /** + * The pillar's edit buffer for this item. Source shows `value`; with + * `onChange` and an item that is not read-only, an edit replaces it. + * Omitted, Source shows the item's effective body, read-only. + */ + source?: { + value: Record; + onChange?: (next: Record) => void; + }; + /** After a rollback landed: the pillar re-reads the item. */ + onRolledBack?: () => void; + locale?: string; +} + +/** The layers read, asked the first time a sheet needs it. */ +type LayersState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'loaded'; layered: MetadataLayered> } + | { status: 'error'; message: string }; + +const TOOL_BUTTON = 'relative h-7 w-7 p-0'; + +/** + * One toolbar per item: a new item starts with nothing read, so a sheet never + * shows the previous item's references or layers. + */ +export function StudioItemTools(props: StudioItemToolsProps): React.ReactElement { + return ; +} + +function StudioItemToolsFor({ + type, + name, + packageId, + readOnly = false, + source, + onRolledBack, + locale, +}: StudioItemToolsProps): React.ReactElement { + const client = useMetadataClient(); + const canReadTrail = useCanReadAuthoringTrail(); + const canAuthor = useCanAuthorMetadata(); + const [open, setOpen] = React.useState(null); + const refs = useReferencesScan(client, type, name); + const [layers, setLayers] = React.useState({ status: 'idle' }); + + // Re-entry guard as the references scan's: a read in flight or done is not + // asked again; `idle` and `error` fall through, so Retry is this same call. + async function loadLayers() { + if (layers.status === 'loading' || layers.status === 'loaded') return; + setLayers({ status: 'loading' }); + try { + const layered = await client.layered>(type, name, packageId ? { packageId } : {}); + setLayers({ status: 'loaded', layered }); + } catch (err) { + setLayers({ status: 'error', message: err instanceof Error ? err.message : String(err) }); + } + } + + React.useEffect(() => { + if (open === 'references') void refs.load(); + if (open === 'layers' || (open === 'source' && !source)) void loadLayers(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const canRollback = !readOnly && canAuthor; + const sourceEditable = !readOnly && !!source?.onChange; + const close = (o: boolean) => { + if (!o) setOpen(null); + }; + const toolButton = (tool: StudioItemTool, label: string, Icon: React.ElementType, badge?: React.ReactNode) => ( + + ); + const sheet = (tool: StudioItemTool, title: string, body: React.ReactNode, description?: string, wide = false) => ( + + + + {title} + + {type} / {name} + {description ? ` · ${description}` : null} + + + {body} + + + ); + const layersBody = + layers.status === 'error' ? ( +
+

+ {tFormat('engine.studio.itemTools.layersFailed', locale, { message: layers.message })} +

+ +
+ ) : ( + + ); + + return ( + <> +
+ {toolButton('layers', t('engine.edit.layers', locale), Layers3)} + {toolButton( + 'references', + t('engine.edit.references', locale), + Link2, + refs.state.status === 'loaded' && refs.state.items.length > 0 ? ( + + {refs.state.items.length} + + ) : undefined, + )} + {canReadTrail && toolButton('history', t('engine.edit.history', locale), History)} + {canReadTrail && toolButton('audit', t('engine.edit.auditTab', locale), ShieldCheck)} + {toolButton('source', t('engine.edit.inspector.source', locale), FileCode2)} +
+ + {sheet('layers', t('engine.edit.layers', locale),
{layersBody}
)} + + {sheet( + 'references', + t('engine.edit.references', locale), +
+ void refs.load()} /> +
, + )} + + {canReadTrail && + sheet( + 'history', + t('engine.edit.history', locale), +
+ { + setLayers({ status: 'idle' }); + onRolledBack?.(); + } + : undefined + } + rollbackLabel={t('engine.edit.rollback', locale)} + rollbackConfirm={(version) => + t('engine.edit.rollbackConfirm', locale).replace('{version}', String(version)) + } + /> +
, + )} + + {canReadTrail && + sheet( + 'audit', + t('engine.edit.auditTab', locale), +
+ +
, + undefined, + true, + )} + + {sheet( + 'source', + t('engine.edit.inspector.source', locale), +
+ {source || layers.status === 'loaded' ? ( + { + if (sourceEditable) source?.onChange?.(next); + }} + readOnly={!sourceEditable} + /> + ) : layers.status === 'error' ? ( + layersBody + ) : ( +
+ {t('engine.edit.loading', locale)} +
+ )} +
, + sourceEditable ? undefined : t('engine.studio.itemTools.sourceReadOnly', locale), + )} + + ); +} diff --git a/ui/packages/app-shell/src/views/metadata-admin/DraftReviewPanel.tsx b/ui/packages/app-shell/src/views/metadata-admin/DraftReviewPanel.tsx index 0e84373fd84..a2aea6249b0 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/DraftReviewPanel.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/DraftReviewPanel.tsx @@ -19,7 +19,7 @@ * draft drops, "modified" = value changed. */ import React from 'react'; -import { computeDiffRows, type DiffStatus } from './LayeredDiff.js'; +import { computeDiffRows, type DiffStatus } from '../item-tools/LayeredDiff.js'; import { t, type SupportedLocale } from './i18n.js'; const STATUS_BADGE: Record, string> = { diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index 7453b3b2917..90f842a5e47 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -89,7 +89,7 @@ import { } from '@object-ui/components'; import { CapabilityMultiSelectField, parseCapabilityNames } from '@object-ui/fields'; import { PageShell } from './PageShell.js'; -import { HistoryPanel } from './ResourceHistoryPage.js'; +import { HistoryPanel } from '../item-tools/ResourceHistoryPage.js'; import { useMetadataClient, useMetadataTypes, type RichMetadataTypeEntry } from './useMetadata.js'; // objectui#11773 — the package door's draft save sends the version it was built on. import { readDraftForSave, useDraftSaveGuard } from './DraftConflictDialog.js'; diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 1a08a062628..43622480938 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -84,11 +84,9 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@object-ui/components'; -import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; import type { MetadataLayered, MetadataLockState, - MetadataReference, } from '@object-ui/data-objectstack'; // The ONE draft-envelope reader (objectui#8181). Its docblock carries the // read-decoration contract objectui#7603 established here; it was hoisted so @@ -96,7 +94,7 @@ import type { import { extractDraftBody } from '@object-ui/data-objectstack'; import { PageShell } from './PageShell.js'; import { MetadataTypeActions } from './MetadataTypeActions.js'; -import { LayeredDiff, countOverlaidFields } from './LayeredDiff.js'; +import { LayeredDiff, countOverlaidFields } from '../item-tools/LayeredDiff.js'; import { DraftReviewPanel, computeDraftChangeCount } from './DraftReviewPanel.js'; import { SchemaForm, @@ -133,8 +131,9 @@ import { import { useCreateDerive, deriveDefaultCreateFields } from './createDerive.js'; import { RelatedPanel, type RelatedTarget } from './RelatedPanel.js'; import { MetadataDetailDrawer } from './MetadataDetailDrawer.js'; -import { HistoryPanel } from './ResourceHistoryPage.js'; -import { AuditPanel } from './AuditPanel.js'; +import { HistoryPanel } from '../item-tools/ResourceHistoryPage.js'; +import { AuditPanel } from '../item-tools/AuditPanel.js'; +import { ReferencesPanel, useReferencesScan } from '../item-tools/ReferencesPanel.js'; import { getMetadataPreview, useRegisteredMetadataPreview, @@ -145,7 +144,7 @@ import { useRegisterAssistantEditor, type AssistantEditorContext } from '../../a import { useRegisteredMetadataInspector } from './inspector-registry.js'; import { useRegisteredMetadataDefaultInspector } from './default-inspector-registry.js'; import { useMetadataLocale, t, tFormat, translateMetadataType, translateValidationMessage } from './i18n.js'; -import { JsonSourceEditor } from './JsonSourceEditor.js'; +import { JsonSourceEditor } from '../item-tools/JsonSourceEditor.js'; import { validateMetadataDraft, hasClientValidator, type DraftMode } from './clientValidation.js'; import { DraftVerdictContext, @@ -417,40 +416,6 @@ export function MetadataResourceEditPage({ ); } -/** - * References panel data — one discriminated union, not a `refs` / `refsLoading` - * pair (objectui#5110). - * - * The pair could only spell two of the three facts this panel has to tell - * apart. `refs == null` was already overloaded as "still loading", so a failed - * `client.references()` call had nowhere to go: the catch wrote `setRefs([])`, - * which is byte-identical to a *successful* scan that found nothing — and the - * panel renders that state as "Nothing in the metadata graph points at this - * item. Safe to delete." A refused request, a dropped connection, an expired - * session and a `501 NOT_IMPLEMENTED` were therefore all shown to the operator - * as an affirmative, measured all-clear, with a `console.error` nobody reads as - * the only trace. The operator is on this panel precisely because they are - * about to delete something. - * - * `status` makes the third fact representable, and makes the wrong combination - * unrepresentable: no value of this type is both `error` and `loaded`, so no - * render path can read a failure as a measurement. The `error` arm carries the - * cause and asserts NOTHING about deletion safety — the honest answer when the - * question was never answered — and the panel offers a retry so the operator - * can ask it again. - * - * `idle` is deliberately distinct from `loading`: the sheet is lazy, and - * "never asked" is what re-enables a retry after a failure (see - * `loadReferences`'s re-entry guard). - * - * ADR-0110 D3 — a miss and a fault are different facts. - */ -type ReferencesState = - | { status: 'idle' } - | { status: 'loading' } - | { status: 'loaded'; items: MetadataReference[] } - | { status: 'error'; message: string }; - /** The two catalogs the single `client.get('object', …)` call yields. */ type ObjectCatalog = { fields: ObjectFieldOption[]; @@ -612,7 +577,7 @@ function MetadataResourceEditPageImpl({ const [draft, setDraft] = React.useState>(() => createMode ? { ...(config.createDefaults ?? {}), [identityField]: '' } : {}, ); - const [refsState, setRefsState] = React.useState({ status: 'idle' }); + const { state: refsState, load: loadReferences } = useReferencesScan(client, type, name); const [loading, setLoading] = React.useState(!createMode); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); @@ -1410,26 +1375,6 @@ function MetadataResourceEditPageImpl({ }; }, [client, type, name, ownerPackageId, bundleMember, createMode, reloadKey, locale, recordDraftRead]); - // Lazy-load references the first time the References sheet opens. - // - // Re-entry guard: a request already in flight is not duplicated, and a - // completed scan is not re-run when the sheet is reopened. `idle` and - // `error` both fall through — which is what makes the panel's Retry work - // without a second code path: it calls this same loader (objectui#5110). - async function loadReferences() { - if (refsState.status === 'loading' || refsState.status === 'loaded') return; - setRefsState({ status: 'loading' }); - try { - const r = await client.references(type, name); - setRefsState({ status: 'loaded', items: r }); - } catch (err: any) { - // NOT `{ status: 'loaded', items: [] }`: an unanswered question is not - // an answer of "nothing". The panel renders this arm as a failed check - // with a retry and says nothing about whether deleting is safe. - setRefsState({ status: 'error', message: String(err?.message ?? err) }); - } - } - // Related drawer state. `null` = closed. We avoid querystring round- // trips on every keystroke; URL state is best-effort sync via effect // below. @@ -3800,101 +3745,6 @@ function MetadataResourceEditPageImpl({ return {page}; } -/** - * Renders exactly one arm of `ReferencesState` — the union is the whole point - * (objectui#5110), so the error arm is checked FIRST and there is no path from - * a failure to the empty state's "Safe to delete." - */ -function ReferencesPanel({ - state, - locale, - onRetry, -}: { - state: ReferencesState; - locale?: string; - onRetry: () => void; -}) { - if (state.status === 'error') { - return ( - - - - {t('engine.edit.refsErrorTitle', locale)} - - - {t('engine.edit.refsErrorDesc', locale)} - - {state.message ? ( -

- {state.message} -

- ) : null} - -
- ); - } - if (state.status !== 'loaded') { - return ( -
- {t('engine.edit.refsScanning', locale)} -
- ); - } - const refs = state.items; - if (refs.length === 0) { - return ( - - {t('engine.edit.refsEmptyTitle', locale)} - - {t('engine.edit.refsEmptyDesc', locale)} - - - ); - } - return ( -
- - - - - - - - - - {refs.map((r, i) => ( - - - - - - ))} - -
{t('engine.edit.refsColFromType', locale)}{t('engine.edit.refsColFromName', locale)}{t('engine.edit.refsColPath', locale)}
- - {r.fromType} - - {r.fromName} - {r.path} -
-
- ); -} - /** * SaveStatusIndicator — small inline label next to the Save icon that * communicates auto-save state so the icon-only button is not a black diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.rollbackConfirm-13333.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.rollbackConfirm-13333.test.tsx index 909b29ed4be..3c4ab79307b 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.rollbackConfirm-13333.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceHistoryPage.rollbackConfirm-13333.test.tsx @@ -18,7 +18,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react'; // @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) import { maskComments } from '../../../../../scripts/js-comment-mask.mjs'; -import { HistoryPanel } from './ResourceHistoryPage'; +import { HistoryPanel } from '../item-tools/ResourceHistoryPage'; const mask: (source: string) => string = maskComments; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -93,7 +93,7 @@ describe('Setup metadata surfaces call no native confirm() (objectui#13333)', () const FILES = [ 'ResourceEditPage.tsx', 'MetadataTypeActions.tsx', - 'ResourceHistoryPage.tsx', + '../item-tools/ResourceHistoryPage.tsx', 'datasource/DatasourceResourcePage.tsx', ]; // A call of the global: bare `confirm(`, or `window.` / `globalThis.` before diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceRouter.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceRouter.tsx index 977b9454c76..092abd0f519 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceRouter.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceRouter.tsx @@ -27,7 +27,7 @@ import { useLocation, useSearchParams } from 'react-router-dom'; import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; import { MetadataResourceListPage } from './ResourceListPage.js'; import { MetadataResourceEditPage } from './ResourceEditPage.js'; -import { MetadataResourceHistoryPage } from './ResourceHistoryPage.js'; +import { MetadataResourceHistoryPage } from '../item-tools/ResourceHistoryPage.js'; /** * Props forwarded by `ComponentNavView` — it merges URL query-string diff --git a/ui/packages/app-shell/src/views/metadata-admin/index.ts b/ui/packages/app-shell/src/views/metadata-admin/index.ts index e997b7346bd..5c0457600c0 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/index.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/index.ts @@ -21,7 +21,7 @@ export { MetadataResourceListPage } from './ResourceListPage.js'; export { MetadataResourceEditPage } from './ResourceEditPage.js'; export { RelatedPanel } from './RelatedPanel.js'; export { MetadataDetailDrawer } from './MetadataDetailDrawer.js'; -export { MetadataResourceHistoryPage } from './ResourceHistoryPage.js'; +export { MetadataResourceHistoryPage } from '../item-tools/ResourceHistoryPage.js'; export { MetadataDiagnosticsPage } from './DiagnosticsPage.js'; export { MetadataQuickFind } from './QuickFind.js'; export { PageShell as MetadataPageShell } from './PageShell.js'; @@ -41,7 +41,7 @@ export { SchemaForm } from './SchemaForm.js'; * directory. Type-only: erased at build, so nothing is added to the bundle. */ export type { FormFieldSpec, FormSectionSpec, FormViewSpec } from './form-spec.js'; -export { LayeredDiff } from './LayeredDiff.js'; +export { LayeredDiff } from '../item-tools/LayeredDiff.js'; export { PermissionMatrixEditPage } from './PermissionMatrixEditor.js'; export { translateMetadataType, diff --git a/ui/packages/app-shell/src/views/metadata-admin/metadataAdminFaces.displayLocale-9909.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/metadataAdminFaces.displayLocale-9909.test.tsx index a3882cec6fc..d02e6b47613 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/metadataAdminFaces.displayLocale-9909.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/metadataAdminFaces.displayLocale-9909.test.tsx @@ -42,8 +42,8 @@ vi.mock('./external/api', async (importOriginal) => ({ listRemoteTables: async () => [{ name: 'orders', schema: 'public', columnCount: 3, rowCountEstimate: 1234567 }], })); -import { AuditPanel } from './AuditPanel'; -import { HistoryPanel } from './ResourceHistoryPage'; +import { AuditPanel } from '../item-tools/AuditPanel'; +import { HistoryPanel } from '../item-tools/ResourceHistoryPage'; import { ExternalDatasourcePanel } from './external/ExternalDatasourcePanel'; import { SchemaBrowser } from './external/SchemaBrowser'; import { FlowRunsPanel } from './previews/FlowRunsPanel'; diff --git a/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.fastfail.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.fastfail.test.tsx index b81f805e356..ddc9d5de268 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.fastfail.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.fastfail.test.tsx @@ -25,7 +25,7 @@ vi.mock('@monaco-editor/react', () => { // `configureBundledMonaco` before `loader.init()` (its own suite drives it). vi.mock('@object-ui/plugin-editor', () => ({ configureBundledMonaco: () => Promise.resolve() })); -import { JsonSourceEditor } from './JsonSourceEditor'; +import { JsonSourceEditor } from '../item-tools/JsonSourceEditor'; describe('useMonacoFallback — fast-fail on loader rejection', () => { it('shows the textarea immediately when loader.init() rejects, without waiting out fallbackDelayMs', async () => { diff --git a/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.loaderRejection-11800.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.loaderRejection-11800.test.tsx index 7d7bb71ddc5..be2758fd414 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.loaderRejection-11800.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.loaderRejection-11800.test.tsx @@ -36,7 +36,7 @@ import * as React from 'react'; import { render, screen, act } from '@testing-library/react'; import { SourcePageEditor } from './previews/SourcePageEditor'; -import { JsonSourceEditor } from './JsonSourceEditor'; +import { JsonSourceEditor } from '../item-tools/JsonSourceEditor'; const LOADER_SCRIPT = /\/vs\/loader\.js$/; const CHUNK_URL = 'http://localhost/_console/assets/editor.api2-hash.js'; diff --git a/ui/packages/app-shell/src/views/studio-design/DataPillar.headerActions-12511.test.tsx b/ui/packages/app-shell/src/views/studio-design/DataPillar.headerActions-12511.test.tsx index e70301c1246..3e91f7903d4 100644 --- a/ui/packages/app-shell/src/views/studio-design/DataPillar.headerActions-12511.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/DataPillar.headerActions-12511.test.tsx @@ -11,6 +11,10 @@ * - the More menu went with it: its one entry was Add field, and the header * has no object-level action to put in one. * + * objectstack#28239 (maintainer rulings D-5 / D-7) — the one group the header + * does carry is the item toolbar: the item tools Setup's item page opens, + * the same five on every tab. + * * The REAL `DataPillar`; the records grid and the Settings panel are stubbed, * because only the header is under test. */ @@ -63,6 +67,14 @@ import { t } from '../metadata-admin/i18n'; const RECORDS = t('engine.studio.data.tab.records', 'en'); const FORM = t('engine.studio.data.tab.form', 'en'); const TOGGLE_RAIL = t('engine.studio.toggleRail', 'en'); +/** objectstack#28239 — the item toolbar, the same on every tab. */ +const ITEM_TOOLS = [ + ['layers', t('engine.edit.layers', 'en')], + ['references', t('engine.edit.references', 'en')], + ['history', t('engine.edit.history', 'en')], + ['audit', t('engine.edit.auditTab', 'en')], + ['source', t('engine.edit.inspector.source', 'en')], +].map(([tool, label]) => `button|studio-item-tool-${tool}|${label}|`); afterEach(() => { cleanup(); @@ -124,9 +136,9 @@ describe('the Data pillar’s object header actions (objectui#12511)', () => { expect(seen.form).toEqual(seen.records); expect(seen.settings).toEqual(seen.records); expect(seen.recordsAgain).toEqual(seen.records); - // And that set is the mobile rail toggle alone: the header draws no - // action of its own on any tab. - expect(seen.records).toEqual([`button||${TOGGLE_RAIL}|`]); + // And that set is the mobile rail toggle and the item toolbar: the header + // draws no other action of its own on any tab. + expect(seen.records).toEqual([`button||${TOGGLE_RAIL}|`, ...ITEM_TOOLS]); }); it('Object settings, More and the header Add field are gone; the Settings tab opens the panel', async () => { @@ -147,7 +159,7 @@ describe('the Data pillar’s object header actions (objectui#12511)', () => { expect(seen.settings).toEqual(seen.records); // An object takes no environment overlay, so the read-only mark is plain // text, not a control (#27356 row 13). - expect(seen.records).toEqual([`button||${TOGGLE_RAIL}|`]); + expect(seen.records).toEqual([`button||${TOGGLE_RAIL}|`, ...ITEM_TOOLS]); expect(screen.getByTestId('studio-readonly-link')).toHaveTextContent(t('engine.studio.pkg.readonly', 'en-US')); }); }); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.itemTools-28239.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.itemTools-28239.test.tsx new file mode 100644 index 00000000000..bca2f4a6517 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.itemTools-28239.test.tsx @@ -0,0 +1,307 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28239 — Studio's item toolbar: the five item tools Setup's item + * page opens (Layers, References, History with rollback, Audit, Source) on + * every item of the Data, Interfaces and Automations pillars. + * + * - each pillar's open item opens all five, each reading its own door for + * that item; + * - a rollback from Studio asks the same in-app confirm Setup asks, writes + * through the same `client.rollback`, and the pillar re-reads the item; + * - a read-only package shows its history without rollback, and its source + * read-only; + * - History and Audit follow the authoring-trail doors, as on Setup; + * - CONTROL: Setup's item page still opens the five from the shared + * directory. + * + * The REAL pillars and the REAL Setup page; the metadata client is a server + * double, and the records grid and the object settings panel are stubbed. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; + +const PKG = 'com.acme.app'; + +const OBJECT = { name: 'showcase_account', label: 'Account', fields: { name: { type: 'text', label: 'Account Name' } } }; +const FLOW = { + name: 'approval', + label: 'Approval', + type: 'autolaunched', + status: 'active', + nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}; +const APP = { name: 'acme_app', label: 'Acme', navigation: [{ id: 'nav_home', type: 'page', label: 'Home', pageName: 'home' }] }; +const HOME = { + name: 'home', + label: 'Home', + type: 'app', + regions: [{ name: 'main', components: [{ id: 'hello', type: 'element:text', properties: { content: 'Hello' } }] }], +}; + +const DOCS: Record> = { + 'object/showcase_account': OBJECT, + 'flow/approval': FLOW, + 'app/acme_app': APP, + 'page/home': HOME, +}; + +const mockClient = vi.hoisted(() => ({ + withPreviewDrafts() { + return this; + }, + list: vi.fn(async (type: string) => + Object.entries(DOCS) + .filter(([key]) => key.startsWith(`${type}/`)) + .map(([, doc]) => ({ name: doc.name, label: doc.label })), + ), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + getDraft: vi.fn(async (type: string, name: string) => { + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + }), + layered: vi.fn(async (type: string, name: string) => { + const doc = DOCS[`${type}/${name}`] ?? null; + return { code: null, overlay: doc, overlayScope: doc ? 'env' : null, effective: doc, lock: 'none' }; + }), + references: vi.fn(async () => [{ fromType: 'view', fromName: 'all_accounts', path: 'object' }]), + history: vi.fn(async () => ({ + events: [ + { seq: 2, op: 'update', version: 2, actor: 'ada' }, + { seq: 1, op: 'create', version: 1, actor: 'ada' }, + ], + })), + audit: vi.fn(async () => ({ events: [] })), + rollback: vi.fn(async () => ({ success: true })), + save: vi.fn(async (type: string, name: string, item: unknown) => ({ type, name, item })), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), +})); + +const SCHEMA = { type: 'object', properties: { name: { type: 'string' }, label: { type: 'string' } } }; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => ({ + ...(await importOriginal()), + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [ + { type: 'page', name: 'page', label: 'Page', allowEnvironmentOverlay: true, allowRuntimeCreate: true, schema: SCHEMA }, + ], + }), +})); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +vi.mock('@object-ui/plugin-view', async (importOriginal) => { + const mod = await importOriginal>(); + return { ...mod, ObjectView: () =>
}; +}); + +vi.mock('./ObjectSettingsPanel', () => ({ ObjectSettingsPanel: () =>
})); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +// Monaco's loader fails (offline), so the JSON source editor is its textarea. +vi.mock('@monaco-editor/react', () => { + const Editor = () => null; + return { Editor, default: Editor, loader: { init: () => Promise.reject(new Error('offline')) } }; +}); +vi.mock('@object-ui/plugin-editor', () => ({ configureBundledMonaco: () => Promise.resolve() })); + +import { AutomationsPillar, DataPillar, InterfacesPillar } from './StudioDesignSurface'; +import { MetadataResourceEditPage } from '../metadata-admin/ResourceEditPage'; +import '../../services/builtinComponents.js'; +import { t } from '../metadata-admin/i18n'; +import { createEmptyDataSource } from './__tests__/emptyDataSource'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; +import { registerMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry'; +import { PagePreview } from '../metadata-admin/previews/PagePreview'; +import { FlowPreview } from '../metadata-admin/previews/FlowPreview'; +import { PageBlockInspector } from '../metadata-admin/inspectors/PageBlockInspector'; +import { PageDefaultInspector } from '../metadata-admin/inspectors/PageDefaultInspector'; + +const dataSource = createEmptyDataSource(); + +registerMetadataPreview('page', PagePreview); +registerMetadataPreview('flow', FlowPreview); +registerMetadataInspector('page', PageBlockInspector); +registerMetadataDefaultInspector('page', PageDefaultInspector); + +// The Automations pillar's `/automation/_status` probe: "absent", its documented fallback. +vi.stubGlobal('fetch', vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } }))); + +function mePermissions(systemPermissions: string[]): MePermissionsResponse { + return { authenticated: true, userId: 'u1', tenantId: 'org_1', roles: [], permissionSets: [], systemPermissions, objects: {}, fields: {} }; +} +const SETUP_ONLY = mePermissions(['manage_org_users', 'setup.access', 'setup.write']); + +const ROLLBACK = t('engine.edit.rollback', 'en'); + +beforeEach(() => { + for (const fn of Object.values(mockClient)) { + if (typeof fn === 'function' && 'mockClear' in fn) (fn as unknown as { mockClear: () => void }).mockClear(); + } +}); + +afterEach(cleanup); + +type Pillar = 'data' | 'interfaces' | 'automations'; +const PILLARS: Array<{ pillar: Pillar; type: string; name: string; ready: () => Promise }> = [ + { pillar: 'data', type: 'object', name: 'showcase_account', ready: () => screen.findByTestId('records-panel', undefined, { timeout: 8000 }) }, + { pillar: 'interfaces', type: 'page', name: 'home', ready: () => screen.findByRole('button', { name: 'Select hello' }, { timeout: 8000 }) }, + { pillar: 'automations', type: 'flow', name: 'approval', ready: () => screen.findByTestId('auto-canvas-caption', undefined, { timeout: 8000 }) }, +]; + +function mountPillar(pillar: Pillar, opts: { readOnly?: boolean; perms?: MePermissionsResponse } = {}) { + const body = + pillar === 'data' ? ( + + ) : pillar === 'interfaces' ? ( + + ) : ( + + ); + const routed = {body}; + render(opts.perms ? {routed} : routed); +} + +/** Click a tool and return the sheet it opened. */ +async function openTool(tool: string): Promise { + fireEvent.click(await screen.findByTestId(`studio-item-tool-${tool}`, undefined, { timeout: 8000 })); + return screen.findByTestId(`studio-item-tool-sheet-${tool}`); +} + +describe('Studio item toolbar — every pillar opens the five tools (objectstack#28239)', () => { + it.each(PILLARS)('$pillar: Layers, References, History, Audit and Source open on $type/$name', async ({ pillar, type, name, ready }) => { + mountPillar(pillar); + await ready(); + const tools = within(await screen.findByTestId('studio-item-tools', undefined, { timeout: 8000 })); + expect(tools.getAllByRole('button').map((b) => b.getAttribute('data-testid'))).toEqual([ + 'studio-item-tool-layers', + 'studio-item-tool-references', + 'studio-item-tool-history', + 'studio-item-tool-audit', + 'studio-item-tool-source', + ]); + + const layers = await openTool('layers'); + expect(await within(layers).findByRole('tab', { name: t('engine.layers.diff', 'en') })).toBeInTheDocument(); + expect(mockClient.layered).toHaveBeenCalledWith(type, name, { packageId: PKG }); + + const refs = await openTool('references'); + expect(await within(refs).findByText('all_accounts')).toBeInTheDocument(); + expect(mockClient.references).toHaveBeenCalledWith(type, name); + + const history = await openTool('history'); + expect(await within(history).findAllByTitle(ROLLBACK)).toHaveLength(2); + expect(mockClient.history).toHaveBeenCalledWith(type, name, { limit: 100 }); + + await openTool('audit'); + await waitFor(() => expect(mockClient.audit).toHaveBeenCalledWith(type, name, { limit: 100 })); + + const source = await openTool('source'); + const editor = await within(source).findByRole('textbox', { name: 'JSON source' }, { timeout: 8000 }); + expect((editor as HTMLTextAreaElement).value).toContain(`"name": "${name}"`); + expect(editor).not.toHaveAttribute('readonly'); + }); +}); + +describe('Studio item toolbar — rollback (objectstack#28239)', () => { + it('asks the in-app confirm, writes through the same rollback door as Setup, and the pillar re-reads the item', async () => { + mountPillar('data'); + await PILLARS[0]!.ready(); + const history = await openTool('history'); + const buttons = await within(history).findAllByTitle(ROLLBACK); + const reads = mockClient.layered.mock.calls.filter(([type]) => type === 'object').length; + // Newest first: the second button is version 1. + fireEvent.click(buttons[1]!); + const dialog = await screen.findByRole('alertdialog'); + expect(within(dialog).getByRole('heading').textContent).toBe('Restore object/showcase_account to version 1?'); + expect(mockClient.rollback).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByTestId('history-rollback-confirm-action')); + + await waitFor(() => expect(mockClient.rollback).toHaveBeenCalledWith('object', 'showcase_account', 1)); + expect(mockClient.rollback).toHaveBeenCalledTimes(1); + await waitFor(() => + expect(mockClient.layered.mock.calls.filter(([type]) => type === 'object').length).toBeGreaterThan(reads), + ); + }); + + it('a read-only package shows its history without rollback, and its source read-only', async () => { + mountPillar('automations', { readOnly: true }); + await PILLARS[2]!.ready(); + const history = await openTool('history'); + expect(await within(history).findByText('seq 2')).toBeInTheDocument(); + expect(within(history).queryAllByTitle(ROLLBACK)).toHaveLength(0); + + const source = await openTool('source'); + expect(source).toHaveTextContent(t('engine.studio.itemTools.sourceReadOnly', 'en')); + const editor = await within(source).findByRole('textbox', { name: 'JSON source' }, { timeout: 8000 }); + expect(editor).toHaveAttribute('readonly'); + }); + + it('CONTROL: a writable package offers the rollback the read-only one withholds', async () => { + mountPillar('automations'); + await PILLARS[2]!.ready(); + const history = await openTool('history'); + expect(await within(history).findAllByTitle(ROLLBACK)).toHaveLength(2); + }); +}); + +describe('Studio item toolbar — History and Audit follow the trail doors (objectstack#28239)', () => { + it('a setup.access-only caller is offered Layers, References and Source, not History or Audit', async () => { + mountPillar('data', { perms: SETUP_ONLY }); + await PILLARS[0]!.ready(); + const tools = within(await screen.findByTestId('studio-item-tools', undefined, { timeout: 8000 })); + expect(tools.getAllByRole('button').map((b) => b.getAttribute('data-testid'))).toEqual([ + 'studio-item-tool-layers', + 'studio-item-tool-references', + 'studio-item-tool-source', + ]); + }); +}); + +describe("CONTROL — Setup's item page still opens the five from the shared directory (objectstack#28239)", () => { + it('Layers, References, History, Audit and Source open on a Setup item', async () => { + render( + + + , + ); + fireEvent.click(await screen.findByTitle(t('engine.edit.layers', 'en'), undefined, { timeout: 8000 })); + expect(await screen.findByRole('tab', { name: t('engine.layers.diff', 'en') })).toBeInTheDocument(); + + fireEvent.click(screen.getByTitle(t('engine.edit.references', 'en'))); + expect(await screen.findByText('all_accounts')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('history-button')); + expect(await screen.findAllByTitle(ROLLBACK)).toHaveLength(2); + + fireEvent.click(screen.getByTestId('audit-button')); + await waitFor(() => expect(mockClient.audit).toHaveBeenCalledWith('page', 'home', { limit: 100 })); + + // Setup's source is the inspector's Source tab, beside the open sheet. + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + fireEvent.click(screen.getByRole('tab', { name: t('engine.edit.inspector.source', 'en') })); + expect(await screen.findByRole('textbox', { name: 'JSON source' }, { timeout: 8000 })).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 95e9a7fd2a3..025415eeee4 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -72,6 +72,7 @@ import { useStudioTabRowFold, type StudioItemStatus, } from './StudioItemHeader.js'; +import { StudioItemTools } from '../item-tools/StudioItemTools.js'; import { StudioPendingDraftsContext, useStudioItemDraftStatus, @@ -5621,6 +5622,32 @@ export function InterfacesPillar({ {t('engine.studio.if.openInData', locale)} )} + {/* objectstack#28239 — the item tools Setup's item page opens. An + object entry's item is the object, edited in Data: its source + here is the served body, read-only. */} + {current && (draftLoaded || isObjectLeaf) && ( + { + setDraft(next); + setIfDirty(true); + } + : undefined, + } + } + onRolledBack={() => setLeafReloadNonce((n) => n + 1)} + locale={locale} + /> + )} {canvasModeToggleEl} {itemMoreEl} {canCollapseInspector && ( @@ -7757,6 +7784,26 @@ export function DataPillar({ })} ) : null} + {/* objectstack#28239 — the item tools Setup's item page opens. */} + {current && objLoaded && ( + { + setObjDraft(next); + setDirty(true); + }, + }} + onRolledBack={() => setObjReloadNonce((n) => n + 1)} + locale={locale} + /> + )} {/* objectui#12511 — the header holds no actions of its own, so it reads the same on every tab. Object settings retired: the Settings tab is that control. Add field is a field action, @@ -9594,6 +9641,26 @@ export function AutomationsPillar({ )} {flowSwitchEl} + {/* objectstack#28239 — the item tools Setup's item page opens. */} + {current && flowLoaded && ( + { + setDraft(next); + setAutoDirty(true); + } + : undefined, + }} + onRolledBack={() => setFlowReloadNonce((n) => n + 1)} + locale={locale} + /> + )} {!flowTabsEl && autoReadOnlyLinkEl} {current && flowLoaded && !readOnly && packageId !== null && ( = { 'engine.translationPreview.more': '+{count} more…', 'engine.translationPreview.keyCountOne': '{{count} key}', 'engine.translationPreview.keyCountOther': '{{count} keys}', + // Studio item toolbar: the item tools Setup's item page opens, offered in + // every pillar's item header (objectstack#28239). + 'engine.studio.itemTools.group': 'Item tools', + 'engine.studio.itemTools.sourceReadOnly': 'Read-only: this item cannot be edited here.', + 'engine.studio.itemTools.layersFailed': 'Could not read this item\'s layers: {message}', + 'engine.studio.itemTools.layersRetry': 'Try again', }; export default STUDIO_EN; diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 3a1d6814292..9660faba7dd 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -3759,6 +3759,11 @@ const STUDIO_ZH: Record = { 'engine.translationPreview.more': '另有 {count} 项…', 'engine.translationPreview.keyCountOne': '{{count} 个键}', 'engine.translationPreview.keyCountOther': '{{count} 个键}', + // Studio 项工具栏:Setup 项页面的项工具,在每个支柱的项头部提供(objectstack#28239)。 + 'engine.studio.itemTools.group': '项工具', + 'engine.studio.itemTools.sourceReadOnly': '只读:此项无法在这里编辑。', + 'engine.studio.itemTools.layersFailed': '无法读取此项的分层:{message}', + 'engine.studio.itemTools.layersRetry': '重试', }; export default STUDIO_ZH;