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
13 changes: 13 additions & 0 deletions .changeset/12517-publish-confirm-posture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

The Studio's Publish and Changes buttons no longer open the pending-changes sheet the same way.

- **Publish opens it to confirm.** Every pending item is expanded, so the fields and keys each one adds, changes or removes are on screen. A summary line above the list counts what the publish carries, by type (for example "3 items · 1 object · 1 app · 1 page"). Focus lands on Publish all. The header button still publishes nothing by itself; only Publish all in the sheet does.
- **Changes opens it to review.** Items stay collapsed and focus lands on the list. Publish all is still in the footer, unfocused.

Every other place that opens the sheet (the Home page, the draft preview bar and the history timeline) keeps the review behaviour. With nothing pending, Publish stays disabled and says why, as before.

New language-pack keys under `preview.changes`: `summaryItems` (with its plural forms) and `summaryType`, in every shipped locale.
72 changes: 70 additions & 2 deletions packages/app-shell/src/preview/DraftChangesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
* publish action itself still belongs to the caller.
*/

import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, Equal, FilePlus2, FilePen, Loader2, Rocket, ShieldAlert } from 'lucide-react';
import { toast } from 'sonner';
import {
Expand Down Expand Up @@ -471,15 +471,28 @@ export interface DraftChangesPanelProps {
onPublish?: () => void | Promise<void>;
/** Disables the confirm button and shows a spinner while the caller publishes. */
publishing?: boolean;
/**
* How the sheet opens (objectui#12517). `review` — the default, and what
* every host but the Studio's Publish gets: rows collapsed, focus on the
* list. `confirm` — the host's Publish opened it: every row expanded to
* show what changes, a per-type summary line above the list, and focus on
* the footer's Publish all once it renders. Read on each open; a posture
* never changes what the footer button does (objectui#2261: only it
* publishes).
*/
posture?: DraftChangesPosture;
}

export type DraftChangesPosture = 'review' | 'confirm';

export function DraftChangesPanel({
open,
onOpenChange,
packageId,
include,
onPublish,
publishing = false,
posture = 'review',
}: DraftChangesPanelProps) {
const { t, language } = useObjectTranslation();
const [entries, setEntries] = useState<DraftChangeEntry[] | null>(null);
Expand Down Expand Up @@ -514,6 +527,38 @@ export function DraftChangesPanel({
});
}, []);

/**
* objectui#12517 — the posture's two openings. Each open starts collapsed;
* the confirm posture expands every row ONCE, when the list first arrives,
* so a row the author then collapses stays collapsed while the type reads
* below re-map the entries. Focus lands on the list at open (the Publish
* all button does not exist until the list loads) and moves to Publish all
* on that same arrival — only if the author has not moved it meanwhile.
* Layout effects, so the frame that first paints the list already paints
* it expanded: a passive effect let one collapsed frame through first.
*/
const listRef = useRef<HTMLDivElement>(null);
const publishRef = useRef<HTMLButtonElement>(null);
const settledRef = useRef(false);
// The list as it stood when the sheet opened: the previous open's, until
// this open's load replaces it. Only a list newer than that one settles.
const entriesAtOpenRef = useRef<DraftChangeEntry[] | null>(null);
useLayoutEffect(() => {
if (!open) return;
settledRef.current = false;
entriesAtOpenRef.current = entries;
setExpanded(new Set());
// Keyed on the open transition alone: `entries` is read, not watched.
}, [open]);
useLayoutEffect(() => {
if (!open || settledRef.current || entries === null || entries === entriesAtOpenRef.current) return;
settledRef.current = true;
if (posture !== 'confirm') return;
setExpanded(new Set(entries.map(entryKey)));
const button = publishRef.current;
if (button && document.activeElement === listRef.current) button.focus();
}, [open, posture, entries]);

