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
10 changes: 10 additions & 0 deletions .changeset/11591-org-flows-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

Studio's "Organization flows" page no longer says its drafts publish atomically, and a deep link to a flow that is not on the page no longer says no metadata designers are registered (objectui#11591).

On the package-less page (`/studio/~org/automations`), the pending-changes sheet read "Publishing releases the 1 pending draft of this package atomically." That page has no package, and its Publish promotes each draft by itself: a draft that fails stays pending while the others go live. The sheet now says so there, through a new `preview.changes.confirmNoteSeparate` plural family in all ten language packs. A package's sheet keeps its sentence unchanged. `DraftChangesPanel` picks the sentence from its `packageId` prop: with a package, the atomic sentence; without one, the per-draft sentence.

On the Automations pillar, the canvas chip read the designer registry for the open flow's type. With no flow open (a deep link naming a flow the list does not hold, or an empty list), it found none and showed "No metadata designers are registered in this session…" beside the right message, on a page whose flow designer is registered. The chip now reads the registry for the pillar's own type, `flow`, as the configuration panel beside it already did. The notice still shows when no designer is registered, whether or not a flow is open.
28 changes: 22 additions & 6 deletions packages/app-shell/src/preview/DraftChangesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,12 @@ function EntryDetail({ entry }: { entry: DraftChangeEntry }) {
export interface DraftChangesPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** When set, list only pending drafts belonging to this package (Studio is package-scoped). */
/**
* When set, list only pending drafts belonging to this package (Studio is
* package-scoped), and the confirm note says they publish as one atomic
* batch. Without one, the note says each draft publishes by itself
* (objectui#11591).
*/
packageId?: string | null;
/**
* Narrows the listed drafts further, for a host whose scope the `_drafts`
Expand Down Expand Up @@ -627,11 +632,22 @@ export function DraftChangesPanel({
{onPublish && (entries?.length ?? 0) > 0 && !error && (
<div className="mt-auto flex flex-col gap-2 border-t px-4 py-3">
<p className="text-xs text-muted-foreground">
{t('preview.changes.confirmNote', {
count: entries!.length,
defaultValue:
'Publishing releases all {{count}} pending drafts of this package atomically.',
})}
{/* objectui#11591 — the note states what THIS scope's Publish
does. Only a package publishes as one atomic batch (`POST
/packages/:id/publish-drafts`); with no package there is no
batch door, so the caller promotes each draft by itself and a
draft that fails stays pending while the others go live. */}
{packageId
? t('preview.changes.confirmNote', {
count: entries!.length,
defaultValue:
'Publishing releases all {{count}} pending drafts of this package atomically.',
})
: t('preview.changes.confirmNoteSeparate', {
count: entries!.length,
defaultValue:
'Publishing releases the {{count}} pending drafts one at a time: a draft that fails stays pending while the others go live.',
})}
</p>
<Button
size="sm"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* 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#11591 — the pending-changes sheet's confirm note states what THAT
* scope's Publish does.
*
* A package's sheet publishes through `POST /packages/:id/publish-drafts`, one
* atomic pass, and its note says so. The package-less "Organization flows"
* page (objectui#11553) has no package to publish as a batch: its Publish
* promotes each draft by reference, one request per draft, and a draft that
* fails stays pending while the others go live. The sheet there used to read
* "Publishing releases the 1 pending draft of this package atomically." — a
* package it does not have, and an atomicity it does not deliver.
*
* Rendered through a REAL `I18nProvider` over the shipped `en` pack, so the
* sentence asserted is the one an author reads, not the call site's inline
* `defaultValue`. That every locale's copy moved with `en` is pinned beside
* the packs, in `packages/i18n/src/__tests__/confirmNoteSeparate-11591.test.ts`.
*/

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

import { DraftChangesPanel } from '../DraftChangesPanel';

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

type Draft = { type: string; name: string; packageId: string | null };

/** Answer the `_drafts` feed with `drafts`, and every other read as absent. */
function serveDrafts(drafts: Draft[]) {
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(drafts);
if (/\/meta\/flow(\?|$)/.test(url)) return ok([]);
return { ok: false, status: 404, json: async () => ({}) };
}) as unknown as typeof fetch;
}

