From 651797b4fb9a415d210fc95dd7cc88a1cdacf22f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:22:56 +0000 Subject: [PATCH 1/3] fix(data-objectstack,app-shell): Publish all publishes each bundle member of a package-less draft by its own ?member= (objectui#12402) MetadataClient.publishDraft takes an optional member, sent as ?member= only when given; usePublishAllDrafts keeps each _drafts row's member and passes it on the package-less path. Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- .../src/preview/usePublishAllDrafts.ts | 13 +++++++++++- .../data-objectstack/src/metadata-client.ts | 21 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/src/preview/usePublishAllDrafts.ts b/packages/app-shell/src/preview/usePublishAllDrafts.ts index b847395169..fec8eed29b 100644 --- a/packages/app-shell/src/preview/usePublishAllDrafts.ts +++ b/packages/app-shell/src/preview/usePublishAllDrafts.ts @@ -56,6 +56,13 @@ export function usePublishAllDrafts(t: TranslateFn) { type: d.type as string, name: d.name as string, packageId: typeof d.packageId === 'string' && d.packageId ? (d.packageId as string) : null, + // objectui#12402 — the row's bundle member, as `_drafts` served it + // (`ListDraftsResponseSchema.drafts[].member`, present only for a + // type whose identity is a pair, today `email_template`). Two + // languages of one template are two rows with one `type` and + // `name`; only `member` tells them apart. Kept when it is a + // non-empty string, absent otherwise. + ...(typeof d.member === 'string' && d.member ? { member: d.member as string } : {}), })); if (pending.length === 0) { toast.info(t('home.pendingDrafts.nothing', { defaultValue: 'Nothing to publish.' })); @@ -112,7 +119,11 @@ export function usePublishAllDrafts(t: TranslateFn) { ...orphans.filter((d) => d.type === 'seed'), ]; for (const d of ordered) { - const res = await client.publishDraft(d.type, d.name); + // A member row publishes THAT member (`?member=`); a by-name publish + // naming none promotes the canonical member's draft instead. A row + // without one sends no selector — the door refuses it on a type with + // no members. + const res = await client.publishDraft(d.type, d.name, d.member ? { member: d.member } : {}); const seedApplied = (res as any)?.seedApplied; if (seedApplied && seedApplied.success === false) { seedProblems.push(seedApplied.error ?? `${d.name}: sample data failed to load`); diff --git a/packages/data-objectstack/src/metadata-client.ts b/packages/data-objectstack/src/metadata-client.ts index cc2e057804..5a8641c649 100644 --- a/packages/data-objectstack/src/metadata-client.ts +++ b/packages/data-objectstack/src/metadata-client.ts @@ -1399,15 +1399,34 @@ export class MetadataClient { * {@link listDrafts} reads `data?.data?.drafts` because `GET /meta/_drafts` * IS a dispatcher route, and `publishHealthFromResponse` unwraps because the * batch door really is enveloped. The rule is per-route, not per-file. + * + * ## The bundle member (objectui#12402) + * + * Pass `member` to publish one member of a type whose identity is a pair + * (today only `email_template`, `(name, locale)`), sent as `?member=` — the + * selector the by-name doors read since objectstack `038d4c958`, and the + * value a `GET /meta/_drafts` row carries as `member`. A publish that names + * none promotes the CANONICAL member's draft, so two languages of one + * template drafted outside a package cannot both be published by name + * without it. The parameter is OMITTED, never sent empty, when the caller + * has no member: the door refuses a blank selector, and refuses any + * selector on a type with no members. */ async publishDraft( type: string, name: string, + options: { member?: string } = {}, ): Promise<{ success?: boolean; seedApplied?: { success: boolean; inserted?: number; updated?: number; error?: string; errors?: unknown[] }; } & Record> { - const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`; + // Same param-building shape as `reset()`, same wire spelling as the + // by-name reads in `DraftChangesPanel` (`member=`): present only when the + // caller has a member, absent otherwise. + const params: string[] = []; + if (options.member) params.push(`member=${encodeURIComponent(options.member)}`); + const qs = params.length ? `?${params.join('&')}` : ''; + const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish${qs}`; const res = await this.sendWrite(url, { method: 'POST', headers: { ...this.headers, 'Content-Type': 'application/json', Accept: 'application/json' }, From 64b0b9f9491a78cd0a694de727c1039c4017b950 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:23:53 +0000 Subject: [PATCH 2/3] test(data-objectstack,app-shell): pin ?member= on publishDraft and on Publish all's package-less path (objectui#12402) Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- ...blishAllDrafts.bundleMember-12402.test.tsx | 143 ++++++++++++++++++ ...ta-client.publishDraftMember-12402.test.ts | 91 +++++++++++ 2 files changed, 234 insertions(+) create mode 100644 packages/app-shell/src/preview/__tests__/usePublishAllDrafts.bundleMember-12402.test.tsx create mode 100644 packages/data-objectstack/src/metadata-client.publishDraftMember-12402.test.ts diff --git a/packages/app-shell/src/preview/__tests__/usePublishAllDrafts.bundleMember-12402.test.tsx b/packages/app-shell/src/preview/__tests__/usePublishAllDrafts.bundleMember-12402.test.tsx new file mode 100644 index 0000000000..a436cc006f --- /dev/null +++ b/packages/app-shell/src/preview/__tests__/usePublishAllDrafts.bundleMember-12402.test.tsx @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12402 — *Publish all* publishes each bundle member of a + * package-less draft by its own `?member=`. + * + * ## The defect + * + * `GET /meta/_drafts` serves `member` on each draft of a type whose identity + * is a pair (today `email_template`, `(name, locale)`). Two languages of one + * template drafted outside a package are two rows with one `type` and `name`. + * The hook mapped each row to `{ type, name, packageId }`, dropping `member`, + * and published package-less rows by type and name only — so the by-name door + * promoted the canonical member twice and the other language never. + * + * ## Real vs stubbed + * + * Real: the hook, `useMetadataClient` and the `MetadataClient` it builds. + * Stubbed: `sonner` (the terminal sink) and `globalThis.fetch` (the server). + * The drafts are `email_template` and `flow`, types the capability lint does + * not read, so no lint request is in the way. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import type { TranslateFn } from '@object-ui/i18n'; + +vi.mock('sonner', () => ({ + toast: { + warning: vi.fn(), + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + message: vi.fn(), + dismiss: vi.fn(), + }, +})); + +import { toast } from 'sonner'; +import { usePublishAllDrafts } from '../usePublishAllDrafts'; + +const PKG_ID = 'com.acme.crm'; + +const t: TranslateFn = (key, options) => + options?.count === undefined ? key : `${key}:${String(options.count)}`; + +let drafts: unknown[] = []; +/** Every publish request the hook sent, in order. */ +const posts: string[] = []; + +function json(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + posts.length = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const url = String(input); + if (url.endsWith('/publish-drafts')) { + posts.push(url); + return json({ + success: true, + data: { success: true, outcome: 'published', publishedCount: 1, failedCount: 0, published: [], failed: [] }, + }); + } + if (/\/publish(\?|$)/.test(url)) { + posts.push(url); + return json({ success: true, version: 'sha256:0', seq: 1, message: 'ok' }); + } + if (url.includes('/meta/_drafts')) return json({ drafts }); + return json({ success: true, data: {} }); + }) as unknown as typeof fetch, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +async function publishAll() { + const { result } = renderHook(() => usePublishAllDrafts(t)); + let outcome: Awaited> | undefined; + await act(async () => { + outcome = await result.current.publishAll(); + }); + return outcome; +} + +/** The path and query of a publish URL, without the origin and API base. */ +const tail = (url: string) => url.slice(url.indexOf('/meta/') >= 0 ? url.indexOf('/meta/') : url.indexOf('/packages/')); + +describe('Publish all addresses each bundle member of a package-less draft (objectui#12402)', () => { + it('two package-less members of one email_template publish with their own ?member=', async () => { + drafts = [ + { type: 'email_template', name: 'welcome', member: 'en-US', packageId: null }, + { type: 'email_template', name: 'welcome', member: 'zh-CN', packageId: null }, + ]; + const outcome = await publishAll(); + + expect(outcome).toEqual({ ok: true, attempted: 2 }); + expect(posts.map(tail)).toEqual([ + '/meta/email_template/welcome/publish?member=en-US', + '/meta/email_template/welcome/publish?member=zh-CN', + ]); + expect(vi.mocked(toast.error)).not.toHaveBeenCalled(); + }); + + it('a member-less draft sends no selector, and an empty member is not sent', async () => { + drafts = [ + { type: 'flow', name: 'nightly_digest', packageId: null }, + { type: 'email_template', name: 'welcome', member: '', packageId: null }, + ]; + await publishAll(); + + expect(posts.map(tail)).toEqual([ + '/meta/flow/nightly_digest/publish', + '/meta/email_template/welcome/publish', + ]); + expect(posts.some((u) => u.includes('member='))).toBe(false); + }); + + it('package-bound drafts are unchanged: one batch publish, no by-name publish', async () => { + drafts = [ + { type: 'email_template', name: 'welcome', member: 'en-US', packageId: PKG_ID }, + { type: 'email_template', name: 'welcome', member: 'zh-CN', packageId: PKG_ID }, + { type: 'email_template', name: 'reminder', member: 'zh-CN', packageId: null }, + ]; + const outcome = await publishAll(); + + expect(outcome).toEqual({ ok: true, attempted: 3 }); + expect(posts.map(tail)).toEqual([ + `/packages/${PKG_ID}/publish-drafts`, + '/meta/email_template/reminder/publish?member=zh-CN', + ]); + }); +}); diff --git a/packages/data-objectstack/src/metadata-client.publishDraftMember-12402.test.ts b/packages/data-objectstack/src/metadata-client.publishDraftMember-12402.test.ts new file mode 100644 index 0000000000..94d4dbdca6 --- /dev/null +++ b/packages/data-objectstack/src/metadata-client.publishDraftMember-12402.test.ts @@ -0,0 +1,91 @@ +/** + * 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. + */ + +/** + * `publishDraft()` names the bundle member it publishes — objectui#12402, the + * publish half of objectui#12271. + * + * Since objectstack `038d4c958` the by-name publish door + * (`POST /api/v1/meta/TYPE/NAME/publish`) reads `?member=`: one language of an + * `email_template` is one member, and a publish that names none promotes the + * CANONICAL member's draft. `GET /meta/_drafts` serves each draft's `member` + * (`ListDraftsResponseSchema.drafts[].member`), and `publishDraft` had no way + * to send it back. + * + * ## What is pinned + * + * - given a member -> `?member=VALUE`, encoded as the by-name reads encode it; + * - no member -> the parameter is ABSENT, never sent empty (the door + * refuses a blank selector, and any selector on a member-less type); + * - the two-argument call every existing caller makes sends exactly the URL + * it sent before this card. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { MetadataClient } from './metadata-client'; + +function record() { + const seen: { url: string; method?: string }[] = []; + const client = new MetadataClient({ + baseUrl: 'http://localhost:3000', + fetch: vi.fn(async (url: string, init?: RequestInit) => { + seen.push({ url, method: init?.method }); + return new Response(JSON.stringify({ success: true, version: 'sha256:0', seq: 1, message: 'ok' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as unknown as typeof fetch, + }); + return { seen, client }; +} + +function queryOf(url: string): URLSearchParams { + const q = url.indexOf('?'); + return new URLSearchParams(q === -1 ? '' : url.slice(q + 1)); +} + +describe('MetadataClient.publishDraft — the bundle member selector (objectui#12402)', () => { + it('sends ?member=VALUE when the caller names a member', async () => { + const { seen, client } = record(); + await client.publishDraft('email_template', 'welcome', { member: 'zh-CN' }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.method).toBe('POST'); + expect(seen[0]?.url).toBe('http://localhost:3000/api/v1/meta/email_template/welcome/publish?member=zh-CN'); + }); + + it('two members of one name publish to two different URLs', async () => { + const { seen, client } = record(); + await client.publishDraft('email_template', 'welcome', { member: 'en-US' }); + await client.publishDraft('email_template', 'welcome', { member: 'zh-CN' }); + + expect(seen.map((s) => queryOf(s.url).get('member'))).toEqual(['en-US', 'zh-CN']); + }); + + it('omits the parameter when there is no member: absent, not empty', async () => { + for (const options of [undefined, {}, { member: '' }, { member: undefined }]) { + const { seen, client } = record(); + await client.publishDraft('email_template', 'welcome', options); + expect(seen[0]?.url).toBe('http://localhost:3000/api/v1/meta/email_template/welcome/publish'); + expect(queryOf(seen[0]!.url).has('member')).toBe(false); + } + }); + + it('keeps the two-argument call byte for byte', async () => { + const { seen, client } = record(); + await client.publishDraft('view', 'cases'); + expect(seen[0]?.url).toBe('http://localhost:3000/api/v1/meta/view/cases/publish'); + }); + + it('encodes the member as a query value', async () => { + const { seen, client } = record(); + await client.publishDraft('email_template', 'welcome', { member: 'x&y z' }); + expect(seen[0]?.url.endsWith('/publish?member=x%26y%20z')).toBe(true); + expect(queryOf(seen[0]!.url).get('member')).toBe('x&y z'); + }); +}); From 062328bf65da8b843c7f5b91d4d1bb8104bfee01 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:29:36 +0000 Subject: [PATCH 3/3] chore(changeset): publishDraft member and Publish all (objectui#12402) Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- .changeset/12402-publish-all-member.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/12402-publish-all-member.md diff --git a/.changeset/12402-publish-all-member.md b/.changeset/12402-publish-all-member.md new file mode 100644 index 0000000000..a618561612 --- /dev/null +++ b/.changeset/12402-publish-all-member.md @@ -0,0 +1,12 @@ +--- +'@object-ui/data-objectstack': minor +'@object-ui/app-shell': patch +--- + +"Publish all" publishes each language of a package-less email template by its own bundle member (objectui#12402, the publish half of objectui#12271). + +Since `@objectstack/spec` 18.0.0-next.1 each `GET /meta/_drafts` row carries `member` for a type whose identity is a pair (today only `email_template`, `(name, locale)`), and the by-name publish door reads it back as `?member=`. A publish that names no member promotes the canonical member's draft, so two languages of one template drafted outside a package were published as the canonical one twice. + +- **`MetadataClient.publishDraft(type, name, options?)`** takes an optional `{ member }` and sends `?member=` when it is set. Without one the parameter is omitted, never sent empty. This widens the method's accepted arguments; a two-argument call sends exactly the request it sent before. +- **`usePublishAllDrafts`** (Home's "Publish all" and the draft-preview bar) keeps each draft row's `member` and passes it on the package-less path. Rows without a member send no selector. +- **Unchanged:** package-bound drafts still publish through `publishPackageDrafts(packageId)`.