diff --git a/.changeset/12271-draft-member.md b/.changeset/12271-draft-member.md
new file mode 100644
index 0000000000..c127613474
--- /dev/null
+++ b/.changeset/12271-draft-member.md
@@ -0,0 +1,5 @@
+---
+'@object-ui/app-shell': patch
+---
+
+Studio stops conflating two members of one bundle. The pending-changes sheet reads each draft's `member` off `GET /meta/_drafts` (objectstack#23073) and lists two languages of one `email_template` as two rows, each reading, diffing and classifying its own member through the by-name `/meta` doors' `?member=`; a draft without `member` sends no selector, as before. The build history names the member of each touched item that carries one.
diff --git a/packages/app-shell/src/preview/CommitTimeline.tsx b/packages/app-shell/src/preview/CommitTimeline.tsx
index 1de73bac07..e30a7fcb07 100644
--- a/packages/app-shell/src/preview/CommitTimeline.tsx
+++ b/packages/app-shell/src/preview/CommitTimeline.tsx
@@ -194,6 +194,21 @@ export function CommitTimeline({ open, onOpenChange, packageId, onReverted }: Co
{c.actor ? · {c.actor} : null}
{c.createdAt ? · {relativeTime(c.createdAt)} : null}
+ {c.memberItems ? (
+ // objectstack#23073 — the bundle member of each touched
+ // item that has one, so two languages of one template read
+ // as two items. A name-only item adds nothing here.
+
+ {c.memberItems.map((it) => (
+
+ {it.type}/{it.name} · {it.member}
+
+ ))}
+
+ ) : null}
{c.operation === 'apply' ? (
): string {
+ return JSON.stringify([entry.type, entry.name, entry.member ?? null]);
+}
+
/**
* Names that exist in the PUBLISHED world for a type — the plain (no
* `preview=draft`) list. One request classifies every draft of that type:
@@ -192,12 +211,16 @@ function unwrapItem(payload: unknown): Record | null {
async function fetchItemBody(
type: string,
name: string,
- opts: { draft?: boolean; packageId?: string | null } = {},
+ opts: { draft?: boolean; packageId?: string | null; member?: string } = {},
): Promise | null> {
// `type` is already the canonical singular — folded in `listPendingDrafts`.
const params: string[] = [];
if (opts.draft) params.push('state=draft');
if (opts.packageId) params.push(`package=${encodeURIComponent(opts.packageId)}`);
+ // The by-name doors' bundle-member selector (objectstack `038d4c958`). Sent
+ // only for an entry `_drafts` served a member for: the doors refuse it on a
+ // type with no members, and refuse a blank one.
+ if (opts.member) params.push(`member=${encodeURIComponent(opts.member)}`);
const qs = params.length ? `?${params.join('&')}` : '';
const res = await fetch(
`/api/v1/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`,
@@ -322,8 +345,12 @@ function EntryDetail({
try {
const [published, draft] = await Promise.all([
// A NEW item 404s on the published read — that's data, not an error.
- fetchItemBody(entry.type, entry.name, { packageId: entry.packageId }),
- fetchItemBody(entry.type, entry.name, { draft: true, packageId: entry.packageId }),
+ fetchItemBody(entry.type, entry.name, { packageId: entry.packageId, member: entry.member }),
+ fetchItemBody(entry.type, entry.name, {
+ draft: true,
+ packageId: entry.packageId,
+ member: entry.member,
+ }),
]);
if (cancelled) return;
const computed = computeChangeDetail(published, draft);
@@ -336,7 +363,7 @@ function EntryDetail({
return () => {
cancelled = true;
};
- }, [entry.type, entry.name, entry.packageId]);
+ }, [entry.type, entry.name, entry.packageId, entry.member]);
if (error) {
return (
@@ -514,9 +541,38 @@ export function DraftChangesPanel({
// Classify new-vs-update per TYPE: one published-list read covers every
// draft of that type. A type whose read fails stays unclassified
// (rendered neutrally) rather than failing the whole panel.
- const types = [...new Set(drafts.map((d) => d.type))];
- await Promise.all(
- types.map(async (type) => {
+ //
+ // A member entry is classified by its OWN published read instead
+ // (objectstack#23073): the name list says the NAME is published, which
+ // for `email_template` is true as soon as any one language is — so the
+ // first `zh-CN` draft of a published `en-US` template read *Update*.
+ // Whether this member is published is answered by the by-name door with
+ // its `?member=`; nothing here derives a member from the name.
+ const memberEntries = drafts.filter((d) => d.member);
+ const types = [...new Set(drafts.filter((d) => !d.member).map((d) => d.type))];
+ const classifyMembers = memberEntries.map(async (target) => {
+ let exists: boolean;
+ try {
+ exists =
+ (await fetchItemBody(target.type, target.name, {
+ packageId: target.packageId,
+ member: target.member,
+ })) !== null;
+ } catch {
+ return;
+ }
+ const key = entryKey(target);
+ setEntries((prev) =>
+ prev
+ ? prev.map((entry) =>
+ entryKey(entry) === key ? { ...entry, kind: exists ? 'update' : 'new' } : entry,
+ )
+ : prev,
+ );
+ });
+ await Promise.all([
+ ...classifyMembers,
+ ...types.map(async (type) => {
let published: Set | null = null;
try {
published = await publishedNamesOf(type);
@@ -526,14 +582,14 @@ export function DraftChangesPanel({
setEntries((prev) =>
prev
? prev.map((entry) =>
- entry.type === type
+ entry.type === type && !entry.member
? { ...entry, kind: published!.has(entry.name) ? 'update' : 'new' }
: entry,
)
: prev,
);
}),
- );
+ ]);
} catch (e) {
setError((e as Error).message);
}
@@ -624,7 +680,7 @@ export function DraftChangesPanel({
{items.map((entry) => {
- const key = `${entry.type}:${entry.name}`;
+ const key = entryKey(entry);
const isExpanded = expanded.has(key);
// objectui#11807 — the drill-in found nothing this publish
// changes: not an UPDATE, whatever the name list said.
@@ -661,6 +717,16 @@ export function DraftChangesPanel({
)}
{entry.name}
+ {entry.member ? (
+ // The member is what tells two rows of one name
+ // apart (objectstack#23073); shown as served.
+
+ {entry.member}
+
+ ) : null}
{!isUnchanged && entry.kind ? (
({
+ ...(await importOriginal()),
+ useObjectTranslation: () => ({
+ t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k,
+ }),
+}));
+
+import { CommitTimeline } from '../CommitTimeline';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+function mockCommits(commits: unknown[]) {
+ global.fetch = vi.fn(async () => ({
+ ok: true,
+ status: 200,
+ json: async () => ({ commits }),
+ })) as unknown as typeof fetch;
+}
+
+const item = (type: string, name: string, member?: string) => ({
+ type,
+ name,
+ ...(member ? { member } : {}),
+ existedBefore: true,
+ prevVersion: 1,
+});
+
+describe('CommitTimeline shows the member where an item carries one (objectui#12271)', () => {
+ it('names each member of a commit that touched two languages of one template', async () => {
+ mockCommits([
+ {
+ id: 'c1',
+ operation: 'apply',
+ itemCount: 3,
+ items: [
+ item('email_template', 'welcome', 'en-US'),
+ item('email_template', 'welcome', 'zh-CN'),
+ item('object', 'visit'),
+ ],
+ },
+ ]);
+ render( {}} packageId="com.x" />);
+
+ const list = await screen.findByTestId('commit-member-items');
+ expect(within(list).getAllByRole('listitem').map((li) => li.textContent)).toEqual([
+ 'email_template/welcome · en-US',
+ 'email_template/welcome · zh-CN',
+ ]);
+ });
+
+ it('adds nothing for a commit whose items carry no member', async () => {
+ mockCommits([
+ { id: 'c2', operation: 'apply', itemCount: 1, items: [item('object', 'visit')] },
+ ]);
+ render( {}} packageId="com.x" />);
+
+ await screen.findByTestId('commit-row');
+ expect(screen.queryByTestId('commit-member-items')).toBeNull();
+ });
+});
diff --git a/packages/app-shell/src/preview/__tests__/DraftChangesPanel.bundleMember-12271.test.tsx b/packages/app-shell/src/preview/__tests__/DraftChangesPanel.bundleMember-12271.test.tsx
new file mode 100644
index 0000000000..f22e33a641
--- /dev/null
+++ b/packages/app-shell/src/preview/__tests__/DraftChangesPanel.bundleMember-12271.test.tsx
@@ -0,0 +1,198 @@
+/**
+ * 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#12271 — the review sheet addresses a bundle member.
+ *
+ * Since objectstack#23073 each `_drafts` row of a type whose identity is a pair
+ * (today only `email_template`, `(name, locale)`) carries `member`, and the
+ * by-name `/meta` doors take it back as `?member=`. Two languages of one
+ * template are therefore two drafts with the same `type` and `name`. The sheet
+ * keyed its rows on `type:name` and read every body without a selector, so it
+ * drew them as one row (twice) and read the canonical body for both.
+ *
+ * Pinned here, against a mocked server that answers each member's own body:
+ * - two members of one name are two rows, each showing its member;
+ * - each row's drill-in reads published and draft with its own `?member=`,
+ * and diffs its own bodies;
+ * - a member's NEW / UPDATE mark comes from its own published read, not from
+ * the name being in the published list;
+ * - an entry with no `member` sends no `?member=` at all.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import * as React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+
+vi.mock('@object-ui/i18n', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useObjectTranslation: () => ({
+ t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k,
+ }),
+}));
+
+import { DraftChangesPanel } from '../DraftChangesPanel';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+type Body = Record;
+
+/** Bodies keyed `type/name` or `type/name#member`; absent = 404. */
+interface Fixture {
+ drafts: Array<{ type: string; name: string; member?: string }>;
+ publishedNames: Record;
+ published: Record;
+ draft: Record;
+}
+
+function mockServer(fx: Fixture): string[] {
+ const urls: string[] = [];
+ global.fetch = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ urls.push(url);
+ const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
+ const missing = { ok: false, status: 404, json: async () => ({}) };
+ if (url.includes('/_drafts')) {
+ return ok({
+ drafts: fx.drafts.map((d) => ({
+ ...d,
+ label: null,
+ packageId: 'com.x',
+ updatedAt: null,
+ updatedBy: null,
+ })),
+ });
+ }
+ const [path, query = ''] = url.split('?');
+ const [type, name] = path.replace('/api/v1/meta/', '').split('/');
+ if (!name) return ok((fx.publishedNames[type] ?? []).map((n) => ({ name: n })));
+ const member = new URLSearchParams(query).get('member');
+ const key = member === null ? `${type}/${name}` : `${type}/${name}#${member}`;
+ if (query.includes('state=draft')) {
+ return fx.draft[key] ? ok({ type, name, item: fx.draft[key] }) : missing;
+ }
+ return fx.published[key] ? ok(fx.published[key]) : missing;
+ }) as unknown as typeof fetch;
+ return urls;
+}
+
+function renderPanel() {
+ return render( {}} packageId="com.x" onPublish={vi.fn()} />);
+}
+
+/** The row toggle whose member cell reads `member`. */
+function memberRow(member: string): HTMLElement {
+ const hit = screen
+ .getAllByTestId('draft-entry-toggle')
+ .find((el) => el.querySelector('[data-testid="draft-entry-member"]')?.textContent === member);
+ if (!hit) throw new Error(`no row for member ${member}`);
+ return hit;
+}
+
+const fixture: Fixture = {
+ drafts: [
+ { type: 'email_template', name: 'welcome', member: 'en-US' },
+ { type: 'email_template', name: 'welcome', member: 'zh-CN' },
+ { type: 'object', name: 'visit' },
+ ],
+ // The NAME is published (its en-US member is); the zh-CN member is not.
+ publishedNames: { email_template: ['welcome'], object: ['visit'] },
+ published: {
+ 'email_template/welcome#en-US': { name: 'welcome', subject: 'Welcome' },
+ 'object/visit': { name: 'visit', label: 'Visit' },
+ },
+ draft: {
+ 'email_template/welcome#en-US': { name: 'welcome', subject: 'Welcome aboard' },
+ 'email_template/welcome#zh-CN': { name: 'welcome', subject: '欢迎', body: '你好' },
+ 'object/visit': { name: 'visit', label: 'Customer Visit' },
+ },
+};
+
+describe('two members of one email_template are two rows (objectui#12271)', () => {
+ it('lists each member as its own row, showing its member', async () => {
+ mockServer(fixture);
+ renderPanel();
+
+ await waitFor(() => expect(screen.getAllByTestId('draft-entry-toggle')).toHaveLength(3));
+ expect(screen.getAllByTestId('draft-entry-member').map((el) => el.textContent)).toEqual([
+ 'en-US',
+ 'zh-CN',
+ ]);
+ });
+
+ it("classifies each member by its own published read, not by the name list", async () => {
+ const urls = mockServer(fixture);
+ renderPanel();
+
+ await waitFor(() => expect(within(memberRow('zh-CN')).getByText('New')).toBeInTheDocument());
+ expect(within(memberRow('en-US')).getByText('Update')).toBeInTheDocument();
+ expect(urls).toContain('/api/v1/meta/email_template/welcome?package=com.x&member=zh-CN');
+ expect(urls).toContain('/api/v1/meta/email_template/welcome?package=com.x&member=en-US');
+ });
+
+ it("expanding a member reads and diffs that member's own bodies", async () => {
+ const urls = mockServer(fixture);
+ renderPanel();
+
+ await waitFor(() => expect(screen.getAllByTestId('draft-entry-toggle')).toHaveLength(3));
+ fireEvent.click(memberRow('zh-CN'));
+
+ await waitFor(() => expect(screen.getByTestId('draft-entry-detail')).toBeInTheDocument());
+ const detail = screen.getByTestId('draft-entry-detail');
+ // zh-CN is new: its draft adds `body` and `subject` against no published body.
+ expect(detail).toHaveTextContent('+ body');
+ expect(detail).toHaveTextContent('+ subject');
+ expect(urls).toContain('/api/v1/meta/email_template/welcome?state=draft&package=com.x&member=zh-CN');
+ // The canonical body was never read for this row.
+ expect(urls).not.toContain('/api/v1/meta/email_template/welcome?state=draft&package=com.x');
+ // Only the expanded row opened: the en-US row stays collapsed.
+ expect(memberRow('en-US')).toHaveAttribute('aria-expanded', 'false');
+ expect(memberRow('zh-CN')).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('expanding the other member reads its own selector and changes its own key', async () => {
+ const urls = mockServer(fixture);
+ renderPanel();
+
+ await waitFor(() => expect(screen.getAllByTestId('draft-entry-toggle')).toHaveLength(3));
+ fireEvent.click(memberRow('en-US'));
+
+ await waitFor(() => expect(screen.getByTestId('draft-entry-detail')).toBeInTheDocument());
+ expect(screen.getByTestId('draft-entry-detail')).toHaveTextContent('~ subject');
+ expect(screen.getByTestId('draft-entry-detail')).not.toHaveTextContent('body');
+ expect(urls).toContain('/api/v1/meta/email_template/welcome?state=draft&package=com.x&member=en-US');
+ });
+});
+
+describe('an entry without member sends no selector (objectui#12271)', () => {
+ it('reads a name-only draft exactly as before', async () => {
+ const urls = mockServer(fixture);
+ renderPanel();
+
+ await waitFor(() => expect(screen.getAllByTestId('draft-entry-toggle')).toHaveLength(3));
+ const visit = screen
+ .getAllByTestId('draft-entry-toggle')
+ .find((el) => el.querySelector('.font-mono')?.textContent === 'visit')!;
+ expect(visit.querySelector('[data-testid="draft-entry-member"]')).toBeNull();
+ fireEvent.click(visit);
+
+ await waitFor(() => expect(screen.getByTestId('draft-entry-detail')).toBeInTheDocument());
+ const objectReads = urls.filter((u) => u.startsWith('/api/v1/meta/object'));
+ expect(objectReads).toEqual(
+ expect.arrayContaining([
+ '/api/v1/meta/object',
+ '/api/v1/meta/object/visit?package=com.x',
+ '/api/v1/meta/object/visit?state=draft&package=com.x',
+ ]),
+ );
+ expect(objectReads.some((u) => u.includes('member='))).toBe(false);
+ });
+});
diff --git a/packages/app-shell/src/preview/commitHistory.ts b/packages/app-shell/src/preview/commitHistory.ts
index fd83479cb0..c0d535683a 100644
--- a/packages/app-shell/src/preview/commitHistory.ts
+++ b/packages/app-shell/src/preview/commitHistory.ts
@@ -146,6 +146,18 @@ function commitStoreFailure(status: number, payload: unknown, fallbackMessage: s
);
}
+/** A touched item of a commit that names its bundle member (objectstack#23073). */
+export interface CommitMemberItem {
+ type: string;
+ name: string;
+ /**
+ * `ListPackageCommitsResponseSchema` `commits[].items[].member`: present on
+ * every item of a type whose identity is a pair (today only
+ * `email_template`, `(name, locale)`), absent for a name-only type.
+ */
+ member: string;
+}
+
export interface CommitEntry {
id: string;
operation: 'apply' | 'revert';
@@ -154,6 +166,31 @@ export interface CommitEntry {
aiModel?: string;
itemCount: number;
createdAt?: string;
+ /**
+ * The commit's touched items that carry a `member`, in served order;
+ * undefined when none does. Only these are kept: the timeline shows a member
+ * where an item carries one and nothing extra for a name-only item, so two
+ * languages of one template read as two items instead of one name twice.
+ */
+ memberItems?: CommitMemberItem[];
+}
+
+function memberItemsOf(raw: unknown): CommitMemberItem[] | undefined {
+ if (!Array.isArray(raw)) return undefined;
+ const out: CommitMemberItem[] = [];
+ for (const it of raw) {
+ const i = it as Record | null;
+ if (
+ i &&
+ typeof i.type === 'string' &&
+ typeof i.name === 'string' &&
+ typeof i.member === 'string' &&
+ i.member
+ ) {
+ out.push({ type: i.type, name: i.name, member: i.member });
+ }
+ }
+ return out.length > 0 ? out : undefined;
}
export async function fetchCommits(packageId: string): Promise {
@@ -186,6 +223,7 @@ export async function fetchCommits(packageId: string): Promise {
aiModel: typeof c.aiModel === 'string' ? c.aiModel : undefined,
itemCount: typeof c.itemCount === 'number' ? c.itemCount : 0,
createdAt: typeof c.createdAt === 'string' ? c.createdAt : undefined,
+ memberItems: memberItemsOf(c.items),
} satisfies CommitEntry;
});
}
diff --git a/packages/app-shell/src/preview/usePendingDrafts.ts b/packages/app-shell/src/preview/usePendingDrafts.ts
index 86684cc35e..e87dbb6468 100644
--- a/packages/app-shell/src/preview/usePendingDrafts.ts
+++ b/packages/app-shell/src/preview/usePendingDrafts.ts
@@ -39,6 +39,15 @@ export interface PendingDraftEntry {
type: string;
name: string;
packageId: string | null;
+ /**
+ * The bundle member this draft is (objectstack#23073, `ListDraftsResponseSchema`
+ * `drafts[].member`): present on every draft of a type whose identity is a
+ * pair (today only `email_template`, `(name, locale)`), absent for a type
+ * identified by its name alone. Two members of one name are two entries; a
+ * caller addressing this draft through a by-name `/meta` door passes it back
+ * as `?member=`. Copied as served, never derived from the name or the body.
+ */
+ member?: string;
}
/**
@@ -124,6 +133,9 @@ export async function fetchPendingDrafts(
type: d.type as string,
name: d.name as string,
packageId: typeof d.packageId === 'string' && d.packageId ? (d.packageId as string) : null,
+ // Only when served: an entry without `member` stays without one, so no
+ // caller ever sends an empty `?member=` (a blank selector is a 400).
+ ...(typeof d.member === 'string' && d.member ? { member: d.member as string } : {}),
}));
}