function renderSheet(packageId: string | null) {
return render(
<I18nProvider
instance={createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false })}
persistLanguage={false}
>
<DraftChangesPanel open onOpenChange={() => {}} packageId={packageId} onPublish={vi.fn()} />
</I18nProvider>,
);
}

/** The confirm footer's note — the paragraph right above the publish button. */
async function confirmNote(): Promise<string> {
const button = await screen.findByTestId('draft-changes-publish', undefined, { timeout: 4000 });
const note = button.parentElement?.querySelector('p');
if (!note) throw new Error('the confirm footer rendered no note');
return note.textContent ?? '';
}

describe('the confirm note says what this scope publishes (objectui#11591)', () => {
it('package-less, one draft: no package, no atomicity', async () => {
serveDrafts([{ type: 'flow', name: 'qa_urgent_alert_clone', packageId: null }]);
renderSheet(null);

const note = await confirmNote();
expect(note).toBe('Publishing releases the 1 pending draft on its own: if it fails, it stays pending.');
expect(note).not.toMatch(/atomic/i);
expect(note).not.toMatch(/package/i);
});

it('package-less, several drafts: each separately, and a partial publish is possible', async () => {
serveDrafts([
{ type: 'flow', name: 'qa_urgent_alert_clone', packageId: null },
{ type: 'flow', name: 'qa_daily_digest_clone', packageId: null },
]);
renderSheet(null);

const note = await confirmNote();
expect(note).toBe(
'Publishing releases the 2 pending drafts one at a time: a draft that fails stays pending while the others go live.',
);
expect(note).not.toMatch(/atomic/i);
expect(note).not.toMatch(/package/i);
});

it('control — a package’s sheet keeps its sentence, byte for byte', async () => {
serveDrafts([{ type: 'flow', name: 'showcase_daily_digest', packageId: 'com.example.showcase' }]);
renderSheet('com.example.showcase');

await waitFor(async () =>
expect(await confirmNote()).toBe('Publishing releases the 1 pending draft of this package atomically.'),
);
});

it('control — a package’s sheet, several drafts', async () => {
serveDrafts([
{ type: 'flow', name: 'showcase_daily_digest', packageId: 'com.example.showcase' },
{ type: 'flow', name: 'showcase_urgent_task_alert', packageId: 'com.example.showcase' },
]);
renderSheet('com.example.showcase');

expect(await confirmNote()).toBe('Publishing releases all 2 pending drafts of this package atomically.');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -314,4 +314,30 @@ describe('Automations pillar — the fourth site, found by sweeping (#6795 C, si
expect(document.body.textContent ?? '').not.toContain('click a node to configure');
expect(document.body.textContent ?? '').not.toContain('Click a node on the canvas');
});

/**
* objectui#11591 — the notice keys on the REGISTRY, not on an open flow: with
* no flow open (a deep link naming one the rail does not hold) it still
* shows here, where the registry is empty, and it does not show where the
* registry is populated — that half is pinned in
* `StudioDesignSurface.packageLessFlows-11553.test.tsx`.
*/
it('with no flow open, still names the reason — the registry, not the selection, decides', async () => {
assertRegistriesEmptyWithControl();
mockClient.layered.mockClear();

render(
<MemoryRouter initialEntries={['/studio/~org/automations?surface=flow%3Anobody_has_this']}>
<AutomationsPillar packageId={null} />
</MemoryRouter>,
);

await screen.findByText('The link names flow “nobody_has_this”, which is not here.', undefined, { timeout: 4000 });
const hits = screen.getAllByText(
'No metadata designers are registered in this session, so this flow cannot be designed here.',
);
// The canvas header chip AND the rail, as with a flow open.
expect(hits.length).toBe(2);
expect(mockClient.layered).not.toHaveBeenCalledWith('flow', expect.anything());
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.f

import { StudioDesignSurface } from './StudioDesignSurface';
import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource';
import { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { getMetadataPreview, registerMetadataPreview } from '../metadata-admin/preview-registry';
import { registerMetadataInspector } from '../metadata-admin/inspector-registry';
import { FlowPreview } from '../metadata-admin/previews/FlowPreview';
import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector';
Expand Down Expand Up @@ -379,3 +379,65 @@ describe('the package-less scope reviews and publishes exactly its own drafts (o
expect(server.packageBatches).toEqual([]);
});
});

/**
* objectui#11591 — the "no designers" notice keys on the designer registry,
* never on the absence of an open flow. On a deep-link miss and on an empty
* list no flow is open, and the canvas chip used to read the registry for the
* OPEN flow's type — none — so it said "No metadata designers are registered
* in this session…" on a page whose designers are registered (this file
* registers `FlowPreview` / `FlowInspector` above, and the controls below read
* the registry back). That the notice still shows when the registry really is
* empty, open flow or none, is pinned beside its other sites, in
* `StudioDesignSurface.designerRegistryMissing.test.tsx`.
*/
describe('a deep-link miss and an empty list show no "no designers" notice (objectui#11591)', () => {
const NO_DESIGNERS = 'No metadata designers are registered';

it('the measured miss: the package-less page, a link naming a packaged flow', async () => {
renderAt('/studio/~org/automations?surface=flow%3Ashowcase_urgent_task_alert');

await screen.findByText('The link names flow “showcase_urgent_task_alert”, which is not here.', undefined, {
timeout: 8000,
});
// Control: the designers are registered, so the notice would be false.
expect(getMetadataPreview('flow')).toBe(FlowPreview);
expect(document.body.textContent).not.toContain(NO_DESIGNERS);
expect(screen.getByText('Visual orchestration · click a node to configure')).toBeInTheDocument();
// No flow was opened in the missing one's place.
expect(mockClient.layered).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: /QA urgent alert clone/ })).toBeInTheDocument();
});

it('a package page’s miss, too', async () => {
renderAt(`/studio/${SHOWCASE}/automations?surface=flow%3Anobody_has_this`);

await screen.findByText('The link names flow “nobody_has_this”, which is not here.', undefined, { timeout: 8000 });
expect(getMetadataPreview('flow')).toBe(FlowPreview);
expect(document.body.textContent).not.toContain(NO_DESIGNERS);
});

it('an empty list shows the rail’s own empty state — no miss, no "no designers" notice', async () => {
server.active.delete('flow/qa_urgent_alert_clone');
renderAt('/studio/~org/automations');

await screen.findByText(
'No flows outside a package yet. Clone a packaged flow in Setup › Packaged automation to edit the copy here.',
undefined,
{ timeout: 8000 },
);
expect(screen.getByText('Select an automation')).toBeInTheDocument();
expect(getMetadataPreview('flow')).toBe(FlowPreview);
expect(document.body.textContent).not.toContain(NO_DESIGNERS);
expect(document.body.textContent).not.toContain('The link names flow');
expect(mockClient.layered).not.toHaveBeenCalled();
});

it('control: an open flow on the same page reads the same chip', async () => {
renderAt('/studio/~org/automations');
await openedFlow('qa_urgent_alert_clone');

expect(screen.getByText('Visual orchestration · click a node to configure')).toBeInTheDocument();
expect(document.body.textContent).not.toContain(NO_DESIGNERS);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4584,7 +4584,12 @@ export function AutomationsPillar({
// offer a way to author the first one (mirrors the object/app creators).
const [creating, setCreating] = React.useState(false);
const [createBusy, setCreateBusy] = React.useState(false);
const Preview = getMetadataPreview(current?.type ?? '');
// objectui#11591 — keyed on the pillar's one type, as the inspector beside
// it is, never on the open flow's: with no flow open (a deep link naming one
// this rail does not hold, or an empty rail) a selection-keyed read found no
// designer and the canvas chip below said none were registered, on a page
// whose designers are.
const Preview = getMetadataPreview('flow');
const inspector = getMetadataInspector('flow');
const isEditable = !!Preview;
// objectui#6795 part C — the FOURTH site, found by sweeping past the three the
Expand Down
Loading
Loading