From c0bd2d11a6cea717e4809e2946418fd9c66b674a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 03:06:35 +0000 Subject: [PATCH] fix(app-shell): Studio names a draft-only item by its _drafts label, machine name second (objectui#11862 remainder) Each `GET /meta/_drafts` row carries the draft body's own label since objectstack#22200 (installed as 18.0.0-next.1). Read it off the row, never by reading the item: - Access: a draft-only permission set leads with its label in the rail. - Automations: a draft-only flow reads as its label (packageSurfaces). - Interfaces: a label-less nav entry that opens a draft-only object or dashboard inherits the draft's label (rail, edit-mode cards, inspector placeholder) through a last rung on useNavTargetLabel fed with the surface's pending drafts; the target pickers offer draft rows by label. - Changes list: every row leads with the row's label, name beneath it. A row with no label keeps its name alone. Display only. Claude-Session: https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB Co-authored-by: Claude --- .changeset/11862-draft-labels-remainder.md | 14 + .../app-shell/src/hooks/useNavTargetLabel.ts | 54 +++- .../src/preview/DraftChangesPanel.tsx | 31 +- ...raftChangesPanel.labelFirst-11862.test.tsx | 118 +++++++ .../app-shell/src/preview/usePendingDrafts.ts | 42 ++- ...ignSurface.accessRailLabels-11862.test.tsx | 43 ++- ...esignSurface.navDraftLabels-11862.test.tsx | 297 ++++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 73 +++-- .../studio-design/packageSurfaces.test.ts | 64 +++- .../views/studio-design/packageSurfaces.ts | 38 ++- 10 files changed, 714 insertions(+), 60 deletions(-) create mode 100644 .changeset/11862-draft-labels-remainder.md create mode 100644 packages/app-shell/src/preview/__tests__/DraftChangesPanel.labelFirst-11862.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/StudioDesignSurface.navDraftLabels-11862.test.tsx diff --git a/.changeset/11862-draft-labels-remainder.md b/.changeset/11862-draft-labels-remainder.md new file mode 100644 index 0000000000..ce287690ee --- /dev/null +++ b/.changeset/11862-draft-labels-remainder.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': patch +--- + +Studio names a draft-only item by its label, with the machine name as secondary text (objectui#11862, the remainder after objectui#11906). + +Since objectstack 18.0.0-next.0 each row of `GET /meta/_drafts` carries the draft body's own label, or `null` when the body declares none (objectstack#22200). Studio now reads that label off the row. It does not read each item to find it: + +- **Access:** a new permission set that exists only as a draft reads as its label in the permission-set list, with its machine name beneath it. It used to read as its identifier (`qa_readers`) after a reload. +- **Automations:** a draft-only flow reads as its label in the flow list. +- **Interfaces:** a nav entry that has no label of its own and opens a draft-only object or dashboard reads as that draft's label. This holds in the nav list, on the edit-mode cards and in the Label placeholder of the nav-item inspector. A published label still wins. The inspector's page, dashboard and report pickers offer a draft-only item as `Label (name)`, as they offer a published one. +- **Pending changes:** each row of the list leads with its label, with the machine name beneath it in monospace. This applies to new and updated items alike. A label given in several languages reads in the console language. + +A row whose draft declares no label shows its name alone, as before. Display only: nothing that is saved changes. Nothing is added to the package entry: no export, prop or language-pack key. diff --git a/packages/app-shell/src/hooks/useNavTargetLabel.ts b/packages/app-shell/src/hooks/useNavTargetLabel.ts index c65f5f3004..12608265c4 100644 --- a/packages/app-shell/src/hooks/useNavTargetLabel.ts +++ b/packages/app-shell/src/hooks/useNavTargetLabel.ts @@ -44,9 +44,22 @@ * * A target with no label answers `undefined` — the renderer then falls through * the spec's ladder and, at the bottom, to the target's machine name. + * + * ## A designer's pending drafts — the last rung (objectui#11862) + * + * A draft-only object or dashboard is in no published list, so the metadata + * above cannot name it, and a label-less entry that opens one read as its + * machine name in the Studio's nav editor. Where a designer holds its + * package's pending drafts, it hands them to {@link DraftTargetLabels}, and the + * label a draft's `_drafts` row carries (objectstack-ai/objectstack#22200) is + * asked last: only when the metadata has no label for the target, so a + * published label still wins. Nothing is fetched for it — the rows are the + * ones the designer already read. Without a provider the list is empty and + * this hook answers exactly as before, which is the console's case. */ -import { useMemo } from 'react'; +import { createContext, useContext, useMemo } from 'react'; +import { canonicalMetaUrlType } from '@objectstack/spec/shared'; import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { resolveViewId } from '@object-ui/core'; import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; @@ -83,6 +96,36 @@ export function findByName(items: readonly unknown[] | undefined, name: string): ); } +/** + * A pending draft as a designer holds it: the type its row is stored under, + * its name, and the label its `_drafts` row carries (absent or `null` when the + * draft body declares none). + */ +export interface DraftTargetLabel { + type: string; + name: string; + label?: unknown; +} + +/** + * The pending drafts a designer holds, for the last rung above. Internal to + * app-shell: the Studio surface provides the `_drafts` rows it reads for its + * Changes count; nothing else does. + */ +export const DraftTargetLabels = createContext>([]); + +/** The label of the pending draft of `type` named `name`, as display text, or `undefined`. */ +function draftText( + drafts: ReadonlyArray, + type: string, + name: string, + language: string | undefined, +): string | undefined { + // A stored type spelling is folded as every `/meta` reader folds it. + const draft = drafts.find((d) => d.name === name && canonicalMetaUrlType(d.type) === type); + return draft ? metadataText(draft.label, language) : undefined; +} + /** An object's merged `listViews` map (`.` → view), or `{}` when it has none. */ function listViewsOf(def: LabelledRecord | undefined): Record { const views = def?.listViews; @@ -93,13 +136,15 @@ export function useNavTargetLabel(): NavTargetLabelResolver { const metadata = useMetadata(); const { objectLabel, viewLabel, dashboardLabel } = useObjectLabel(); const { language } = useObjectTranslation(); + const drafts = useContext(DraftTargetLabels); return useMemo( () => (target: NavLabelTarget) => { switch (target.kind) { case 'object': { const def = findByName(metadata.objects, target.objectName); - const text = metadataText(def?.label, language); + const text = + metadataText(def?.label, language) ?? draftText(drafts, 'object', target.objectName, language); return nonEmpty(objectLabel({ name: target.objectName, label: text ?? '' })); } case 'view': { @@ -110,13 +155,14 @@ export function useNavTargetLabel(): NavTargetLabelResolver { } case 'dashboard': { const def = findByName(metadata.dashboards, target.dashboardName); - const text = metadataText(def?.label, language); + const text = + metadataText(def?.label, language) ?? draftText(drafts, 'dashboard', target.dashboardName, language); return nonEmpty(dashboardLabel({ name: target.dashboardName, label: text ?? '' })); } default: return undefined; } }, - [metadata, objectLabel, viewLabel, dashboardLabel, language], + [metadata, objectLabel, viewLabel, dashboardLabel, language, drafts], ); } diff --git a/packages/app-shell/src/preview/DraftChangesPanel.tsx b/packages/app-shell/src/preview/DraftChangesPanel.tsx index cc71e34a1a..889646a653 100644 --- a/packages/app-shell/src/preview/DraftChangesPanel.tsx +++ b/packages/app-shell/src/preview/DraftChangesPanel.tsx @@ -72,6 +72,7 @@ import { useObjectTranslation } from '@object-ui/i18n'; // refuses, and a faithful copy is exactly the fork that guard exists to prevent. import { fetchPendingDrafts } from './usePendingDrafts.js'; import { canonicalMetaUrlType } from '@objectstack/spec/shared'; +import { resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui'; import { stripReadDecorations } from '@objectstack/spec/kernel'; import { diffFields } from '../views/metadata-admin/previews/object-fields-io.js'; // The live `?surface=` channel, and NOT `useSurfaceDeepLink` beside it: this @@ -100,6 +101,13 @@ export interface DraftChangeEntry { * two rows, each with its own reads; an entry without one sends no selector. */ member?: string; + /** + * The label this draft's `_drafts` row carries (objectui#11862): the draft + * body's own label, read off the row (objectstack-ai/objectstack#22200). + * Absent when the body declares none. The row leads with it and shows the + * name beneath it; with none, the name alone is the row's text. + */ + label?: I18nLabel; /** `new` = no published version; `update` = overwrites one; undefined = probing. */ kind?: 'new' | 'update'; } @@ -133,6 +141,7 @@ async function listPendingDrafts(packageId?: string | null): Promise(null); const [error, setError] = useState(null); /** @@ -685,6 +694,13 @@ export function DraftChangesPanel({ // objectui#11807 — the drill-in found nothing this publish // changes: not an UPDATE, whatever the name list said. const isUnchanged = unchanged.has(key); + // objectui#11862 — the row's label leads, in the console + // language, for a NEW row and an UPDATE row alike: the one + // `_drafts` serves, so this list never chooses between a + // published and a draft label. The name follows as + // secondary text, or is the row's text when there is no + // label. + const labelText = resolveI18nLabel(entry.label, language)?.trim() ?? ''; return (