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
43 changes: 22 additions & 21 deletions packages/app-shell/src/console/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,43 +115,44 @@ function StatPill({
*/
function PendingDraftsBanner({ t }: { t: (key: string, opts?: any) => string }) {
const client = useMetadataClient();
const [count, setCount] = useState(0);
const [pkgIds, setPkgIds] = useState<string[]>([]);
const [drafts, setDrafts] = useState<Array<{ type: string; name: string }>>([]);
const [publishing, setPublishing] = useState(false);
const count = drafts.length;

useEffect(() => {
let cancelled = false;
Promise.resolve(client.listDrafts?.({}))
.then((drafts) => {
if (cancelled || !Array.isArray(drafts)) return;
setCount(drafts.length);
setPkgIds([...new Set(drafts.map((d: any) => d.packageId).filter(Boolean) as string[])]);
.then((rows) => {
if (cancelled || !Array.isArray(rows)) return;
setDrafts(
rows
.filter((d: any) => d && typeof d.type === 'string' && typeof d.name === 'string')
.map((d: any) => ({ type: d.type, name: d.name })),
);
})
.catch(() => { /* drafts unsupported / error → don't show */ });
return () => { cancelled = true; };
}, [client]);

// One-click publish: promote the draft package(s) directly so a brand-new
// One-click publish: promote every pending draft BY REFERENCE so a brand-new
// user reaches "it's live" without hunting for a designer. (Pre-PMF activation
// > the draft-review gate; the review path can return when it matters.)
// > the draft-review gate.) Publishing per-(type,name) — not per-package —
// means a draft with no `packageId` binding still publishes, instead of the
// banner dead-ending with "no draft packages" while the count stays stuck.
const publish = async () => {
setPublishing(true);
try {
const ids = pkgIds.length
? pkgIds
: [...new Set((((await client.listDrafts?.({})) as any[]) || []).map((d) => d.packageId).filter(Boolean) as string[])];
if (ids.length === 0) throw new Error('no draft packages');
for (const id of ids) {
const res = await fetch(`/api/v1/packages/${encodeURIComponent(id)}/publish-drafts`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({}),
});
if (!res.ok) throw new Error((await res.text().catch(() => '')) || `HTTP ${res.status}`);
const pending = drafts.length
? drafts
: (((await client.listDrafts?.({})) as any[]) || [])
.filter((d) => d && typeof d.type === 'string' && typeof d.name === 'string')
.map((d) => ({ type: d.type, name: d.name }));
if (pending.length === 0) throw new Error('nothing to publish');
for (const d of pending) {
await client.publishDraft(d.type, d.name);
}
toast.success(t('home.pendingDrafts.published', { defaultValue: 'Published! Your changes are live.' }));
setCount(0);
setDrafts([]);
// Surface the now-live app — reload so the populated home shows it.
setTimeout(() => { try { window.location.reload(); } catch { /* ignore */ } }, 700);
} catch (e) {
Expand Down
22 changes: 22 additions & 0 deletions packages/data-objectstack/src/metadata-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,26 @@ describe('MetadataClient', () => {
});
expect((await bare.listDrafts()).map((d) => d.name)).toEqual(['b']);
});

it('publishDraft POSTs /meta/:type/:name/publish (promotes a draft by ref, no package needed)', async () => {
const seen: { url: string; method?: string }[] = [];
const c = new MetadataClient({
baseUrl: 'http://localhost:3000',
fetch: mockFetch(async (url, init) => {
seen.push({ url, method: init?.method });
return jsonResponse({ success: true });
}),
});
await c.publishDraft('dashboard', 'sales_dashboard');
expect(seen[0]?.method).toBe('POST');
expect(seen[0]?.url).toBe('http://localhost:3000/api/v1/meta/dashboard/sales_dashboard/publish');
});

it('publishDraft throws on a non-ok response', async () => {
const c = new MetadataClient({
baseUrl: '',
fetch: mockFetch(async () => new Response('nope', { status: 500 })),
});
await expect(c.publishDraft('view', 'x')).rejects.toBeTruthy();
});
});
17 changes: 17 additions & 0 deletions packages/data-objectstack/src/metadata-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,23 @@ export class MetadataClient {
return (await res.json()) as T;
}

/**
* Publish a single pending draft BY REFERENCE — promotes the draft row for
* `(type, name)` into the active overlay and drops the draft. Works for ANY
* draft, including ones with no `packageId` binding (which the package-scoped
* `/packages/:id/publish-drafts` flow cannot reach). Use this to publish the
* exact set returned by {@link listDrafts} without needing a package.
*/
async publishDraft(type: string, name: string): Promise<void> {
const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`;
const res = await this.fetchImpl(url, {
method: 'POST',
headers: { ...this.headers, 'Content-Type': 'application/json', Accept: 'application/json' },
body: '{}',
});
if (!res.ok) throw await parseError(res);
}

/**
* Get the 3-state layered view of a metadata item (Phase 3a). Returns
* `code` (the artifact / fallback default), `overlay` (the saved
Expand Down
Loading