const markCompared = (key: string, isUnchanged: boolean) => {
setUnchanged((prev) => {
if (prev.has(key) === isUnchanged) return prev;
Expand Down Expand Up @@ -652,6 +697,13 @@ export function DraftChangesPanel({
side="right"
className="flex w-[420px] flex-col sm:max-w-[420px]"
data-testid="draft-changes-panel"
data-posture={posture}
onOpenAutoFocus={(event) => {
// objectui#12517 — the list, not the first tabbable (the close
// button): see the posture effect above for where focus goes next.
event.preventDefault();
listRef.current?.focus();
}}
>
<SheetHeader>
<SheetTitle>
Expand All @@ -663,9 +715,24 @@ export function DraftChangesPanel({
})}
</SheetDescription>
</SheetHeader>
{posture === 'confirm' && entries && entries.length > 0 && !error ? (
// objectui#12517 — what the publish carries, counted off the same
// per-type grouping the headings below read.
<p data-testid="draft-changes-summary" className="mt-3 px-4 text-xs font-medium text-foreground">
{[
t('preview.changes.summaryItems', { count: entries.length, defaultValue: '{{count}} items' }),
...[...byType.entries()].map(([type, items]) =>
t('preview.changes.summaryType', { n: items.length, type, defaultValue: '{{n}} {{type}}' }),
),
].join(' · ')}
</p>
) : null}
<div
ref={listRef}
tabIndex={-1}
aria-label={t('preview.changes.title', { defaultValue: 'Pending changes' })}
data-testid="draft-changes-list"
className="mt-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-6"
className="mt-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-6 outline-none"
>
{error ? (
<p className="text-sm text-destructive">
Expand Down Expand Up @@ -854,6 +921,7 @@ export function DraftChangesPanel({
})}
</p>
<Button
ref={publishRef}
size="sm"
onClick={() => void onPublish()}
disabled={publishing}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* 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#12517 — the pending-changes sheet opens in one of two postures.
*
* `confirm` (the Studio's Publish): every row expanded, a per-type summary
* line above the list, focus on the footer's Publish all. `review` (the
* Studio's Changes, and the default every other host gets): rows collapsed,
* focus on the list, the footer button present but not focused, no summary.
*
* The CONTROL is the mount the Home page, `DraftPreviewBar` and
* `CommitTimeline` use: no `posture` prop at all. It must read exactly as the
* review posture does.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
// Interpolating, so the summary line reads as an author would see it.
t: (_k: string, o?: Record<string, unknown> & { defaultValue?: string }) =>
(o?.defaultValue ?? _k).replace(/\{\{(\w+)\}\}/g, (_m, name: string) => String(o?.[name] ?? '')),
language: 'en',
}),
}));

import { DraftChangesPanel, type DraftChangesPosture } from '../DraftChangesPanel';

// Three drafts of two types: the summary line has something to count.
const DRAFT_ROWS = [
{ type: 'object', name: 'st_ticket', packageId: 'com.x', label: 'Ticket' },
{ type: 'object', name: 'st_queue', packageId: 'com.x', label: 'Queue' },
{ type: 'page', name: 'st_home', packageId: 'com.x', label: 'Home' },
];

const fetchMock = 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({ drafts: DRAFT_ROWS });
if (/\/meta\/object(\?|$)/.test(url)) return ok([{ name: 'st_queue' }]);
if (/\/meta\/page(\?|$)/.test(url)) return ok([]);
if (url.includes('state=draft')) return ok({ name: 'x', label: 'Draft label' });
return { ok: false, status: 404, json: async () => ({}) };
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
fetchMock.mockClear();
});

function Host({ posture, open = true }: { posture?: DraftChangesPosture; open?: boolean }) {
return (
<DraftChangesPanel
open={open}
onOpenChange={() => {}}
packageId="com.x"
onPublish={() => {}}
{...(posture ? { posture } : {})}
/>
);
}

