Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/12402-publish-all-member.md
Original file line number Diff line number Diff line change
@@ -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)`.
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof result.current.publishAll>> | 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',
]);
});
});
13 changes: 12 additions & 1 deletion packages/app-shell/src/preview/usePublishAllDrafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' }));
Expand Down Expand Up @@ -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`);
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
21 changes: 20 additions & 1 deletion packages/data-objectstack/src/metadata-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>> {
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' },
Expand Down
Loading