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
313 changes: 277 additions & 36 deletions packages/app-shell/src/preview/DraftChangesPanel.tsx

Large diffs are not rendered by default.

159 changes: 159 additions & 0 deletions packages/app-shell/src/preview/__tests__/DraftChangesPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof DraftChangesPanel>> = {}) {
return render(
<DraftChangesPanel open onOpenChange={() => {}} 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();
});
});
13 changes: 11 additions & 2 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -614,9 +614,12 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
<GitBranch className="h-3.5 w-3.5" />
{t('engine.studio.changes', locale)}{hasPending ? ` · ${pendingCount}` : ''}
</button>
{/* 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. */}
<button
type="button"
onClick={doPublish}
onClick={() => setChangesOpen(true)}
disabled={publishing || !hasPending}
title={hasPending ? t('engine.studio.publishTitle', locale) : t('engine.studio.publishNoneTitle', locale)}
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50"
Expand Down Expand Up @@ -651,7 +654,13 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
</div>
</div>

<DraftChangesPanel open={changesOpen} onOpenChange={setChangesOpen} packageId={packageId} />
<DraftChangesPanel
open={changesOpen}
onOpenChange={setChangesOpen}
packageId={packageId}
onPublish={doPublish}
publishing={publishing}
/>
</div>
);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,12 @@ const ar = {
empty: 'لا شيء معلّق — نُشرت كل المسودات.',
kindNew: 'جديد',
kindUpdate: 'تحديث',
detailLoading: 'جارٍ تحميل التفاصيل…',
detailLoadFailed: 'تعذر تحميل تفاصيل التغيير:',
detailNone: 'لم يتم اكتشاف اختلافات — المسودة مطابقة للنسخة المنشورة.',
detailChangedKeys: 'تغييرات أخرى:',
confirmNote: 'النشر يُصدر كل المسودات المعلقة ({{count}}) لهذه الحزمة دفعة واحدة.',
publishConfirm: 'نشر الكل',
},
},
filterBuilder: {
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,12 @@ const de = {
empty: 'Nichts ausstehend — alle Entwürfe wurden veröffentlicht.',
kindNew: 'Neu',
kindUpdate: 'Aktualisierung',
detailLoading: 'Details werden geladen…',
detailLoadFailed: 'Änderungsdetails konnten nicht geladen werden:',
detailNone: 'Keine Unterschiede erkannt — der Entwurf entspricht der veröffentlichten Version.',
detailChangedKeys: 'Ebenfalls geändert:',
confirmNote: 'Beim Veröffentlichen werden alle {{count}} ausstehenden Entwürfe dieses Pakets atomar freigegeben.',
publishConfirm: 'Alle veröffentlichen',
},
},
filterBuilder: {
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1891,6 +1891,12 @@ const en = {
empty: 'Nothing pending — every draft has been published.',
kindNew: 'New',
kindUpdate: 'Update',
detailLoading: 'Loading detail…',
detailLoadFailed: 'Could not load change detail:',
detailNone: 'No differences detected — the draft matches the published version.',
detailChangedKeys: 'Also changed:',
confirmNote: 'Publishing releases all {{count}} pending drafts of this package atomically.',
publishConfirm: 'Publish all',
},
},
renderer: {
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,12 @@ const es = {
empty: 'Nada pendiente — todos los borradores se han publicado.',
kindNew: 'Nuevo',
kindUpdate: 'Actualización',
detailLoading: 'Cargando detalles…',
detailLoadFailed: 'No se pudieron cargar los detalles del cambio:',
detailNone: 'No se detectaron diferencias — el borrador coincide con la versión publicada.',
detailChangedKeys: 'También cambió:',
confirmNote: 'Publicar libera atómicamente los {{count}} borradores pendientes de este paquete.',
publishConfirm: 'Publicar todo',
},
},
filterBuilder: {
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,12 @@ const fr = {
empty: 'Rien en attente — tous les brouillons ont été publiés.',
kindNew: 'Nouveau',
kindUpdate: 'Mise à jour',
detailLoading: 'Chargement des détails…',
detailLoadFailed: 'Impossible de charger les détails de la modification :',
detailNone: 'Aucune différence détectée — le brouillon correspond à la version publiée.',
detailChangedKeys: 'Également modifié :',
confirmNote: 'La publication libère atomiquement les {{count}} brouillons en attente de ce paquet.',
publishConfirm: 'Tout publier',
},
},
filterBuilder: {
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,12 @@ const ja = {
empty: '保留中の変更はありません — すべてのドラフトが公開済みです。',
kindNew: '新規',
kindUpdate: '更新',
detailLoading: '詳細を読み込み中…',
detailLoadFailed: '変更の詳細を読み込めませんでした:',
detailNone: '差分は検出されませんでした — ドラフトは公開版と一致しています。',
detailChangedKeys: 'その他の変更:',
confirmNote: '公開すると、このパッケージの保留中ドラフト {{count}} 件がまとめて(アトミックに)公開されます。',
publishConfirm: 'すべて公開',
},
},
filterBuilder: {
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,12 @@ const ko = {
empty: '대기 중인 항목 없음 — 모든 드래프트가 게시되었습니다.',
kindNew: '신규',
kindUpdate: '업데이트',
detailLoading: '상세 정보 로드 중…',
detailLoadFailed: '변경 상세 정보를 불러오지 못했습니다:',
detailNone: '차이가 감지되지 않았습니다 — 초안이 게시된 버전과 일치합니다.',
detailChangedKeys: '기타 변경:',
confirmNote: '게시하면 이 패키지의 대기 중인 초안 {{count}}개가 한 번에(원자적으로) 게시됩니다.',
publishConfirm: '모두 게시',
},
},
filterBuilder: {
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,12 @@ const pt = {
empty: 'Nada pendente — todos os rascunhos foram publicados.',
kindNew: 'Novo',
kindUpdate: 'Atualização',
detailLoading: 'Carregando detalhes…',
detailLoadFailed: 'Não foi possível carregar os detalhes da alteração:',
detailNone: 'Nenhuma diferença detectada — o rascunho corresponde à versão publicada.',
detailChangedKeys: 'Também alterado:',
confirmNote: 'Publicar libera atomicamente todos os {{count}} rascunhos pendentes deste pacote.',
publishConfirm: 'Publicar tudo',
},
},
filterBuilder: {
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,12 @@ const ru = {
empty: 'Ничего не ожидает — все черновики опубликованы.',
kindNew: 'Новое',
kindUpdate: 'Обновление',
detailLoading: 'Загрузка деталей…',
detailLoadFailed: 'Не удалось загрузить детали изменения:',
detailNone: 'Различий не обнаружено — черновик совпадает с опубликованной версией.',
detailChangedKeys: 'Также изменено:',
confirmNote: 'Публикация атомарно выпускает все {{count}} ожидающих черновиков этого пакета.',
publishConfirm: 'Опубликовать всё',
},
},
filterBuilder: {
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1973,6 +1973,12 @@ const zh = {
empty: '没有待发布的内容 —— 所有草稿均已发布。',
kindNew: '新增',
kindUpdate: '更新',
detailLoading: '正在加载详情…',
detailLoadFailed: '无法加载变更详情:',
detailNone: '未检测到差异 —— 草稿与已发布版本一致。',
detailChangedKeys: '其他变更:',
confirmNote: '发布将一次性(原子地)发布此包全部 {{count}} 个待发布草稿。',
publishConfirm: '全部发布',
},
},
renderer: {
Expand Down
Loading