async function renderHost(posture?: DraftChangesPosture) {
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch);
const utils = render(<Host posture={posture} />);
// Every row classified: each badge has replaced its spinner.
await waitFor(() => expect(screen.getAllByText(/^(New|Update)$/)).toHaveLength(DRAFT_ROWS.length));
return utils;
}

const toggles = () => screen.getAllByTestId('draft-entry-toggle');

function expectReviewPosture() {
for (const toggle of toggles()) expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryAllByTestId('draft-entry-detail')).toHaveLength(0);
expect(screen.getByTestId('draft-changes-list')).toHaveFocus();
// The footer is there — review never hides the way to publish — but unfocused.
const publish = screen.getByTestId('draft-changes-publish');
expect(publish).toBeInTheDocument();
expect(publish).not.toHaveFocus();
expect(screen.queryByTestId('draft-changes-summary')).toBeNull();
}

describe('objectui#12517 — the pending-changes sheet opens in a posture', () => {
it('confirm: every row expanded, the summary line counted by type, focus on Publish all', async () => {
// No frame shows a collapsed row: the expansion lands in the commit that
// first renders the list, not in a passive effect after it is painted
// (under load the observer below fired in that gap, and so would a paint).
let collapsedSeen = 0;
const observer = new MutationObserver(() => {
collapsedSeen += document.querySelectorAll('[data-testid="draft-entry-toggle"][aria-expanded="false"]').length;
});
observer.observe(document.body, { subtree: true, childList: true, attributes: true });
await renderHost('confirm');
observer.disconnect();
expect(collapsedSeen).toBe(0);
expect(screen.getByTestId('draft-changes-panel')).toHaveAttribute('data-posture', 'confirm');
for (const toggle of toggles()) expect(toggle).toHaveAttribute('aria-expanded', 'true');
await waitFor(() => expect(screen.getAllByTestId('draft-entry-detail')).toHaveLength(DRAFT_ROWS.length));
expect(screen.getByTestId('draft-changes-publish')).toHaveFocus();
expect(screen.getByTestId('draft-changes-summary')).toHaveTextContent(/^3 items · 2 object · 1 page$/);
});

it('review: rows collapsed, focus on the list, Publish all present but not focused', async () => {
await renderHost('review');
expect(screen.getByTestId('draft-changes-panel')).toHaveAttribute('data-posture', 'review');
expectReviewPosture();
});

it('CONTROL: a host that passes no posture (Home, DraftPreviewBar, CommitTimeline) gets review', async () => {
await renderHost(undefined);
expect(screen.getByTestId('draft-changes-panel')).toHaveAttribute('data-posture', 'review');
expectReviewPosture();
});

it('confirm: a row the author collapses stays collapsed while the rows classify', async () => {
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch);
render(<Host posture="confirm" />);
await waitFor(() => expect(toggles()).toHaveLength(DRAFT_ROWS.length));
await waitFor(() => expect(toggles()[0]).toHaveAttribute('aria-expanded', 'true'));
fireEvent.click(toggles()[0]);
await waitFor(() => expect(screen.getAllByText(/^(New|Update)$/)).toHaveLength(DRAFT_ROWS.length));
expect(toggles()[0]).toHaveAttribute('aria-expanded', 'false');
expect(toggles()[1]).toHaveAttribute('aria-expanded', 'true');
});

it('the posture is read on each open: Publish then Changes opens collapsed the second time', async () => {
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch);
const { rerender } = render(<Host posture="confirm" />);
await waitFor(() => expect(screen.getByTestId('draft-changes-publish')).toHaveFocus());
for (const toggle of toggles()) expect(toggle).toHaveAttribute('aria-expanded', 'true');

rerender(<Host posture="confirm" open={false} />);
await waitFor(() => expect(screen.queryByTestId('draft-changes-panel')).toBeNull());
await act(async () => {
rerender(<Host posture="review" />);
});
await waitFor(() => expect(screen.getAllByText(/^(New|Update)$/)).toHaveLength(DRAFT_ROWS.length));
expectReviewPosture();
});
});
Loading
Loading