From 0067412d1662f9dbf07b545f457d2e970e7f870a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:24:05 +0000 Subject: [PATCH 1/4] refactor(app-shell): move Studio's draft autosave hook to its own module (objectui#11787) `useDraftAutoSave`, `DraftSend` and `draftSnapshotKey` move out of StudioDesignSurface.tsx unchanged, so the package door's permission matrix and the hooks panel can run the same autosave as the pillars. The matrix cannot import the surface: the surface imports the matrix. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../studio-design/StudioDesignSurface.tsx | 153 +--------------- .../views/studio-design/useDraftAutoSave.ts | 166 ++++++++++++++++++ 2 files changed, 168 insertions(+), 151 deletions(-) create mode 100644 packages/app-shell/src/views/studio-design/useDraftAutoSave.ts diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 9d6ced425b..b760c912e7 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -205,6 +205,8 @@ import { newField, } from '../metadata-admin/previews/object-fields-io.js'; import { CreateItemDialog } from './CreateItemDialog.js'; +// objectui#11787 — the shared draft autosave, moved to its own module unchanged. +import { useDraftAutoSave, type DraftSend } from './useDraftAutoSave.js'; import { FLOW_PRESETS, flowPresetTrigger, type FlowPreset } from './flowPresets.js'; import { CreatePackageDialog, @@ -254,68 +256,6 @@ const PILLARS: ReadonlyArray<{ key: string; label: string; Icon: LucideIcon }> = { key: 'automations', label: 'Automations', Icon: Workflow }, { key: 'interfaces', label: 'Interfaces', Icon: LayoutDashboard }, ]; -// objectui#5813 — debounced draft auto-save, shared by the pillars' editors. -// Drafts never touch the live app, so persisting them automatically is -// zero-risk; the Save draft buttons it replaces were a standing tax on the -// topbars AND a real loss point (forgot-to-save). Semantics: -// - re-arms 1.5s after the LAST edit (the snapshot key changes per edit); -// - `blocked` mirrors each site's old disabled-guard — in particular a -// CEL-blocking inspector must gate the TIMER, not just a button, or the -// timer publishes the malformed definition a second later (objectui#4306); -// - a FAILED save does not retry until the user edits again (the snapshot -// it attempted is remembered), so an invalid draft can't toast-loop. -// - objectui#11189 — the returned `flush` fires the pending save now instead -// of at the timer, under the same conditions, and says whether a save went -// out. A flushed snapshot counts as attempted, so the timer never sends it -// a second time. -// - objectui#11204 — a save that lands clears its dirty flag only if nothing -// was edited while it was in flight. The hook hands every save it sends a -// `DraftSend` claim on the snapshot it sent, and the caller's -// `set*Dirty(false)` after its await runs only while `sent.unmoved()`. An -// edit taken meanwhile keeps the buffer dirty, and the autosave, unblocked -// by the save's end, sends it next. A save the caller sends itself takes -// its claim from `sending(body)`. -// - objectui#11232 — a dirty period belongs to the item that was open when it -// began. The caller names the item its `save` addresses as `target`, a -// primitive identity (type and name; never a memoised object, AGENTS.md -// #10). On a switch the caller's `save` addresses the newly opened item at -// once, while the buffer holds the previous item's document until the new -// load installs its own; so a period that began on another item is never -// sent, by the timer or by `flush`, and it ends only when the caller's dirty -// flag falls. -// The previous item's pending edit is dropped: what the Data pillar's switch -// has always done, and what every pillar did whenever the new load landed -// inside the debounce. A claim also reads moved once the target has -// changed, so a save that lands after a switch never clears the dirty flag -// of the item opened since. -// - objectui#11272 — the buffer belongs to the item it was loaded for. The -// caller names that item as `loadedFor`, in `target`'s spelling, and sets -// it where its load installs a buffer and nowhere else. Until the open -// item's own document is in, `loadedFor` is not `target`: then the timer -// and `flush` send nothing, `sending` gives no claim (every save the caller -// sends itself asks it first, and sends nothing without one), and `loaded` -// is false, which the caller reads to show and offer nothing of the buffer -// under the open item. An edit a period begins while the buffer is another -// item's is never sent. It ends with the dirty flag, which every caller's -// buffer install clears: the page inspector's and the Data pillar's as -// they install (the Data pillar also at its load's start), a leaf with no -// editable draft as its `{}` goes in, and Automations when the load -// settles. The Interfaces nav installs only over a clean buffer, or on its -// mount (it is keyed by package). - -/** - * objectui#11204 — one draft save's claim on the buffer it sent. `unmoved()` - * is true while the buffer, as last committed, is still the snapshot that - * save sent, compared the way the autosave compares snapshots (serialised). - * It compares content, not an edit count: an edit undone while the save was - * in flight leaves the buffer as the server now holds it, and so clean. - * objectui#11232 — and only while the hook's `target` is still the item the - * save was sent for: after a switch the buffer is the next item's to clear. - */ -interface DraftSend { - unmoved: () => boolean; -} - /** * objectui#11357 — the body the Interfaces pillar saves for its open leaf. A * page goes through the `page` registration's `fromDraft`, the serialiser @@ -336,95 +276,6 @@ function interfacesSaveBody(type: string, draft: Record): Recor return fromDraft ? fromDraft(draft) : draft; } -function draftSnapshotKey(snapshot: unknown): string { - try { - return JSON.stringify(snapshot ?? null); - } catch { - // Unserializable draft (never the case for metadata bodies): a constant - // key means one auto-save per dirty period instead of per edit, and a - // save that lands always reads unmoved: degraded, as before. - return '"__unserializable__"'; - } -} - -function useDraftAutoSave(opts: { - /** objectui#11232 — the item `save` addresses, as a primitive identity. */ - target: string; - /** objectui#11272 — the item whose document the buffer holds: the target - * the caller's load installed it for, in the same spelling. */ - loadedFor: string; - dirty: boolean; - blocked: boolean; - snapshot: unknown; - save: (sent: DraftSend) => void | Promise; -}): { - flush: () => boolean; - /** A claim for a save the caller sends itself; `null` refuses it (objectui#11272). */ - sending: (snapshot: unknown) => DraftSend | null; - /** objectui#11272 — the buffer is the open item's own document. */ - loaded: boolean; -} { - const { target, loadedFor, dirty, blocked, snapshot, save } = opts; - // Pure (the react compiler forbids impure render calls). - const snapKey = React.useMemo(() => draftSnapshotKey(snapshot), [snapshot]); - const lastAttemptRef = React.useRef(null); - const saveRef = React.useRef(save); - // What the timer below would send, as last committed, for `flush` and for a - // landing save's claim to read. A layout effect, so it is current as soon as - // a render commits: a save that lands right after an edit reads the edit. - // `since` is the target the dirty period began on (objectui#11232): taken - // as the flag rises, kept while it stays up. - const pendingRef = React.useRef({ dirty, blocked, snapKey, target, loadedFor, since: target }); - React.useLayoutEffect(() => { - const prev = pendingRef.current; - saveRef.current = save; - pendingRef.current = { dirty, blocked, snapKey, target, loadedFor, since: dirty && prev.dirty ? prev.since : target }; - }); - // A state initializer, not a memo: React keeps its identity by contract, so - // a caller may list it, or a member of it, as an effect dependency - // (AGENTS.md #10). - const [api] = React.useState(() => { - const claim = (key: string, sentFor: string): DraftSend => ({ - unmoved: () => pendingRef.current.target === sentFor && pendingRef.current.snapKey === key, - }); - // objectui#11232 — the pending edit is the open item's own: its dirty - // period began on the item `save` now addresses. objectui#11272 — and the - // buffer it edits is that item's document: its load installed it for it. - const owned = (): boolean => { - const { since, target: now, loadedFor: holds } = pendingRef.current; - return since === now && holds === now; - }; - const send = (key: string): void => { - lastAttemptRef.current = key; - void saveRef.current(claim(key, pendingRef.current.target)); - }; - return { - send, - owned, - flush: (): boolean => { - const pending = pendingRef.current; - if (!pending.dirty || pending.blocked || !owned() || lastAttemptRef.current === pending.snapKey) return false; - send(pending.snapKey); - return true; - }, - sending: (sent: unknown): DraftSend | null => - owned() ? claim(draftSnapshotKey(sent), pendingRef.current.target) : null, - }; - }); - React.useEffect(() => { - if (!dirty || blocked) return; - if (lastAttemptRef.current === snapKey) return; - const timer = setTimeout(() => { - if (lastAttemptRef.current === snapKey) return; - // Read as last committed: a switch since the edit left it with its item. - if (!api.owned()) return; - api.send(snapKey); - }, 1500); - return () => clearTimeout(timer); - }, [dirty, blocked, snapKey, api]); - return { flush: api.flush, sending: api.sending, loaded: loadedFor === target }; -} - /** * objectui#11786 — tells the surface's Publish what edit a pillar holds unsent * (`StudioHeld.clause`, or `null`), and takes it back when the pillar unmounts, diff --git a/packages/app-shell/src/views/studio-design/useDraftAutoSave.ts b/packages/app-shell/src/views/studio-design/useDraftAutoSave.ts new file mode 100644 index 0000000000..9a33e92753 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/useDraftAutoSave.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The debounced draft autosave every Studio editor shares (objectui#5813). + * + * Moved out of `StudioDesignSurface.tsx` unchanged by objectui#11787 so that the + * package door's permission matrix (`metadata-admin/PermissionMatrixEditor.tsx`) + * and the Data pillar's hooks panel (`ObjectHooksPanel.tsx`) run the same + * autosave as the pillars, not a second debounce. A module of its own because + * the matrix cannot import the surface: the surface imports the matrix, and + * the matrix is also loaded on the environment-admin route, where the surface + * is not. + */ + +import * as React from 'react'; + +// objectui#5813 — debounced draft auto-save, shared by the pillars' editors. +// Drafts never touch the live app, so persisting them automatically is +// zero-risk; the Save draft buttons it replaces were a standing tax on the +// topbars AND a real loss point (forgot-to-save). Semantics: +// - re-arms 1.5s after the LAST edit (the snapshot key changes per edit); +// - `blocked` mirrors each site's old disabled-guard — in particular a +// CEL-blocking inspector must gate the TIMER, not just a button, or the +// timer publishes the malformed definition a second later (objectui#4306); +// - a FAILED save does not retry until the user edits again (the snapshot +// it attempted is remembered), so an invalid draft can't toast-loop. +// - objectui#11189 — the returned `flush` fires the pending save now instead +// of at the timer, under the same conditions, and says whether a save went +// out. A flushed snapshot counts as attempted, so the timer never sends it +// a second time. +// - objectui#11204 — a save that lands clears its dirty flag only if nothing +// was edited while it was in flight. The hook hands every save it sends a +// `DraftSend` claim on the snapshot it sent, and the caller's +// `set*Dirty(false)` after its await runs only while `sent.unmoved()`. An +// edit taken meanwhile keeps the buffer dirty, and the autosave, unblocked +// by the save's end, sends it next. A save the caller sends itself takes +// its claim from `sending(body)`. +// - objectui#11232 — a dirty period belongs to the item that was open when it +// began. The caller names the item its `save` addresses as `target`, a +// primitive identity (type and name; never a memoised object, AGENTS.md +// #10). On a switch the caller's `save` addresses the newly opened item at +// once, while the buffer holds the previous item's document until the new +// load installs its own; so a period that began on another item is never +// sent, by the timer or by `flush`, and it ends only when the caller's dirty +// flag falls. +// The previous item's pending edit is dropped: what the Data pillar's switch +// has always done, and what every pillar did whenever the new load landed +// inside the debounce. A claim also reads moved once the target has +// changed, so a save that lands after a switch never clears the dirty flag +// of the item opened since. +// - objectui#11272 — the buffer belongs to the item it was loaded for. The +// caller names that item as `loadedFor`, in `target`'s spelling, and sets +// it where its load installs a buffer and nowhere else. Until the open +// item's own document is in, `loadedFor` is not `target`: then the timer +// and `flush` send nothing, `sending` gives no claim (every save the caller +// sends itself asks it first, and sends nothing without one), and `loaded` +// is false, which the caller reads to show and offer nothing of the buffer +// under the open item. An edit a period begins while the buffer is another +// item's is never sent. It ends with the dirty flag, which every caller's +// buffer install clears: the page inspector's and the Data pillar's as +// they install (the Data pillar also at its load's start), a leaf with no +// editable draft as its `{}` goes in, and Automations when the load +// settles. The Interfaces nav installs only over a clean buffer, or on its +// mount (it is keyed by package). + +/** + * objectui#11204 — one draft save's claim on the buffer it sent. `unmoved()` + * is true while the buffer, as last committed, is still the snapshot that + * save sent, compared the way the autosave compares snapshots (serialised). + * It compares content, not an edit count: an edit undone while the save was + * in flight leaves the buffer as the server now holds it, and so clean. + * objectui#11232 — and only while the hook's `target` is still the item the + * save was sent for: after a switch the buffer is the next item's to clear. + */ +export interface DraftSend { + unmoved: () => boolean; +} + +function draftSnapshotKey(snapshot: unknown): string { + try { + return JSON.stringify(snapshot ?? null); + } catch { + // Unserializable draft (never the case for metadata bodies): a constant + // key means one auto-save per dirty period instead of per edit, and a + // save that lands always reads unmoved: degraded, as before. + return '"__unserializable__"'; + } +} + +export function useDraftAutoSave(opts: { + /** objectui#11232 — the item `save` addresses, as a primitive identity. */ + target: string; + /** objectui#11272 — the item whose document the buffer holds: the target + * the caller's load installed it for, in the same spelling. */ + loadedFor: string; + dirty: boolean; + blocked: boolean; + snapshot: unknown; + save: (sent: DraftSend) => void | Promise; +}): { + flush: () => boolean; + /** A claim for a save the caller sends itself; `null` refuses it (objectui#11272). */ + sending: (snapshot: unknown) => DraftSend | null; + /** objectui#11272 — the buffer is the open item's own document. */ + loaded: boolean; +} { + const { target, loadedFor, dirty, blocked, snapshot, save } = opts; + // Pure (the react compiler forbids impure render calls). + const snapKey = React.useMemo(() => draftSnapshotKey(snapshot), [snapshot]); + const lastAttemptRef = React.useRef(null); + const saveRef = React.useRef(save); + // What the timer below would send, as last committed, for `flush` and for a + // landing save's claim to read. A layout effect, so it is current as soon as + // a render commits: a save that lands right after an edit reads the edit. + // `since` is the target the dirty period began on (objectui#11232): taken + // as the flag rises, kept while it stays up. + const pendingRef = React.useRef({ dirty, blocked, snapKey, target, loadedFor, since: target }); + React.useLayoutEffect(() => { + const prev = pendingRef.current; + saveRef.current = save; + pendingRef.current = { dirty, blocked, snapKey, target, loadedFor, since: dirty && prev.dirty ? prev.since : target }; + }); + // A state initializer, not a memo: React keeps its identity by contract, so + // a caller may list it, or a member of it, as an effect dependency + // (AGENTS.md #10). + const [api] = React.useState(() => { + const claim = (key: string, sentFor: string): DraftSend => ({ + unmoved: () => pendingRef.current.target === sentFor && pendingRef.current.snapKey === key, + }); + // objectui#11232 — the pending edit is the open item's own: its dirty + // period began on the item `save` now addresses. objectui#11272 — and the + // buffer it edits is that item's document: its load installed it for it. + const owned = (): boolean => { + const { since, target: now, loadedFor: holds } = pendingRef.current; + return since === now && holds === now; + }; + const send = (key: string): void => { + lastAttemptRef.current = key; + void saveRef.current(claim(key, pendingRef.current.target)); + }; + return { + send, + owned, + flush: (): boolean => { + const pending = pendingRef.current; + if (!pending.dirty || pending.blocked || !owned() || lastAttemptRef.current === pending.snapKey) return false; + send(pending.snapKey); + return true; + }, + sending: (sent: unknown): DraftSend | null => + owned() ? claim(draftSnapshotKey(sent), pendingRef.current.target) : null, + }; + }); + React.useEffect(() => { + if (!dirty || blocked) return; + if (lastAttemptRef.current === snapKey) return; + const timer = setTimeout(() => { + if (lastAttemptRef.current === snapKey) return; + // Read as last committed: a switch since the edit left it with its item. + if (!api.owned()) return; + api.send(snapKey); + }, 1500); + return () => clearTimeout(timer); + }, [dirty, blocked, snapKey, api]); + return { flush: api.flush, sending: api.sending, loaded: loadedFor === target }; +} From ce12e9bdbccf80152d311dae90f65dbab43a5bd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:41:08 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(app-shell):=20one=20save=20model=20in?= =?UTF-8?q?=20Studio=20=E2=80=94=20the=20package=20door's=20matrix=20and?= =?UTF-8?q?=20hooks=20autosave,=20one=20create=20label,=20a=20Changes=20co?= =?UTF-8?q?unt=20that=20keeps=20up=20(objectui#11787)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The permission matrix autosaves to the package draft on the package door through the shared Studio autosave, with the pillars' status line in place of Save; the environment-admin door keeps its explicit Save (ADR-0086 D7). The api name is fixed on the package door, since the draft is stored under it. - A hook autosaves to its draft; a blocking CEL verdict holds it. "Save hook" is gone, and hook saves and "+ New" report to the surface's count. - New permission set says "Save as draft" like every other create dialog. - A draft save asks for a pending-drafts read of its own, never joined to one already in flight, and the header counts at least that draft until it answers. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11787-one-save-model.md | 13 ++ .../usePendingDrafts.afterSave-11787.test.tsx | 141 +++++++++++++ .../app-shell/src/preview/usePendingDrafts.ts | 50 ++++- ...rmissionMatrixEditor.artifactTier.test.tsx | 6 +- ...issionMatrixEditor.autosave-11787.test.tsx | 194 ++++++++++++++++++ ...missionMatrixEditor.bulkMergeKeys.test.tsx | 11 +- ...sionMatrixEditor.cloneToCustomize.test.tsx | 6 +- .../PermissionMatrixEditor.dirty.test.tsx | 9 +- ...ionMatrixEditor.packageDoorFacets.test.tsx | 18 +- ...ixEditor.packageDoorRereadRefusal.test.tsx | 33 +-- ...nMatrixEditor.readDecorationStrip.test.tsx | 5 +- .../PermissionMatrixEditor.readonly.test.tsx | 9 +- ...nMatrixEditor.readonlyHeaderBadge.test.tsx | 9 +- .../PermissionMatrixEditor.scope.test.tsx | 6 +- .../metadata-admin/PermissionMatrixEditor.tsx | 106 +++++++++- .../src/views/metadata-admin/i18n.ts | 6 - .../CreateItemDialog.oneLabel-11787.test.ts | 79 +++++++ .../ObjectHooksPanel.autosave-11787.test.tsx | 140 +++++++++++++ .../ObjectHooksPanel.celGate.test.tsx | 62 +++--- ...ooksPanel.designerRegistryMissing.test.tsx | 18 +- .../views/studio-design/ObjectHooksPanel.tsx | 133 +++++++++--- .../StudioDesignSurface.accessGuard.test.tsx | 3 +- ...oDesignSurface.changesCount-11787.test.tsx | 186 +++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 38 +++- 24 files changed, 1141 insertions(+), 140 deletions(-) create mode 100644 .changeset/11787-one-save-model.md create mode 100644 packages/app-shell/src/preview/usePendingDrafts.afterSave-11787.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.autosave-11787.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/CreateItemDialog.oneLabel-11787.test.ts create mode 100644 packages/app-shell/src/views/studio-design/ObjectHooksPanel.autosave-11787.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/StudioDesignSurface.changesCount-11787.test.tsx diff --git a/.changeset/11787-one-save-model.md b/.changeset/11787-one-save-model.md new file mode 100644 index 0000000000..f7c0a91035 --- /dev/null +++ b/.changeset/11787-one-save-model.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +--- + +Studio saves one way on a package: the permission matrix and hooks autosave to the package draft like the other pillars, every create dialog says *Save as draft*, and the Changes count keeps up with a save (objectui#11787). + +- **Permission matrix, package door.** An edit is saved to the package's draft 1.5 seconds after the last change, the same autosave the Data and Interfaces pillars run, and the header shows *Saving…* and then *Saved* with the time in place of the Save button. A CEL syntax error in the row filter holds the edit and says why. An edit made while a save is in flight is kept and sent next. The set's API name can no longer be edited here, because the autosave stores the draft under that name. It is set when the set is created. +- **Permission matrix, environment-admin door.** Unchanged. It keeps its explicit Save, because it writes live access configuration (ADR-0086 D7). +- **Hooks.** A hook autosaves to its draft the same way, and the *Save hook* button is gone. A hook whose run condition does not parse is held until it does. Creating a hook and saving one both update the Changes count. +- **Create dialogs.** *New permission set* now says *Save as draft*, like every other Studio create dialog. +- **Changes count.** After a draft save, the header no longer says "No drafts pending publish" next to an item's "Unpublished draft" chip. It counts at least the draft just saved until its next read answers, and that read is sent after the save instead of reusing a read that was already in flight. + +No export, prop, type or language-pack key changes. diff --git a/packages/app-shell/src/preview/usePendingDrafts.afterSave-11787.test.tsx b/packages/app-shell/src/preview/usePendingDrafts.afterSave-11787.test.tsx new file mode 100644 index 0000000000..b1115b0860 --- /dev/null +++ b/packages/app-shell/src/preview/usePendingDrafts.afterSave-11787.test.tsx @@ -0,0 +1,141 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * objectui#11787 — the count read after a draft save is sent after it, and + * until it answers the count is known to be at least one. + * + * Studio's header showed "No drafts pending publish" beside an item's + * "Unpublished draft" chip after a save, until the count caught up. Two causes + * meet in this hook: + * - the read a save asks for could JOIN a `_drafts` request already on the + * wire (objectui#11797's sharing), sent before the save and so unable to + * count the draft it wrote; + * - until any read answers, the count shown is the one from before the save. + * + * Counted on the wire with the same held `fetch` the objectui#11797 pins use: + * every request stays open until the test answers it. + */ +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { usePendingDrafts } from './usePendingDrafts.js'; + +interface HeldRequest { + url: string; + respond: (status: number, body: unknown) => void; +} + +function heldFetch() { + const held: HeldRequest[] = []; + vi.stubGlobal( + 'fetch', + vi.fn( + (input: RequestInfo | URL) => + new Promise((resolve) => { + held.push({ + url: String(input), + respond: (status, body) => + resolve({ ok: status >= 200 && status < 300, status, json: async () => body }), + }); + }), + ), + ); + const reads = (url: string) => held.filter((r) => r.url === url); + return { held, reads }; +} + +const CRM = '/api/v1/meta/_drafts?packageId=app.crm'; +const draft = (name: string) => ({ type: 'object', name, packageId: 'app.crm' }); + +/** The Studio header's shape: the count, whether it is behind a save, and the save's refresh. */ +function Header() { + const { count, refresh, behindSave } = usePendingDrafts({ packageId: 'app.crm' }); + return ( + <> + {count === null ? 'unknown' : String(count)} + {String(behindSave)} + - {writable && ( + {/* ADR-0086 D7 — the environment door's explicit Save: a live write. */} + {writable && !packageId && ( )} + {/* objectui#11787 — the package door autosaves (see the autosave + above), so its Save button gives way to the status line every + Studio pillar shows: saving while in flight, the last-saved time + once landed, and why an edit is held when a CEL error holds it. */} + {writable && packageId && ( + saving ? ( + + + {t('engine.studio.autoSaving')} + + ) : celErrorCount > 0 && isDirty ? ( + + {t('perm.cel.saveBlocked')} + + ) : savedAt && !isDirty ? ( + + {tFormat('engine.studio.data.lastSaved', locale, { + time: savedAt.toLocaleTimeString(displayLocale, { hour: '2-digit', minute: '2-digit' }), + })} + + ) : null + )} {/* objectui#5987 — the locked editor's PRIMARY action, in the slot Save would occupy: a code package ships this set, so the ruled path is to clone it and edit the clone. Only where the artifact tier is @@ -1290,7 +1369,12 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, setDraft((p) => ({ ...p, name: e.target.value }))} className="h-8 w-56" /> diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index bda9189f32..d8b94c573c 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -2757,7 +2757,6 @@ const ENGINE_STRINGS_EN: Record = { // ── StudioDesignSurface (ADR-0080 WYSIWYG design surface) ────────────── // Shared chrome 'engine.studio.cancel': 'Cancel', - 'engine.studio.create': 'Create', 'engine.studio.creating': 'Creating…', // objectui#11792 — CreateItemDialog's preview of the name the item is saved // under, shown when the caller supplies that rule (an object's namespace prefix). @@ -3374,8 +3373,6 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.hooks.async': 'async', 'engine.studio.hooks.pick': 'Select a hook to edit it.', 'engine.studio.hooks.newLabel': 'New hook', - 'engine.studio.hooks.saved': 'Saved “{label}” as draft', - 'engine.studio.hooks.save': 'Save hook', 'engine.studio.data.addFieldTitle': 'Add a field (then set its type and properties on the right)', 'engine.studio.data.addField': 'Add field', 'engine.studio.data.editFieldProps': 'Edit field properties', @@ -5992,7 +5989,6 @@ const ENGINE_STRINGS_ZH: Record = { // ── StudioDesignSurface (ADR-0080 WYSIWYG design surface) ────────────── // Shared chrome 'engine.studio.cancel': '取消', - 'engine.studio.create': '创建', 'engine.studio.creating': '创建中…', 'engine.studio.createItem.savedAs': '保存为', // `383502b23` — see the English entry. This value must share no leading @@ -6518,8 +6514,6 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.hooks.async': '异步', 'engine.studio.hooks.pick': '选择一个钩子进行编辑。', 'engine.studio.hooks.newLabel': '新钩子', - 'engine.studio.hooks.saved': '已将「{label}」存为草稿', - 'engine.studio.hooks.save': '保存钩子', 'engine.studio.data.addFieldTitle': '添加一个字段(随后在右侧设置类型与属性)', 'engine.studio.data.addField': '添加字段', 'engine.studio.data.editFieldProps': '编辑字段属性', diff --git a/packages/app-shell/src/views/studio-design/CreateItemDialog.oneLabel-11787.test.ts b/packages/app-shell/src/views/studio-design/CreateItemDialog.oneLabel-11787.test.ts new file mode 100644 index 0000000000..94e8c3a448 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/CreateItemDialog.oneLabel-11787.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11787 — Studio's create dialogs say one thing: *Save as draft*. + * + * Every `CreateItemDialog` in Studio writes a draft (an app, a page, a + * dashboard, a report, an object, a flow, a permission set), so the submit + * button names that one act with one string, `engine.studio.createDraft`. The + * QA pass that filed the card found *New permission set* alone saying + * *Create*. + * + * The call sites are ENUMERATED from this package's source, not listed here + * (AGENTS.md #9): a dialog added later is asked the same question, and the + * control below proves the enumeration reaches the site this card changed. + */ + +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) +import { maskComments } from '../../../../../scripts/js-comment-mask.mjs'; + +const mask: (source: string) => string = maskComments; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '../../../../..'); +const SRC = path.join(repoRoot, 'packages/app-shell/src'); + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) out.push(...sourceFiles(full)); + else if (full.endsWith('.tsx') && !/\.test\.tsx$/.test(full)) out.push(full); + } + return out; +} + +/** One rendered ``: its file, its submit label, and the props text around it. */ +interface Site { + file: string; + submitLabel: string | undefined; + props: string; +} + +function createDialogSites(): Site[] { + const sites: Site[] = []; + for (const file of sourceFiles(SRC)) { + const source = mask(readFileSync(file, 'utf8')); + // `submitLabel` is a required prop, so the first one after the tag is the tag's own. + for (const props of source.split(' { + it('every CreateItemDialog submits as "Save as draft"', () => { + const sites = createDialogSites(); + expect(sites.length).toBeGreaterThan(0); + for (const site of sites) { + expect({ file: site.file, submitLabel: site.submitLabel }).toEqual({ file: site.file, submitLabel: ONE_LABEL }); + } + }); + + it('CONTROL — the enumeration reaches the New permission set dialog, the one that said "Create"', () => { + const access = createDialogSites().filter((site) => /title=\{t\('engine\.studio\.access\.new'/.test(site.props)); + expect(access).toHaveLength(1); + expect(access[0].submitLabel).toBe(ONE_LABEL); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.autosave-11787.test.tsx b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.autosave-11787.test.tsx new file mode 100644 index 0000000000..0157b28a0b --- /dev/null +++ b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.autosave-11787.test.tsx @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11787 — a hook autosaves to its draft like the other Studio + * editors, and its saves reach the surface's pending-changes count. + * + * The *Save hook* button was the third save model in Studio (beside the + * pillars' autosave and the permission matrix's Save). The panel now runs the + * shared autosave: an edit is sent 1.5s after the last change, a save that + * lands reports to the surface (`onDraftSaved`) so the header's count + * refreshes, and an edit taken while a save is in flight is kept and sent next + * (the objectui#11204 rule every pillar keeps). The held half — a blocking CEL + * verdict holds the autosave — is pinned beside it in + * `ObjectHooksPanel.celGate.test.tsx`. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; + +const hook = { + name: 'guard_hook', + label: 'Guard', + object: 'invoice', + events: ['beforeInsert'], + handler: 'guard_fn', +}; + +/** Bodies the panel sent, copied at the moment of the call. */ +const sent: Array> = []; +/** When set, the first save waits on it: a save held in flight. */ +let holdFirstSave: Promise | null = null; + +const mockClient = { + list: vi.fn(async () => [hook]), + listDrafts: vi.fn(async () => []), + getDraft: vi.fn(async () => null), + get: vi.fn(async () => null), + withPreviewDrafts() { + return this; + }, + save: vi.fn(async (_type: string, _name: string, body: Record) => { + sent.push(JSON.parse(JSON.stringify(body))); + if (sent.length === 1 && holdFirstSave) await holdFirstSave; + return {}; + }), +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient }; +}); + +import { ObjectHooksPanel } from './ObjectHooksPanel'; +import { registerBuiltinInspectors } from '../metadata-admin/inspectors'; +import { __setCelFormulaLoader } from '../metadata-admin/celAuthoring'; + +registerBuiltinInspectors(); + +beforeEach(() => { + sent.length = 0; + holdFirstSave = null; + mockClient.save.mockClear(); + __setCelFormulaLoader(() => + Promise.resolve({ + validateExpression: () => ({ ok: true, errors: [], warnings: [] }), + introspectScope: () => ({ fields: ['status'], roots: ['record'], functions: ['has'] }), + inferExpressionType: () => 'boolean' as const, + }), + ); +}); +afterEach(() => { + cleanup(); + __setCelFormulaLoader(undefined); +}); + +const AUTOSAVE = { timeout: 4000 }; + +async function openGuard(onDraftSaved?: () => void) { + render(); + fireEvent.click(await screen.findByText('Guard')); + fireEvent.click(await screen.findByText('Expression')); + return screen.getAllByRole('combobox').find((el) => el.tagName === 'TEXTAREA') as HTMLTextAreaElement; +} + +describe('ObjectHooksPanel — a hook autosaves to its draft (objectui#11787)', () => { + it('sends an edit after the pause, with no Save hook button, and tells the surface', async () => { + const onDraftSaved = vi.fn(); + const box = await openGuard(onDraftSaved); + expect(screen.queryByRole('button', { name: /Save/i })).toBeNull(); + + fireEvent.change(box, { target: { value: "record.status == 'open'" } }); + // Debounced, not per keystroke. + expect(mockClient.save).not.toHaveBeenCalled(); + + await waitFor(() => expect(mockClient.save).toHaveBeenCalledTimes(1), AUTOSAVE); + expect(mockClient.save).toHaveBeenCalledWith( + 'hook', + 'guard_hook', + expect.objectContaining({ object: 'invoice', handler: 'guard_fn' }), + expect.objectContaining({ mode: 'draft', packageId: 'com.example.showcase' }), + ); + await waitFor(() => expect(onDraftSaved).toHaveBeenCalledTimes(1)); + expect(await screen.findByTestId('hooks-saved-at')).toBeInTheDocument(); + }); + + it('"+ New" is a draft save too: the surface hears of it', async () => { + const onDraftSaved = vi.fn(); + render(); + await screen.findByText('Guard'); + + fireEvent.click(screen.getByRole('button', { name: /New/ })); + await waitFor(() => expect(mockClient.save).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onDraftSaved).toHaveBeenCalledTimes(1)); + }); + + it('keeps an edit taken while the autosave is in flight, and sends it next (objectui#11204)', async () => { + let release!: () => void; + holdFirstSave = new Promise((resolve) => { + release = resolve; + }); + const box = await openGuard(); + + fireEvent.change(box, { target: { value: "record.status == 'open'" } }); + await waitFor(() => expect(sent).toHaveLength(1), AUTOSAVE); + expect(await screen.findByTestId('hooks-autosaving')).toBeInTheDocument(); + + // A second edit while the first save is on the wire. + fireEvent.change(box, { target: { value: "record.status == 'closed'" } }); + release(); + + // The landed save does not put its own body back over the newer edit… + await waitFor(() => expect(screen.queryByTestId('hooks-autosaving')).toBeNull()); + expect(box).toHaveValue("record.status == 'closed'"); + // …and the edit, still unsent, goes out next. + await waitFor(() => expect(sent).toHaveLength(2), AUTOSAVE); + expect(JSON.stringify(sent[0])).toContain("record.status == 'open'"); + expect(JSON.stringify(sent[1])).toContain("record.status == 'closed'"); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.celGate.test.tsx b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.celGate.test.tsx index 8a522509ae..611bca04df 100644 --- a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.celGate.test.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.celGate.test.tsx @@ -1,16 +1,21 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The object hooks panel owns its OWN per-hook Save (it writes the hook - * directly with `client.save('hook', …, { mode: 'draft' })` — the object's Save + * The object hooks panel owns its OWN per-hook save (it writes the hook + * directly with `client.save('hook', …, { mode: 'draft' })` — the object's * draft does not cover hooks), so it must refuse to write a guard whose CEL * does not parse — objectui#4527 phase 2. * * Of the four panel hosts the #4547 census surfaced, this is the only one that - * owns a Save button; the other three write through the Data pillar's draft and - * report upward instead. So this suite is the end-to-end proof for the - * hook branch of the default-inspector family: HookDefaultInspector's verdict - * has to reach a real disabled button, not just a callback. + * owns its save; the other three write through the Data pillar's draft and + * report upward instead. So this suite is the end-to-end proof for the hook + * branch of the default-inspector family: HookDefaultInspector's verdict has to + * reach the save itself, not just a callback. + * + * objectui#11787 — that save is now the panel's autosave (the shared one every + * Studio editor runs), so the verdict HOLDS it: a malformed guard is never + * sent, however long the pause, and the panel says why. Each absence is read + * against the same harness sending the same hook once the guard parses. */ import '@testing-library/jest-dom/vitest'; @@ -71,7 +76,10 @@ afterEach(() => { __setCelFormulaLoader(undefined); }); -const saveButton = () => screen.getByRole('button', { name: /Save/i }); +/** Longer than the shared autosave's pause (1.5s). */ +const PAST_THE_PAUSE = 2200; +const AUTOSAVE = { timeout: 4000 }; +const pause = () => new Promise((resolve) => setTimeout(resolve, PAST_THE_PAUSE)); /** Open the panel, select the hook, and switch its guard into raw CEL mode. */ async function openGuard() { @@ -81,35 +89,41 @@ async function openGuard() { return screen.getAllByRole('combobox').find((el) => el.tagName === 'TEXTAREA') as HTMLTextAreaElement; } -describe('ObjectHooksPanel — its own Save refuses a guard that does not parse (#4527)', () => { - it('disables Save while the hook guard is malformed', async () => { - const box = await openGuard(); +describe('ObjectHooksPanel — its autosave holds a guard that does not parse (#4527, objectui#11787)', () => { + beforeEach(() => mockClient.save.mockClear()); - // A valid guard first: dirties the draft (so Save is live at all) and pins - // the must-not-change half — a good guard never blocks. - fireEvent.change(box, { target: { value: "record.status == 'open'" } }); - await waitFor(() => expect(saveButton()).toBeEnabled(), { timeout: 4000 }); + it('holds the malformed hook, and says why', async () => { + const box = await openGuard(); + // No Save button: the panel autosaves. + expect(screen.queryByRole('button', { name: /Save/i })).toBeNull(); fireEvent.change(box, { target: { value: 'record.status ==' } }); - await waitFor(() => expect(saveButton()).toBeDisabled(), { timeout: 4000 }); + expect(await screen.findByTestId('hooks-autosave-held', undefined, AUTOSAVE)).toBeInTheDocument(); + await pause(); + expect(mockClient.save).not.toHaveBeenCalled(); }); - it('re-enables Save once the guard parses again', async () => { + it('sends the hook once the guard parses again', async () => { const box = await openGuard(); fireEvent.change(box, { target: { value: 'record.status ==' } }); - await waitFor(() => expect(saveButton()).toBeDisabled(), { timeout: 4000 }); + await screen.findByTestId('hooks-autosave-held', undefined, AUTOSAVE); fireEvent.change(box, { target: { value: "record.status == 'open'" } }); - await waitFor(() => expect(saveButton()).toBeEnabled(), { timeout: 4000 }); + await waitFor(() => expect(mockClient.save).toHaveBeenCalledTimes(1), AUTOSAVE); + expect(mockClient.save).toHaveBeenCalledWith( + 'hook', + 'guard_hook', + expect.objectContaining({ object: 'invoice' }), + expect.objectContaining({ mode: 'draft', packageId: 'com.example.showcase' }), + ); + expect(screen.queryByTestId('hooks-autosave-held')).toBeNull(); }); - it('never writes the malformed hook', async () => { + it('CONTROL — a good guard is sent after the pause, never held', async () => { const box = await openGuard(); - fireEvent.change(box, { target: { value: 'record.status ==' } }); - await waitFor(() => expect(saveButton()).toBeDisabled(), { timeout: 4000 }); - - fireEvent.click(saveButton()); - expect(mockClient.save).not.toHaveBeenCalled(); + fireEvent.change(box, { target: { value: "record.status == 'open'" } }); + await waitFor(() => expect(mockClient.save).toHaveBeenCalledTimes(1), AUTOSAVE); + expect(screen.queryByTestId('hooks-autosave-held')).toBeNull(); }); }); diff --git a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.designerRegistryMissing.test.tsx b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.designerRegistryMissing.test.tsx index 8607fd9e83..6979f12624 100644 --- a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.designerRegistryMissing.test.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.designerRegistryMissing.test.tsx @@ -8,8 +8,8 @@ * undefined the panel falls back to the generic `SchemaForm`, which synthesises * a control per top-level key of the selected hook. That is a working editor, * not an empty state — so the pin has to prove it WORKS, not merely that - * something rendered: an edit in the generic form dirties the panel, enables - * its own Save, and the save writes the edited value through. + * something rendered: an edit in the generic form dirties the panel, and the + * panel's autosave (objectui#11787) writes the edited value through. * * "Something rendered" would pass on the hook's bare label — exactly the shape * of the `ObjectActionsPanel` defect the card repaired — which is why the @@ -91,15 +91,13 @@ describe('ObjectHooksPanel — no curated hook editor registered (#6846)', () => // state — it is an editor. expect(document.body.textContent ?? '').not.toMatch(/loading|try again/i); - // "Working": an edit in the generic form dirties the panel and enables - // its own Save… - const save = screen.getByRole('button', { name: /Save/i }); - expect(save).toBeDisabled(); + // "Working": an edit in the generic form dirties the panel, and its + // autosave (objectui#11787 — no Save button) writes the edited value + // through, as a draft of THIS hook, after the shared autosave's pause. + expect(screen.queryByRole('button', { name: /Save/i })).toBeNull(); + expect(mockClient.save).not.toHaveBeenCalled(); fireEvent.change(handler, { target: { value: 'guard_fn_v2' } }); - await waitFor(() => expect(save).toBeEnabled()); - // …and Save writes the edited value through, as a draft of THIS hook. - fireEvent.click(save); - await waitFor(() => expect(mockClient.save).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockClient.save).toHaveBeenCalledTimes(1), { timeout: 4000 }); expect(mockClient.save).toHaveBeenCalledWith( 'hook', 'guard_hook', diff --git a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx index dbf8a3335f..1fcc105610 100644 --- a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx @@ -16,10 +16,11 @@ * draft doesn't cover them. * * Master-detail, editable: a left list of the hooks that fire on this object, - * and on the right the platform's own generic metadata form (SchemaForm) — the - * same surface the metadata admin uses to edit a hook — with a per-hook Save. - * There's no curated `hook` inspector, so SchemaForm synthesises a structured - * form from the hook's shape. + * and on the right the hook's inspector (the platform's generic metadata form, + * SchemaForm, until the curated one registers). An edit autosaves to the + * hook's draft the way the other Studio editors do (objectui#11787): the shared + * autosave, 1.5s after the last edit, held while a CEL syntax error stands, and + * the panel's status line in place of the old Save hook button. * * A new hook targets the object it is created from (`addHook`). Its target * picker is told which objects belong to this package (objectui#11820), so it @@ -28,8 +29,8 @@ */ import React from 'react'; -import { Webhook, Plus, Loader2, Save } from 'lucide-react'; -import { toast } from 'sonner'; +import { Webhook, Plus, Loader2 } from 'lucide-react'; +import { useDisplayLocale } from '@object-ui/i18n'; import { SchemaForm } from '../metadata-admin/SchemaForm.js'; import { useRegisteredMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry.js'; import { HookTargetScopeContext, type HookTargetScope } from '../metadata-admin/inspectors/HookDefaultInspector.js'; @@ -37,6 +38,8 @@ import { loadPackageSurfaces } from './packageSurfaces.js'; import { useMetadataClient } from '../metadata-admin/useMetadata.js'; // objectui#11773 — the hook's draft save sends the version it was built on. import { useDraftSaveGuard } from '../metadata-admin/DraftConflictDialog.js'; +// objectui#11787 — a hook autosaves through the pillars' own autosave. +import { useDraftAutoSave, type DraftSend } from './useDraftAutoSave.js'; import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js'; // `formatMetadataError` is the one metadata-save error reader (objectui#11302). import { extractDraftBody, formatMetadataError } from '@object-ui/data-objectstack'; @@ -85,6 +88,7 @@ export function ObjectHooksPanel({ disabled, hookSchema, publishNonce = 0, + onDraftSaved, }: { objectName: string; packageId: string; @@ -100,6 +104,11 @@ export function ObjectHooksPanel({ * metadata contract rather than being synthesised from the value shape. */ hookSchema?: Record; + /** + * objectui#11787 — a hook draft landed (an edit, or "+ New"), so the + * surface's pending-changes count refreshes, as every other draft save does. + */ + onDraftSaved?: () => void; }) { const locale = useMetadataLocale(); const client = useMetadataClient(); @@ -112,13 +121,19 @@ export function ObjectHooksPanel({ const [error, setError] = React.useState(null); const [selected, setSelected] = React.useState(null); const [draft, setDraft] = React.useState(null); + // objectui#11787 — the hook whose body the buffer holds (`selected` when its + // install has run), and when its last draft save landed. + const [draftFor, setDraftFor] = React.useState(null); + const [savedAt, setSavedAt] = React.useState<{ hook: string; at: Date } | null>(null); + const displayLocale = useDisplayLocale(); const [dirty, setDirty] = React.useState(false); const [saving, setSaving] = React.useState(false); const [nonce, setNonce] = React.useState(0); // objectui#11773 — the version the open hook's draft was saved at, sent as - // `If-Match` by its next save. The list re-read that follows this panel's own - // save reads back what it wrote, so the version survives it; a conflict's - // "reload" re-reads the list, and a publish forgets the version. + // `If-Match` by its next save. A save writes the list in place (objectui#11787) + // and the re-read that follows "+ New" reads back what was written, so the + // version survives both; a conflict's "reload" re-reads the list, and a + // publish forgets the version. const reloadHooks = React.useCallback(() => setNonce((n) => n + 1), []); const { save: saveHookDraft, @@ -161,11 +176,11 @@ export function ObjectHooksPanel({ ? packageScope : null; - /* ─── Blocking CEL verdicts → this panel's OWN Save (objectui#4527) ──────── + /* ─── Blocking CEL verdicts → this panel's OWN save (objectui#4527) ──────── * * Unlike the object's other panels, this one writes the hook itself - * (`client.save('hook', …, { mode: 'draft' })`), so it owns the button that - * has to refuse. The count is STAMPED with the hook it describes and + * (`client.save('hook', …, { mode: 'draft' })`), so it owns the save that + * has to refuse: the autosave below is held while one stands (objectui#11787). The count is STAMPED with the hook it describes and * mismatch is read as 0, so a verdict that lands after the author switched * hooks cannot gate the one now on screen — derivation, not a reset effect, * and the selected hook always has its editor available to fix in. */ @@ -225,28 +240,72 @@ export function ObjectHooksPanel({ }; }, [client, packageId, objectName, nonce]); - // Load the selected hook into an editable draft (once per selection change). + // Load the selected hook into an editable draft: on a selection change, and + // when the list changes. objectui#11787 — except over an unsent edit of that + // same hook: the list an autosave writes holds what that save sent, and an + // edit the author took meanwhile is newer than it. + const bufferRef = React.useRef({ hook: draftFor, dirty }); + React.useLayoutEffect(() => { + bufferRef.current = { hook: draftFor, dirty }; + }); React.useEffect(() => { + if (selected !== null && bufferRef.current.hook === selected && bufferRef.current.dirty) return; const hook = hooks.find((h) => h.name === selected) ?? null; setDraft(hook ? { ...hook } : null); + setDraftFor(hook ? selected : null); setDirty(false); }, [selected, hooks]); - const save = React.useCallback(async () => { + const save = React.useCallback(async (sent: DraftSend) => { if (!draft?.name) return; setSaving(true); setError(null); try { - if ((await saveHookDraft('hook', String(draft.name), draft, { mode: 'draft', packageId })) === 'reloaded') return; - toast.success(tFormat('engine.studio.hooks.saved', locale, { label: String(draft.label || draft.name) })); - setDirty(false); - setNonce((n) => n + 1); + if ((await saveHookDraft('hook', String(draft.name), draft, { mode: 'draft', packageId })) === 'reloaded') { + // objectui#11773 — the author chose the saved version: the list re-read + // installs it over this buffer, which holds nothing of theirs to keep. + setDirty(false); + return; + } + // No success toast: with autosave it would fire after every editing + // pause; the status line is the affordance (objectui#11787). + // objectui#11204 — clean only if nothing was edited while it was in flight. + if (sent.unmoved()) setDirty(false); + setSavedAt({ hook: String(selected ?? ''), at: new Date() }); + onDraftSaved?.(); + // The list now holds what the draft holds. Written in place rather than + // read again: a re-read blanks the list to "Loading…" at every editing + // pause, and it would answer with this very body (a draft wins over the + // published row of its name, and a hook no longer aimed at this object + // leaves its list). + const sentBody = draft; + setHooks((list) => { + const kept = list.filter((h) => h.name !== sentBody.name); + if (!targetsObject(sentBody, objectName)) return kept; + const at = list.findIndex((h) => h.name === sentBody.name); + return at < 0 ? [...kept, sentBody] : list.map((h, i) => (i === at ? sentBody : h)); + }); } catch (e) { setError(formatMetadataError(e)); } finally { setSaving(false); } - }, [saveHookDraft, draft, packageId, locale]); + }, [saveHookDraft, draft, packageId, selected, onDraftSaved, objectName]); + + // objectui#11787 — a hook autosaves to its draft like the pillars' editors: + // the shared autosave, blocked where the Save hook button was disabled (a + // blocking CEL verdict holds the edit, objectui#4527) and while the package + // is read-only. The hook is the open item; the buffer is its own once the + // install above has run for it. + const hookTarget = `hook:${selected ?? ''}`; + useDraftAutoSave({ + target: hookTarget, + loadedFor: `hook:${draftFor ?? ''}`, + dirty, + blocked: !draft?.name || saving || !!disabled || blockingIssues > 0, + snapshot: draft, + save, + }); const addHook = React.useCallback(async () => { const name = nextHookName(objectName, hooks.map((h) => String(h.name ?? ''))); @@ -265,6 +324,8 @@ export function ObjectHooksPanel({ setError(null); try { await client.save('hook', name, fresh, { mode: 'draft', packageId }); + // objectui#11787 — a new hook is a new draft: the count says so. + onDraftSaved?.(); setNonce((n) => n + 1); setSelected(name); } catch (e) { @@ -272,7 +333,7 @@ export function ObjectHooksPanel({ } finally { setSaving(false); } - }, [client, objectName, hooks, packageId, locale]); + }, [client, objectName, hooks, packageId, locale, onDraftSaved]); return (
@@ -338,18 +399,26 @@ export function ObjectHooksPanel({ <>
{String(draft.label || draft.name)} - {!disabled && ( - - )} + {/* objectui#11787 — the status line every Studio editor shows in + place of a Save button: saving while in flight, the + last-saved time once landed, and why an edit is held when a + CEL syntax error holds it. */} + {disabled ? null : saving ? ( + + + {t('engine.studio.autoSaving', locale)} + + ) : blockingIssues > 0 && dirty ? ( + + {t('perm.cel.saveBlocked', locale)} + + ) : savedAt && savedAt.hook === selected && !dirty ? ( + + {tFormat('engine.studio.data.lastSaved', locale, { + time: savedAt.at.toLocaleTimeString(displayLocale, { hour: '2-digit', minute: '2-digit' }), + })} + + ) : null}
{HookInspector ? ( diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessGuard.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessGuard.test.tsx index 1e855d4b39..a56484397d 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessGuard.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessGuard.test.tsx @@ -298,7 +298,8 @@ describe('AccessPillar — unsaved OWD overview edits guard (#2600)', () => { fireEvent.change(within(dialog).getByPlaceholderText('Display name (e.g. Sales permissions)'), { target: { value: 'Set C' }, }); - fireEvent.click(within(dialog).getByRole('button', { name: 'Create' })); + // objectui#11787 — the create dialog's one label: it writes a draft. + fireEvent.click(within(dialog).getByRole('button', { name: t('engine.studio.createDraft', 'en-US') })); await findInMatrix('set_c'); expect(screen.queryByTestId('owd-overview')).not.toBeInTheDocument(); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.changesCount-11787.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.changesCount-11787.test.tsx new file mode 100644 index 0000000000..3dcaf89410 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.changesCount-11787.test.tsx @@ -0,0 +1,186 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11787 — the header's Changes count after a draft save. + * + * Found in a browser QA pass: after an autosave, an item showed its + * "Unpublished draft" chip while the header beside it still said "No drafts + * pending publish", until the count's read caught up. A draft save stamped with + * this package means at least one draft is pending, so from the render in which + * the save lands the header says so, and the read the save asks for is sent + * after it rather than joined to one already on the wire. + * + * Driven through a save every author reaches: the Access pillar's + * "New permission set", whose dialog also carries Studio's one create label. + * + * Real: the surface, its pillar, `useMetadataClient` and the client it builds, + * and `usePendingDrafts`. Stubbed: `globalThis.fetch` (the server; every + * `_drafts` read is held open until the test answers it), `packages-io`'s + * package list, `sonner`, and the dock and panels no assertion reads. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const PACKAGE_ID = 'app.a'; +const DRAFTS = `/api/v1/meta/_drafts?packageId=${PACKAGE_ID}`; + +vi.mock('sonner', () => ({ + toast: { + warning: vi.fn(), + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + message: vi.fn(), + dismiss: vi.fn(), + }, + Toaster: () => null, +})); + +vi.mock('./packages-io', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + fetchPackages: vi.fn(async () => [{ id: PACKAGE_ID, name: PACKAGE_ID, writable: true, namespace: 'a' }]), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => ({}) }; +}); + +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { t } from '../metadata-admin/i18n'; + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; +(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = + (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver ?? + class { + observe() {} + unobserve() {} + disconnect() {} + }; + +interface HeldRead { + respond: (drafts: Array>) => void; +} + +/** The `_drafts` reads, in the order they were sent, each held until answered. */ +const draftReads: HeldRead[] = []; +/** Draft saves the surface sent (`PUT …?mode=draft`). */ +const draftSaves: string[] = []; + +function json(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); +} + +beforeEach(() => { + draftReads.length = 0; + draftSaves.length = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: { method?: string }) => { + const url = String(input); + if (url === DRAFTS) { + return new Promise((resolve) => { + draftReads.push({ respond: (drafts) => resolve(json({ drafts })) }); + }); + } + if (init?.method === 'PUT' && url.includes('mode=draft')) { + draftSaves.push(url); + return json({}); + } + // Every other read (the pillar's lists, the type registry, the matrix's + // reads) answers empty — none of them is under test here. + return json([]); + }) as unknown as typeof fetch, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const NONE_PENDING = t('engine.studio.publishNoneTitle', 'en'); +const changesButton = () => screen.getByRole('button', { name: new RegExp(`^${t('engine.studio.changes', 'en')}`) }); + +async function drain() { + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); +} + +async function createSetThroughTheDialog() { + fireEvent.click(await screen.findByRole('button', { name: t('engine.studio.access.new', 'en') })); + const dialog = await screen.findByRole('dialog'); + fireEvent.change(within(dialog).getByPlaceholderText(t('engine.studio.access.labelPlaceholder', 'en')), { + target: { value: 'Set C' }, + }); + // Studio's one create label (objectui#11787): every create dialog writes a draft. + fireEvent.click(within(dialog).getByRole('button', { name: t('engine.studio.createDraft', 'en') })); +} + +describe('the Changes count after a draft save (objectui#11787)', () => { + it('says a draft is pending from the render the save lands in, and the read it asks for is its own', async () => { + render( + + + } /> + + , + ); + // The mount read is on the wire and unanswered. + await waitFor(() => expect(draftReads.length).toBeGreaterThan(0)); + await act(drain); + const beforeSave = draftReads.length; + + await act(async () => { + await createSetThroughTheDialog(); + }); + await waitFor(() => expect(draftSaves).toHaveLength(1)); + await act(drain); + + // The save landed: the header no longer says nothing is pending, and the + // count is at least the draft the save wrote — before any read answers. + expect(screen.queryByText(NONE_PENDING)).toBeNull(); + expect(changesButton()).toHaveTextContent('· 1'); + // The save sent a read of its own, not one joined to the read on the wire. + expect(draftReads.length).toBe(beforeSave + 1); + + // The reads sent before the save answer with the ledger as it was: ignored. + await act(async () => { + for (const read of draftReads.slice(0, beforeSave)) read.respond([]); + await drain(); + }); + expect(screen.queryByText(NONE_PENDING)).toBeNull(); + expect(changesButton()).toHaveTextContent('· 1'); + + // The read sent after the save answers with the server's count. + await act(async () => { + draftReads[beforeSave].respond([ + { type: 'permission', name: 'set_c', packageId: PACKAGE_ID }, + { type: 'object', name: 'a_account', packageId: PACKAGE_ID }, + ]); + await drain(); + }); + await waitFor(() => expect(changesButton()).toHaveTextContent('· 2')); + expect(screen.queryByText(NONE_PENDING)).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index b760c912e7..9b725ed0fb 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -1111,7 +1111,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React const [changesOpen, setChangesOpen] = React.useState(false); const [publishing, setPublishing] = React.useState(false); const [publishNonce, setPublishNonce] = React.useState(0); // ↑ → pillars re-read the published baseline - const [draftNonce, setDraftNonce] = React.useState(0); // ↑ → refresh the pending-draft count + const [draftNonce, setDraftNonce] = React.useState(0); // ↑ → a draft save landed (`onDraftSaved`) // objectui#5801 — the shared pending-drafts source: same fetch, same count, // and the assistant bus's metadata-refresh pulse keeps this topbar in step @@ -1125,13 +1125,20 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React count: feedCount, entries: pendingEntries, refresh: refreshPending, + behindSave, } = usePendingDrafts({ packageId }); const pendingCount = packageId !== null ? feedCount : feedCount === null ? null : pendingEntries.filter(isOrgScopeDraft).length; + // A draft save asks for its own read (`onDraftSaved` below, objectui#11787); + // this one serves the mount and a publish. React.useEffect(() => { void refreshPending(); - }, [refreshPending, publishNonce, draftNonce]); + }, [refreshPending, publishNonce]); + const refreshPendingRef = React.useRef(refreshPending); + React.useLayoutEffect(() => { + refreshPendingRef.current = refreshPending; + }); // objectui#11786 — what the open pillar's autosave holds unsent (an // incomplete-but-normal edit), as the clause naming what it needs. A publish @@ -1278,8 +1285,19 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React await refreshPending(); }, [shellClient, packageId, refreshPending, locale, heldEdit]); - const onDraftSaved = React.useCallback(() => setDraftNonce((n) => n + 1), []); - const hasPending = (pendingCount ?? 0) > 0; + // objectui#11787 — a Studio draft save reports here when it lands, and the + // count follows it: the read it asks for is sent after the save, never + // answered by a read already on the wire, and until that read answers the + // count is at least the one draft the save wrote (`behindSave`). So the + // header stops saying "No drafts pending publish" in the same render that + // shows an item's "Unpublished draft" chip, both being set by the save that + // landed. Pinned by `StudioDesignSurface.changesCount-11787.test.tsx`. + const onDraftSaved = React.useCallback(() => { + setDraftNonce((n) => n + 1); + void refreshPendingRef.current({ afterSave: true }); + }, []); + const shownPendingCount = behindSave ? Math.max(pendingCount ?? 0, 1) : pendingCount; + const hasPending = (shownPendingCount ?? 0) > 0; const publishNoneReasonId = React.useId(); // Builder → running-app bridge (Airtable's Launch): the builder edits the @@ -1347,14 +1365,14 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React toast.success(tFormat('engine.studio.app.savedDraft', locale, { label })); setAppDraftPending(label); setAppCreating(false); - setDraftNonce((n) => n + 1); + onDraftSaved(); } catch (e) { setAppErr(formatMetadataError(e)); } finally { setAppBusy(false); } }, - [appAddObjects, loadPackageObjects, shellClient, packageId, locale], + [appAddObjects, loadPackageObjects, shellClient, packageId, locale, onDraftSaved], ); // objectui#5800, fixed in passing — the topbar's app detection used to @@ -1584,7 +1602,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground" > - {t('engine.studio.changes', locale)}{hasPending ? ` · ${pendingCount}` : ''} + {t('engine.studio.changes', locale)}{hasPending ? ` · ${shownPendingCount}` : ''} {/* objectui#8219 — with nothing to publish, say so on the page rather than only in the hover tooltip. */} @@ -5156,6 +5174,7 @@ export function DataPillar({ disabled={readOnly} hookSchema={typeSchemas.hook} publishNonce={publishNonce} + onDraftSaved={onDraftSaved} /> ) : viewMode === 'actions' ? ( Date: Fri, 9 Oct 2026 02:50:04 +0000 Subject: [PATCH 3/4] test(app-shell): type the matrix autosave pin's fake server instead of casting to any (objectui#11787) Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- ...issionMatrixEditor.autosave-11787.test.tsx | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.autosave-11787.test.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.autosave-11787.test.tsx index b8d4e4fa0b..f54a0336aa 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.autosave-11787.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.autosave-11787.test.tsx @@ -30,8 +30,13 @@ const PUBLISHED = { const LAYERED = { effective: PUBLISHED, code: null, overlay: null, overlayScope: null }; +/** A saved permission set, as far as these pins read it. */ +interface SavedSet { + objects: Record>; +} + interface Server { - saved: Array>; + saved: SavedSet[]; savedOpts: Array | undefined>; } @@ -44,7 +49,10 @@ function deferred() { return { promise, resolve }; } -function makeClient(server: Server, opts: { holdFirstSave?: Promise } = {}) { +/** The slice of the metadata client the editor calls here. */ +type FakeClient = Record Promise>; + +function makeClient(server: Server, opts: { holdFirstSave?: Promise } = {}): FakeClient { return { layered: async () => LAYERED, getDraft: async () => null, @@ -56,15 +64,15 @@ function makeClient(server: Server, opts: { holdFirstSave?: Promise } = {} payload: Record, saveOpts?: Record, ) => { - server.saved.push(JSON.parse(JSON.stringify(payload))); + server.saved.push(JSON.parse(JSON.stringify(payload)) as SavedSet); server.savedOpts.push(saveOpts); if (server.saved.length === 1 && opts.holdFirstSave) await opts.holdFirstSave; return payload; }, - } as any; + }; } -let clientImpl: any; +let clientImpl: FakeClient; vi.mock('./useMetadata', () => ({ useMetadataClient: () => clientImpl, @@ -117,7 +125,7 @@ describe('the package door autosaves to the package draft (objectui#11787)', () expect(server.saved).toHaveLength(0); await waitFor(() => expect(server.saved).toHaveLength(1), AUTOSAVE); - expect((server.saved[0] as any).objects.a_account).toEqual({}); + expect(server.saved[0].objects.a_account).toEqual({}); expect(server.savedOpts[0]).toMatchObject({ mode: 'draft', packageId: 'app.a' }); // The surface's pending-changes count hears of it. await waitFor(() => expect(onDraftSaved).toHaveBeenCalledTimes(1)); @@ -146,8 +154,8 @@ describe('the package door autosaves to the package draft (objectui#11787)', () expect(readBox()).toBeChecked(); // …and the edit, still unsent, goes out next. await waitFor(() => expect(server.saved).toHaveLength(2), AUTOSAVE); - expect((server.saved[0] as any).objects.a_account).toEqual({}); - expect((server.saved[1] as any).objects.a_account).toEqual({ allowRead: true }); + expect(server.saved[0].objects.a_account).toEqual({}); + expect(server.saved[1].objects.a_account).toEqual({ allowRead: true }); }); it('fixes the api name: it is the draft’s identity, set by "+ New"', async () => { @@ -179,7 +187,7 @@ describe('the environment door keeps its explicit Save: live config never autosa // CONTROL: the edit is real, and the door's own Save writes it, live. fireEvent.click(saveButton()!); await waitFor(() => expect(server.saved).toHaveLength(1)); - expect((server.saved[0] as any).objects.a_account).toEqual({}); + expect(server.saved[0].objects.a_account).toEqual({}); expect(server.savedOpts[0]).toEqual({ force: false }); expect(onDraftSaved).not.toHaveBeenCalled(); }); From 5e5adb69d99365af4e4ffafdb8b10d640d12618b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:45:18 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(app-shell):=20a=20hook=20keeps=20its=20?= =?UTF-8?q?name=20=E2=80=94=20the=20autosave=20holds=20a=20renamed=20hook?= =?UTF-8?q?=20instead=20of=20staging=20one=20per=20pause=20(objectui#11787?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook inspector commits the name on every keystroke and the panel stores the draft under that name, so an autosaved rename staged one more hook draft per pause while typing (probe on bfe6edc13: hook/audit and hook/audit_trail beside guard_hook). As with the package door's permission-set api name, the name is set once by "+ New": while the buffer's name differs from the hook it holds, the autosave is held and a line says to change it back. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11787-one-save-model.md | 2 +- .../src/views/metadata-admin/i18n.ts | 2 ++ .../ObjectHooksPanel.autosave-11787.test.tsx | 36 +++++++++++++++++-- .../views/studio-design/ObjectHooksPanel.tsx | 27 +++++++++++--- 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/.changeset/11787-one-save-model.md b/.changeset/11787-one-save-model.md index f7c0a91035..9f809b92f7 100644 --- a/.changeset/11787-one-save-model.md +++ b/.changeset/11787-one-save-model.md @@ -6,7 +6,7 @@ Studio saves one way on a package: the permission matrix and hooks autosave to t - **Permission matrix, package door.** An edit is saved to the package's draft 1.5 seconds after the last change, the same autosave the Data and Interfaces pillars run, and the header shows *Saving…* and then *Saved* with the time in place of the Save button. A CEL syntax error in the row filter holds the edit and says why. An edit made while a save is in flight is kept and sent next. The set's API name can no longer be edited here, because the autosave stores the draft under that name. It is set when the set is created. - **Permission matrix, environment-admin door.** Unchanged. It keeps its explicit Save, because it writes live access configuration (ADR-0086 D7). -- **Hooks.** A hook autosaves to its draft the same way, and the *Save hook* button is gone. A hook whose run condition does not parse is held until it does. Creating a hook and saving one both update the Changes count. +- **Hooks.** A hook autosaves to its draft the same way, and the *Save hook* button is gone. A hook whose run condition does not parse is held until it does. A hook keeps the name it was created with: while its name is changed the autosave holds, and a line says to change it back. The draft is stored under the hook's name, so a rename is not a save. Creating a hook and saving one both update the Changes count. - **Create dialogs.** *New permission set* now says *Save as draft*, like every other Studio create dialog. - **Changes count.** After a draft save, the header no longer says "No drafts pending publish" next to an item's "Unpublished draft" chip. It counts at least the draft just saved until its next read answers, and that read is sent after the save instead of reusing a read that was already in flight. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index d8b94c573c..4913c949ff 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -3369,6 +3369,7 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.api.body': 'Example body', 'engine.studio.api.bodyOmitted': 'Required fields come first. Optional fields not shown: {fields}', // Hooks view + 'engine.studio.hooks.renameHeld': 'Not saved yet: a hook keeps the name it was created with. Change the name back to {name} to save your changes.', 'engine.studio.hooks.none': 'No hooks target this object.', 'engine.studio.hooks.async': 'async', 'engine.studio.hooks.pick': 'Select a hook to edit it.', @@ -6510,6 +6511,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.api.body': '示例请求体', 'engine.studio.api.bodyOmitted': '必填字段排在最前。未列出的可选字段:{fields}', // Hooks view + 'engine.studio.hooks.renameHeld': '尚未保存:钩子会保留创建时的名称。把名称改回 {name} 即可保存更改。', 'engine.studio.hooks.none': '没有钩子作用于该对象。', 'engine.studio.hooks.async': '异步', 'engine.studio.hooks.pick': '选择一个钩子进行编辑。', diff --git a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.autosave-11787.test.tsx b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.autosave-11787.test.tsx index 0157b28a0b..315e6d7638 100644 --- a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.autosave-11787.test.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.autosave-11787.test.tsx @@ -9,14 +9,17 @@ * shared autosave: an edit is sent 1.5s after the last change, a save that * lands reports to the surface (`onDraftSaved`) so the header's count * refreshes, and an edit taken while a save is in flight is kept and sent next - * (the objectui#11204 rule every pillar keeps). The held half — a blocking CEL + * (the objectui#11204 rule every pillar keeps). A hook keeps the name it was + * created with: the inspector commits the name on every keystroke and the draft + * is stored under it, so a rename is held rather than staged as one more hook + * per pause while typing. The held half — a blocking CEL * verdict holds the autosave — is pinned beside it in * `ObjectHooksPanel.celGate.test.tsx`. */ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; -import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, cleanup, waitFor, act } from '@testing-library/react'; const hook = { name: 'guard_hook', @@ -75,6 +78,9 @@ afterEach(() => { }); const AUTOSAVE = { timeout: 4000 }; +const pause = (ms: number) => act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); +}); async function openGuard(onDraftSaved?: () => void) { render(); @@ -114,6 +120,32 @@ describe('ObjectHooksPanel — a hook autosaves to its draft (objectui#11787)', await waitFor(() => expect(onDraftSaved).toHaveBeenCalledTimes(1)); }); + it('holds a rename typed with a pause between its parts, and saves once the name is back', async () => { + render(); + fireEvent.click(await screen.findByText('Guard')); + const name = () => screen.getByTestId('hook-name') as HTMLInputElement; + await screen.findByTestId('hook-name'); + expect(name().value).toBe('guard_hook'); + + // Typed in two parts, with a pause longer than the autosave's between them. + fireEvent.change(name(), { target: { value: 'audit' } }); + await pause(1600); + fireEvent.change(name(), { target: { value: 'audit_trail' } }); + await pause(2300); + + // No draft staged under either typed name: the probe on the head before + // this pin measured two, `hook/audit` and `hook/audit_trail`. + expect(mockClient.save).not.toHaveBeenCalled(); + expect(screen.getByTestId('hooks-rename-held')).toHaveTextContent('guard_hook'); + + // CONTROL: the same edit path saves once the hook has its own name again, + // under that name. + fireEvent.change(name(), { target: { value: 'guard_hook' } }); + await waitFor(() => expect(mockClient.save).toHaveBeenCalledTimes(1), AUTOSAVE); + expect(mockClient.save.mock.calls[0][1]).toBe('guard_hook'); + expect(screen.queryByTestId('hooks-rename-held')).toBeNull(); + }); + it('keeps an edit taken while the autosave is in flight, and sends it next (objectui#11204)', async () => { let release!: () => void; holdFirstSave = new Promise((resolve) => { diff --git a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx index 1fcc105610..bcc852a7cb 100644 --- a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.tsx @@ -19,8 +19,9 @@ * and on the right the hook's inspector (the platform's generic metadata form, * SchemaForm, until the curated one registers). An edit autosaves to the * hook's draft the way the other Studio editors do (objectui#11787): the shared - * autosave, 1.5s after the last edit, held while a CEL syntax error stands, and - * the panel's status line in place of the old Save hook button. + * autosave, 1.5s after the last edit, held while a CEL syntax error stands or + * while the hook's name differs from the one it was created with, and the + * panel's status line in place of the old Save hook button. * * A new hook targets the object it is created from (`addHook`). Its target * picker is told which objects belong to this package (objectui#11820), so it @@ -298,11 +299,20 @@ export function ObjectHooksPanel({ // is read-only. The hook is the open item; the buffer is its own once the // install above has run for it. const hookTarget = `hook:${selected ?? ''}`; + // objectui#11787 — a hook keeps the name it was created with ("+ New" sets + // it). The draft is stored under its name, and the inspector commits the name + // on every keystroke, so a rename the autosave sent would stage one more hook + // per pause while typing (measured: two drafts, each under a partial name, + // beside the original). The same rule the package door's permission matrix + // keeps for its api name: a renamed buffer is held, and the line below says + // how to get the edit saved. + const renamedFrom = + draft !== null && draftFor !== null && String(draft.name ?? '') !== draftFor ? draftFor : null; useDraftAutoSave({ target: hookTarget, loadedFor: `hook:${draftFor ?? ''}`, dirty, - blocked: !draft?.name || saving || !!disabled || blockingIssues > 0, + blocked: !draft?.name || saving || !!disabled || blockingIssues > 0 || renamedFrom !== null, snapshot: draft, save, }); @@ -408,7 +418,7 @@ export function ObjectHooksPanel({ {t('engine.studio.autoSaving', locale)} - ) : blockingIssues > 0 && dirty ? ( + ) : renamedFrom !== null ? null : blockingIssues > 0 && dirty ? ( {t('perm.cel.saveBlocked', locale)} @@ -420,6 +430,15 @@ export function ObjectHooksPanel({ ) : null}
+ {renamedFrom !== null && !disabled && ( +

+ {tFormat('engine.studio.hooks.renameHeld', locale, { name: renamedFrom })} +

+ )}
{HookInspector ? (