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
5 changes: 5 additions & 0 deletions .changeset/12271-draft-member.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions packages/app-shell/src/preview/CommitTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,21 @@ export function CommitTimeline({ open, onOpenChange, packageId, onReverted }: Co
{c.actor ? <span>· {c.actor}</span> : null}
{c.createdAt ? <span>· {relativeTime(c.createdAt)}</span> : null}
</p>
{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.
<ul className="mt-1 flex flex-col gap-0.5" data-testid="commit-member-items">
{c.memberItems.map((it) => (
<li
key={JSON.stringify([it.type, it.name, it.member])}
className="truncate font-mono text-xs text-muted-foreground"
>
{it.type}/{it.name} · {it.member}
</li>
))}
</ul>
) : null}
</div>
{c.operation === 'apply' ? (
<Button
Expand Down
86 changes: 76 additions & 10 deletions packages/app-shell/src/preview/DraftChangesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ export interface DraftChangeEntry {
type: string;
name: string;
packageId: string | null;
/**
* The bundle member this draft is, exactly as `_drafts` served it
* (objectstack#23073): present for a type whose identity is a pair (today
* only `email_template`, `(name, locale)`), absent otherwise. Every by-name
* `/meta` read below passes it as `?member=`, so two members of one name are
* two rows, each with its own reads; an entry without one sends no selector.
*/
member?: string;
/** `new` = no published version; `update` = overwrites one; undefined = probing. */
kind?: 'new' | 'update';
}
Expand Down Expand Up @@ -124,9 +132,20 @@ async function listPendingDrafts(packageId?: string | null): Promise<DraftChange
type: canonicalMetaUrlType(d.type),
name: d.name,
packageId: d.packageId,
...(d.member ? { member: d.member } : {}),
}));
}

/**
* A row's identity: type, name AND member (objectstack#23073). Two members of
* one `email_template` share `type` and `name`, so a `type:name` key made them
* one React row, one expanded state and one no-change mark. Serialized as a
* JSON tuple so no name or member spelling can collide with another entry.
*/
function entryKey(entry: Pick<DraftChangeEntry, 'type' | 'name' | 'member'>): 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:
Expand Down Expand Up @@ -192,12 +211,16 @@ function unwrapItem(payload: unknown): Record<string, unknown> | null {
async function fetchItemBody(
type: string,
name: string,
opts: { draft?: boolean; packageId?: string | null } = {},
opts: { draft?: boolean; packageId?: string | null; member?: string } = {},
): Promise<Record<string, unknown> | 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}`,
Expand Down Expand Up @@ -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);
Expand All @@ -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 (
Expand Down Expand Up @@ -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<string> | null = null;
try {
published = await publishedNamesOf(type);
Expand All @@ -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);
}
Expand Down Expand Up @@ -624,7 +680,7 @@ export function DraftChangesPanel({
</h4>
<ul className="flex flex-col gap-1">
{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.
Expand Down Expand Up @@ -661,6 +717,16 @@ export function DraftChangesPanel({
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate font-mono text-xs">{entry.name}</span>
{entry.member ? (
// The member is what tells two rows of one name
// apart (objectstack#23073); shown as served.
<span
className="shrink-0 font-mono text-xs text-muted-foreground"
data-testid="draft-entry-member"
>
{entry.member}
</span>
) : null}
{!isUnchanged && entry.kind ? (
<Badge
variant="outline"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* 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 build history shows the bundle member of each touched
* item that carries one (objectstack#23073, `ListPackageCommitsResponseSchema`
* `commits[].items[].member`), so a commit that touched two languages of one
* `email_template` names both. A commit whose items carry no member renders
* exactly as before: nothing extra.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/i18n')>()),
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(<CommitTimeline open onOpenChange={() => {}} 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(<CommitTimeline open onOpenChange={() => {}} packageId="com.x" />);

await screen.findByTestId('commit-row');
expect(screen.queryByTestId('commit-member-items')).toBeNull();
});
});
Loading
Loading