diff --git a/packages/app-shell/src/preview/DraftChangesPanel.tsx b/packages/app-shell/src/preview/DraftChangesPanel.tsx index 19c5b7046..b2cb5b8eb 100644 --- a/packages/app-shell/src/preview/DraftChangesPanel.tsx +++ b/packages/app-shell/src/preview/DraftChangesPanel.tsx @@ -11,17 +11,21 @@ * user commits. Lists every pending ADR-0033 draft grouped by metadata type, * and classifies each as NEW (no published version exists — publishing adds * it) or UPDATE (a published version exists — publishing overwrites it). - * This is the review surface that turns Publish from a leap of faith into an - * informed click; the per-item designer diff remains the deep-dive. + * Each entry expands into a field-level diff (objects) / changed-key summary + * (everything else), lazily fetched on first expand. This is the review + * surface that turns Publish from a leap of faith into an informed click. * - * Read-only: fetches `_drafts` + per-item `/published` probes on open, and - * never writes. Publishing stays with the caller (DraftPreviewBar / chat). + * Read-only by default: fetches `_drafts` + published lists on open, and + * never writes. When the caller passes `onPublish`, the panel additionally + * renders a confirm footer — review-then-publish in one surface — but the + * publish action itself still belongs to the caller. */ import { useCallback, useEffect, useState } from 'react'; -import { FilePlus2, FilePen, Loader2 } from 'lucide-react'; +import { ChevronDown, ChevronRight, FilePlus2, FilePen, Loader2, Rocket } from 'lucide-react'; import { Badge, + Button, Sheet, SheetContent, SheetDescription, @@ -29,6 +33,7 @@ import { SheetTitle, } from '@object-ui/components'; import { useObjectTranslation } from '@object-ui/i18n'; +import { diffFields } from '../views/metadata-admin/previews/object-fields-io'; export interface DraftChangeEntry { type: string; @@ -83,17 +88,206 @@ async function publishedNamesOf(type: string): Promise> { ); } +/** + * Some framework reads wrap the body in a `{ type, name, item }` envelope + * (draft reads do; published reads return the bare body). Unwrap defensively. + */ +function unwrapItem(payload: unknown): Record | null { + if (!payload || typeof payload !== 'object') return null; + const p = payload as Record; + if (p.item && typeof p.item === 'object') return p.item as Record; + return p; +} + +async function fetchItemBody( + type: string, + name: string, + opts: { draft?: boolean; packageId?: string | null } = {}, +): Promise | null> { + const params: string[] = []; + if (opts.draft) params.push('state=draft'); + if (opts.packageId) params.push(`package=${encodeURIComponent(opts.packageId)}`); + const qs = params.length ? `?${params.join('&')}` : ''; + const res = await fetch( + `/api/v1/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`, + { credentials: 'include', headers: { Accept: 'application/json' }, cache: 'no-store' }, + ); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return unwrapItem(await res.json()); +} + +export interface EntryChangeDetail { + /** Field-level diff — present when either side carries a `fields` map. */ + fields: { + added: string[]; + changed: Array<{ name: string; keys: string[] }>; + removed: string[]; + } | null; + /** Top-level keys (other than `fields`) whose values differ. */ + changedKeys: string[]; +} + +/** Stable equality for metadata values (small JSON — order-sensitive is fine). */ +function valueEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); +} + +/** + * What publishing this draft actually changes, computed client-side from the + * published body (null when the item is NEW) and the pending draft body. + * `fields` gets the dedicated designer diff; every other top-level key is + * compared wholesale — enough to answer "which parts of this item move". + */ +export function computeChangeDetail( + published: Record | null, + draft: Record | null, +): EntryChangeDetail { + const pub = published ?? {}; + const cur = draft ?? {}; + let fields: EntryChangeDetail['fields'] = null; + if (pub.fields != null || cur.fields != null) { + const d = diffFields(pub.fields, cur.fields); + fields = { + added: Object.values(d.byName) + .filter((e) => e.status === 'added') + .map((e) => e.name) + .sort(), + changed: Object.values(d.byName) + .filter((e) => e.status === 'changed') + .map((e) => ({ name: e.name, keys: e.changedKeys })) + .sort((a, b) => a.name.localeCompare(b.name)), + removed: d.removed.map((e) => e.name).sort(), + }; + } + const keys = new Set([...Object.keys(pub), ...Object.keys(cur)]); + keys.delete('fields'); + const changedKeys = [...keys] + .filter((k) => !valueEqual((pub as Record)[k], (cur as Record)[k])) + .sort(); + return { fields, changedKeys }; +} + +/** + * Lazily-loaded drill-in for one draft entry: published vs draft, rendered as + * added / changed / removed field rows plus a changed-top-level-keys strip. + */ +function EntryDetail({ entry }: { entry: DraftChangeEntry }) { + const { t } = useObjectTranslation(); + const [detail, setDetail] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + 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 }), + ]); + if (!cancelled) setDetail(computeChangeDetail(published, draft)); + } catch (e) { + if (!cancelled) setError((e as Error).message); + } + })(); + return () => { + cancelled = true; + }; + }, [entry.type, entry.name, entry.packageId]); + + if (error) { + return ( +

+ {t('preview.changes.detailLoadFailed', { defaultValue: 'Could not load change detail:' })}{' '} + {error} +

+ ); + } + if (!detail) { + return ( +

+ + {t('preview.changes.detailLoading', { defaultValue: 'Loading detail…' })} +

+ ); + } + + const { fields, changedKeys } = detail; + const hasFieldRows = !!fields && (fields.added.length > 0 || fields.changed.length > 0 || fields.removed.length > 0); + if (!hasFieldRows && changedKeys.length === 0) { + return ( +

+ {t('preview.changes.detailNone', { + defaultValue: 'No differences detected — the draft matches the published version.', + })} +

+ ); + } + + return ( +
+ {fields?.added.map((name) => ( +

+ + {name} +

+ ))} + {fields?.changed.map((f) => ( +

+ ~ {f.name} + {f.keys.length > 0 && · {f.keys.join(', ')}} +

+ ))} + {fields?.removed.map((name) => ( +

+ − {name} +

+ ))} + {changedKeys.length > 0 && ( +

+ {t('preview.changes.detailChangedKeys', { defaultValue: 'Also changed:' })}{' '} + {changedKeys.join(', ')} +

+ )} +
+ ); +} + export interface DraftChangesPanelProps { open: boolean; onOpenChange: (open: boolean) => void; /** When set, list only pending drafts belonging to this package (Studio is package-scoped). */ packageId?: string | null; + /** + * When provided, the panel renders a confirm footer whose button invokes + * this — turning the panel into the review-then-publish step. The caller + * still owns the actual publish request and closing the panel on success. + */ + onPublish?: () => void | Promise; + /** Disables the confirm button and shows a spinner while the caller publishes. */ + publishing?: boolean; } -export function DraftChangesPanel({ open, onOpenChange, packageId }: DraftChangesPanelProps) { +export function DraftChangesPanel({ + open, + onOpenChange, + packageId, + onPublish, + publishing = false, +}: DraftChangesPanelProps) { const { t } = useObjectTranslation(); const [entries, setEntries] = useState(null); const [error, setError] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + + const toggleExpanded = useCallback((key: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); const load = useCallback(async () => { setEntries(null); @@ -142,7 +336,11 @@ export function DraftChangesPanel({ open, onOpenChange, packageId }: DraftChange return ( - + {t('preview.changes.title', { defaultValue: 'Pending changes' })} @@ -153,7 +351,7 @@ export function DraftChangesPanel({ open, onOpenChange, packageId }: DraftChange })} -
+
{error ? (

{t('preview.changes.loadFailed', { defaultValue: 'Could not load pending changes:' })}{' '} @@ -175,40 +373,83 @@ export function DraftChangesPanel({ open, onOpenChange, packageId }: DraftChange {type} · {items.length}

    - {items.map((entry) => ( -
  • - {entry.kind === 'new' ? ( - - ) : entry.kind === 'update' ? ( - - ) : ( - - )} - {entry.name} - {entry.kind ? ( - { + const key = `${entry.type}:${entry.name}`; + const isExpanded = expanded.has(key); + return ( +
  • +
  • - ))} + {isExpanded ? ( + + ) : ( + + )} + {entry.kind === 'new' ? ( + + ) : entry.kind === 'update' ? ( + + ) : ( + + )} + {entry.name} + {entry.kind ? ( + + {entry.kind === 'new' + ? t('preview.changes.kindNew', { defaultValue: 'New' }) + : t('preview.changes.kindUpdate', { defaultValue: 'Update' })} + + ) : null} + + {isExpanded && ( +
    + +
    + )} + + ); + })}
)) )}
+ {onPublish && (entries?.length ?? 0) > 0 && !error && ( +
+

+ {t('preview.changes.confirmNote', { + count: entries!.length, + defaultValue: + 'Publishing releases all {{count}} pending drafts of this package atomically.', + })} +

+ +
+ )}
); diff --git a/packages/app-shell/src/preview/__tests__/DraftChangesPanel.test.tsx b/packages/app-shell/src/preview/__tests__/DraftChangesPanel.test.tsx new file mode 100644 index 000000000..63db39182 --- /dev/null +++ b/packages/app-shell/src/preview/__tests__/DraftChangesPanel.test.tsx @@ -0,0 +1,159 @@ +/** + * 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. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +vi.mock('@object-ui/i18n', () => ({ + useObjectTranslation: () => ({ + t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, + }), +})); + +import { DraftChangesPanel, computeChangeDetail } from '../DraftChangesPanel'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/* ─────────────── computeChangeDetail (pure) ─────────────── */ + +describe('computeChangeDetail', () => { + it('classifies a NEW item: every field added, top-level keys changed', () => { + const draft = { + name: 'ticket', + label: 'Ticket', + fields: { status: { type: 'select' }, title: { type: 'text' } }, + }; + const d = computeChangeDetail(null, draft); + expect(d.fields?.added.sort()).toEqual(['status', 'title']); + expect(d.fields?.changed).toEqual([]); + expect(d.fields?.removed).toEqual([]); + expect(d.changedKeys).toEqual(['label', 'name']); + }); + + it('diffs an UPDATE: added / changed (with keys) / removed fields, unchanged keys dropped', () => { + const published = { + name: 'ticket', + label: 'Ticket', + fields: { + title: { type: 'text', label: 'Title' }, + old_notes: { type: 'textarea' }, + }, + }; + const draft = { + name: 'ticket', + label: 'Repair Ticket', // changed + fields: { + title: { type: 'text', label: 'Subject' }, // label changed + status: { type: 'select' }, // added + // old_notes removed + }, + }; + const d = computeChangeDetail(published, draft); + expect(d.fields?.added).toEqual(['status']); + expect(d.fields?.changed).toEqual([{ name: 'title', keys: ['label'] }]); + expect(d.fields?.removed).toEqual(['old_notes']); + expect(d.changedKeys).toEqual(['label']); // `name` unchanged, `fields` handled separately + }); + + it('handles field-less metadata types with a plain top-level key diff', () => { + const published = { name: 'crm', label: 'CRM', navigation: [{ id: 'a' }] }; + const draft = { name: 'crm', label: 'CRM', navigation: [{ id: 'a' }, { id: 'b' }] }; + const d = computeChangeDetail(published, draft); + expect(d.fields).toBeNull(); + expect(d.changedKeys).toEqual(['navigation']); + }); + + it('reports no differences when draft matches published', () => { + const body = { name: 'x', fields: { a: { type: 'text' } } }; + const d = computeChangeDetail(body, structuredClone(body)); + expect(d.fields?.added).toEqual([]); + expect(d.fields?.changed).toEqual([]); + expect(d.fields?.removed).toEqual([]); + expect(d.changedKeys).toEqual([]); + }); +}); + +/* ─────────────── panel behaviour ─────────────── */ + +const PUBLISHED_TICKET = { + name: 'ticket', + label: 'Ticket', + fields: { title: { type: 'text' } }, +}; +const DRAFT_TICKET = { + name: 'ticket', + label: 'Ticket', + fields: { title: { type: 'text' }, status: { type: 'select' } }, +}; + +/** Route fetches by URL shape: _drafts list, published type list, item reads. */ +function mockRoutes() { + global.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + if (url.includes('/_drafts')) { + return ok([{ type: 'object', name: 'ticket', packageId: 'com.x' }]); + } + if (url.includes('state=draft')) { + return ok({ type: 'object', name: 'ticket', item: DRAFT_TICKET }); + } + if (/\/meta\/object\/ticket/.test(url)) { + return ok(PUBLISHED_TICKET); + } + if (/\/meta\/object(\?|$)/.test(url)) { + return ok([{ name: 'ticket' }]); + } + return { ok: false, status: 404, json: async () => ({}) }; + }) as unknown as typeof fetch; +} + +function renderPanel(extra: Partial> = {}) { + return render( + {}} packageId="com.x" {...extra} />, + ); +} + +describe('DraftChangesPanel', () => { + it('shows no publish footer without onPublish (read-only review, e.g. preview bar)', async () => { + mockRoutes(); + renderPanel(); + await waitFor(() => expect(screen.getByText('ticket')).toBeInTheDocument()); + expect(screen.queryByTestId('draft-changes-publish')).not.toBeInTheDocument(); + }); + + it('renders the confirm footer and forwards the click to onPublish', async () => { + mockRoutes(); + const onPublish = vi.fn(); + renderPanel({ onPublish }); + await waitFor(() => expect(screen.getByTestId('draft-changes-publish')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('draft-changes-publish')); + expect(onPublish).toHaveBeenCalledTimes(1); + }); + + it('disables the confirm button while publishing', async () => { + mockRoutes(); + renderPanel({ onPublish: vi.fn(), publishing: true }); + await waitFor(() => expect(screen.getByTestId('draft-changes-publish')).toBeInTheDocument()); + expect(screen.getByTestId('draft-changes-publish')).toBeDisabled(); + }); + + it('expands an entry into a lazily-fetched field-level diff', async () => { + mockRoutes(); + renderPanel(); + await waitFor(() => expect(screen.getByTestId('draft-entry-toggle')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('draft-entry-toggle')); + await waitFor(() => expect(screen.getByTestId('draft-entry-detail')).toBeInTheDocument()); + // status is added in the draft; title unchanged → only the + row shows. + expect(screen.getByText('+ status')).toBeInTheDocument(); + expect(screen.queryByText(/~ title/)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index a96add37f..1046f8f6d 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -614,9 +614,12 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React {t('engine.studio.changes', locale)}{hasPending ? ` · ${pendingCount}` : ''} + {/* Review-then-publish: the button opens the pending-changes panel, + * whose confirm footer runs the actual atomic publish — no more + * one-click release of every package draft straight from here. */}