diff --git a/.changeset/11794-studio-navigation-details.md b/.changeset/11794-studio-navigation-details.md new file mode 100644 index 0000000000..3fb85f03ba --- /dev/null +++ b/.changeset/11794-studio-navigation-details.md @@ -0,0 +1,9 @@ +--- +'@object-ui/app-shell': patch +--- + +Studio navigation details (objectui#11794): + +- **The Data pillar's *Advanced* trigger keeps its own name.** With one of its panels open it used to rename itself to that panel ("Validations"), which hid the word that says sibling panels sit behind it. It now always reads *Advanced*, takes the active pill while one of its panels is open, and the menu marks the open panel as the checked item (the items are radio items now, so assistive technology reads which one is open). +- **The Automations rail can be searched.** A search box above the flow list matches a flow's label or its machine name, case-insensitively, and a search that matches nothing says so. A flow's name wraps instead of being cut to a stub. +- **Create app opens the Interfaces pillar**, where the new app lives, instead of leaving its author on the pillar the button was pressed from. The create writes exactly what it wrote before; only where the author lands changes. Leaving a pillar that holds an unsent edit asks first, as a pillar link does. diff --git a/content/docs/guide/console.md b/content/docs/guide/console.md index ccf5133a44..f11b546cbc 100644 --- a/content/docs/guide/console.md +++ b/content/docs/guide/console.md @@ -30,6 +30,8 @@ The console opens at **http://localhost:5180** (the port is fixed in `apps/conso | **Command Palette** | `⌘+K` opens a searchable command bar for quick navigation. | | **Studio Package Scope** | Studio home, metadata counts, quick-create links, and diagnostics follow the selected package. | | **Package-less Flows in Studio** | Flows that belong to no package — such as a clone of a packaged flow, made from Setup › Packaged automation — are listed and edited at `/studio/~org/automations`, reached from the Studio home ("Not in a package") or the package switcher. They open editable, edits save as drafts, and Publish promotes those drafts. | +| **Studio Automations Rail** | The Automations pillar's flow rail has a search box that matches a flow's label or its machine name, case-insensitively. A long flow name wraps instead of being cut off. | +| **Create App in Studio** | In a package that has no app yet, the Studio header's **Create app** saves the new app as a draft and then opens the **Interfaces** pillar, where that app is designed. Leaving a pillar that holds an unsent edit asks first. | | **Read-only Packages in Studio** | The Studio home lists code and installed packages under "Installed (read-only · browsable)". Clicking a card opens the package for browsing (`/studio/:packageId/data`). The card's **Customize with an overlay** link opens the package's metadata directory (`/apps/setup/metadata?package=:packageId`). Whether an item can take an org overlay is decided per metadata type (`allowOrgOverride`), not per package, and the directory marks each type. Unless the runtime reports `features.marketplace: false`, the section also links to the marketplace (`/apps/setup/system/marketplace`), the same route as Home's "Start with a template". | | **Design in Studio** | Workspace admins get a top-bar entry inside a running app that opens its owning package on the Studio design surface. On an interface route — a dashboard, page, or report — it deep-links straight to that surface's design page in the Interfaces pillar (`/studio/:packageId/interfaces?surface=:`, e.g. `surface=page:showcase_crm_workbench`); elsewhere (objects, the app root) it opens the package's Data tab (`/studio/:packageId/data`). These interfaces are authored in Studio — there is no in-page edit panel. | | **Dashboard Refresh** | A dashboard page shows a **Refresh All** button above its widgets, and a dashboard that sets `refreshIntervalSeconds` (Studio's auto-refresh field) re-reads its widgets' data every that many seconds. Widgets re-read in place, so they are not remounted. `0` or no value means no automatic refresh. Known gap: a dataset-bound single-value (KPI) tile does not refresh yet. | @@ -39,13 +41,16 @@ The console opens at **http://localhost:5180** (the port is fixed in `apps/conso ### Object design (Studio Data tab) -Selecting an object in Studio's **Data** pillar (`/studio/:packageId/data`) opens a -tab strip over that object — **Records · Form · Validations · Hooks · Actions · -API · Settings**. Each of Validations, Hooks and Actions is a no-code **config -panel driven by the corresponding metadata**, and each supports **adding** new -entries — no code round-trip required: +Selecting an object in Studio's **Data** pillar (`/studio/:packageId/data`) opens +two tabs over that object, **Records · Form**, and an **Advanced** menu that holds +**Validations · Hooks · Actions · API · Settings** (objectui#5813). The +**Advanced** trigger keeps its own name whichever of those panels is open: the +open panel is the checked item inside the menu, and the trigger shows as +selected while one of its panels is open. Each of Validations, Hooks and Actions +is a no-code **config panel driven by the corresponding metadata**, and each +supports **adding** new entries — no code round-trip required: -| Tab | Edits | Panel | +| Advanced item | Edits | Panel | |-----|-------|-------| | **Validations** | the object's inline `validations[]` (spec `ValidationRuleSchema`) | Master-detail covering **every** rule type — `script`, `cross_field`, `state_machine`, `format`, `json_schema`, `conditional`. The **New** menu opens on common rules in plain words (*End date on or after start date*, *Number can't be negative*, *Reject the save when…*); each writes a working rule, or waits for the condition the author gives. Every rule type is under **Advanced**. A new rule whose type carries a condition (`script`, `cross_field`, `conditional`) is saved only once it has one; a rule's type can still be switched in place. CEL predicates reuse the shared `ConditionBuilder`, fed the object's draft fields. | | **Hooks** | the separate `hook` metadata type targeting this object | Master-detail whose editor is the platform `SchemaForm` **driven by the live `hook` JSONSchema from `/meta/types`**, so its fields and enums always match the running server's contract. | diff --git a/packages/app-shell/src/views/studio-design/AutomationsPillar.railSearch-11794.test.tsx b/packages/app-shell/src/views/studio-design/AutomationsPillar.railSearch-11794.test.tsx new file mode 100644 index 0000000000..c7164f2da5 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/AutomationsPillar.railSearch-11794.test.tsx @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11794 — the Automations rail can be searched, and a flow's name is + * no longer cut to a stub. + * + * Before: a rail of flows with no search, each label in a `truncate` span in a + * narrow rail, so a long name read as its first few words and an ellipsis. Now a + * search box above the list matches the label or the machine name, a search + * that matches nothing says so, and the label wraps. + * + * The REAL `AutomationsPillar`. The rail lists what `loadPackageSurfaces` + * merges: published rows by their label, a draft-only row by its name. The + * runtime status read answers 404 here, which the pillar takes as an older + * backend (no dots): the dots are `railUnboundReason-11281`'s subject. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const PKG = 'com.acme.app'; +const LONG_LABEL = 'Escalate overdue high-priority support cases to the regional on-call manager'; + +const NODES = [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, +]; +const EDGES = [{ id: 'e1', source: 'start', target: 'end' }]; + +/** Published rows (label + name) and one draft-only header (name only). */ +const PUBLISHED = [ + { name: 'nightly_digest', label: 'Nightly digest' }, + { name: 'approve_order', label: 'Approve order' }, + { name: 'escalate_overdue_cases', label: LONG_LABEL }, +]; +const DRAFT_HEADERS = [{ name: 'welcome_new_member' }]; + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async () => [] as Array>), + listDrafts: vi.fn(async () => [] as Array>), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async () => ({ code: null, overlay: null, overlayScope: null, effective: null, editable: true, deletable: true, resettable: false, lock: 'none' })), + getDraft: vi.fn(async (type: string, name: string) => { + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + }), + save: vi.fn(async () => { + throw new Error('this suite saves nothing'); + }), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { AutomationsPillar } from './StudioDesignSurface'; +import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource'; +import { t, tFormat } from '../metadata-admin/i18n'; + +const dataSource = createEmptyDataSource(); +failOnAbsorbedFetchError(); + +beforeEach(() => { + mockClient.list.mockImplementation(async () => + PUBLISHED.map((f) => ({ ...f, type: 'autolaunched', nodes: NODES, edges: EDGES })), + ); + mockClient.listDrafts.mockImplementation(async () => DRAFT_HEADERS); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** The rail (the `nav` holding the search box) once every row has rendered. */ +async function renderRail(): Promise { + render( + + + , + ); + const search = await screen.findByPlaceholderText(t('engine.studio.designer.search', 'en'), undefined, { timeout: 8000 }); + const rail = search.closest('nav') as HTMLElement; + await waitFor(() => expect(within(rail).getByRole('button', { name: /welcome_new_member/ })).toBeInTheDocument(), { + timeout: 8000, + }); + return rail; +} + +/** The rail rows' visible names, in order. */ +function rowNames(rail: HTMLElement): string[] { + return within(rail) + .queryAllByRole('button') + .filter((b) => b.querySelector('svg') && b.title !== t('engine.studio.auto.newTitle', 'en')) + .map((b) => (b.textContent ?? '').trim()); +} + +describe('the Automations rail search (objectui#11794)', () => { + it('control: an empty search lists every flow, published and draft-only alike', async () => { + const rail = await renderRail(); + expect(rowNames(rail)).toEqual(['Nightly digest', 'Approve order', LONG_LABEL, 'welcome_new_member']); + }); + + it('matches the label, case-insensitively', async () => { + const rail = await renderRail(); + fireEvent.change(within(rail).getByPlaceholderText(t('engine.studio.designer.search', 'en')), { + target: { value: 'DIGEST' }, + }); + expect(rowNames(rail)).toEqual(['Nightly digest']); + }); + + it('matches the machine name where the label does not hold it', async () => { + const rail = await renderRail(); + // `approve_order` is in no label: only the name answers it. + fireEvent.change(within(rail).getByPlaceholderText(t('engine.studio.designer.search', 'en')), { + target: { value: 'approve_order' }, + }); + expect(rowNames(rail)).toEqual(['Approve order']); + }); + + it('a search that matches nothing says so, and clearing it brings every flow back', async () => { + const rail = await renderRail(); + const search = within(rail).getByPlaceholderText(t('engine.studio.designer.search', 'en')); + fireEvent.change(search, { target: { value: ' invoice ' } }); + expect(rowNames(rail)).toEqual([]); + expect(within(rail).getByText(tFormat('engine.list.emptyQuery', 'en', { query: 'invoice' }))).toBeInTheDocument(); + // Not the empty-package line: the package HAS flows. + expect(within(rail).queryByText(t('engine.studio.auto.none', 'en'))).toBeNull(); + + fireEvent.change(search, { target: { value: '' } }); + expect(rowNames(rail)).toHaveLength(4); + }); +}); + +describe('a flow’s name in the Automations rail is not cut to a stub (objectui#11794)', () => { + it('the long label is the row’s whole text, in a wrapping span, not a truncating one', async () => { + const rail = await renderRail(); + const row = within(rail).getByRole('button', { name: new RegExp(LONG_LABEL) }); + const label = within(row).getByText(LONG_LABEL); + // jsdom lays nothing out, so the class that decides clipping is what is + // read: `truncate` is `overflow: hidden; text-overflow: ellipsis; + // white-space: nowrap`, the stub the card measured. + expect(label).not.toHaveClass('truncate'); + expect(label).toHaveClass('break-words'); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.advancedKeepsName-11794.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.advancedKeepsName-11794.test.tsx new file mode 100644 index 0000000000..5e91b889fd --- /dev/null +++ b/packages/app-shell/src/views/studio-design/DataPillar.advancedKeepsName-11794.test.tsx @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11794 — the Data pillar's *Advanced* trigger keeps its own name. + * + * The defect: with one of the five power panels open, the trigger renamed + * itself to that panel ("Validations ▾"), so the word that says sibling panels + * sit behind it was gone. Now the trigger always reads *Advanced*; where the + * author is shows as the trigger's active pill (`aria-pressed`) and as the + * checked radio item inside the menu. + * + * The REAL `DataPillar` and the REAL Radix menu. The panels themselves are + * stubbed: only which one is open is under test, not what it draws. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; + +const servedObject = { + name: 'showcase_account', + label: 'Account', + fields: { name: { type: 'text', label: 'Account Name' } }, +}; + +const mockClient = { + list: vi.fn(async () => [{ name: 'showcase_account', label: 'Account' }]), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async () => ({ effective: servedObject, code: servedObject })), + getDraft: vi.fn(async () => null), + save: vi.fn(async () => ({ ok: true })), +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); + +vi.mock('@object-ui/plugin-view', async (importOriginal) => { + const mod = await importOriginal>(); + return { ...mod, ObjectView: () =>
}; +}); + +vi.mock('./ObjectValidationsPanel', () => ({ ObjectValidationsPanel: () =>
})); +vi.mock('./ObjectSettingsPanel', () => ({ ObjectSettingsPanel: () =>
})); + +import { DataPillar } from './StudioDesignSurface'; +import { SurfaceDeepLinkProvider } from './surfaceDeepLinkChannel'; +import { t } from '../metadata-admin/i18n'; + +const ADVANCED = t('engine.studio.data.tab.advanced', 'en'); +const PANELS = ['rules', 'hooks', 'actions', 'api', 'settings'].map((k) => t(`engine.studio.data.tab.${k}`, 'en')); +const VALIDATIONS = t('engine.studio.data.tab.rules', 'en'); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +async function renderPillar() { + render( + + + + + , + ); + await waitFor(() => expect(screen.getByTestId('records-panel')).toBeInTheDocument(), { timeout: 8000 }); + return screen.getByTestId('data-tabs-advanced'); +} + +/** The open menu's radio items, by their visible name, with their checked state. */ +async function menuState(trigger: HTMLElement): Promise> { + await userEvent.click(trigger); + const menu = await screen.findByRole('menu'); + const state: Record = {}; + for (const item of within(menu).getAllByRole('menuitemradio')) { + state[item.textContent ?? ''] = item.getAttribute('aria-checked'); + } + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()); + return state; +} + +describe('the Data pillar’s Advanced trigger keeps its own name (objectui#11794)', () => { + it('control: on Records the trigger reads Advanced, is not pressed, and no panel is checked', async () => { + const trigger = await renderPillar(); + expect(trigger).toHaveTextContent(ADVANCED); + expect(trigger).toHaveAttribute('aria-pressed', 'false'); + const state = await menuState(trigger); + expect(Object.keys(state)).toEqual(PANELS); + expect(Object.values(state).every((checked) => checked === 'false')).toBe(true); + }); + + it('with Validations open the trigger still reads Advanced (not Validations), takes the active pill, and the menu checks Validations', async () => { + const trigger = await renderPillar(); + await userEvent.click(trigger); + await userEvent.click(await screen.findByRole('menuitemradio', { name: VALIDATIONS })); + await waitFor(() => expect(screen.getByTestId('rules-panel')).toBeInTheDocument()); + + // THE PIN: the trigger's own name, and nothing else, whichever panel is open. + expect(trigger).toHaveTextContent(ADVANCED); + expect(trigger).not.toHaveTextContent(VALIDATIONS); + expect(trigger).toHaveAttribute('aria-pressed', 'true'); + + const state = await menuState(trigger); + expect(state[VALIDATIONS]).toBe('true'); + for (const name of PANELS.filter((n) => n !== VALIDATIONS)) expect(state[name]).toBe('false'); + }); + + it('picking a sibling from the menu moves the check and the panel; the trigger name stays', async () => { + const trigger = await renderPillar(); + await userEvent.click(trigger); + await userEvent.click(await screen.findByRole('menuitemradio', { name: VALIDATIONS })); + await waitFor(() => expect(screen.getByTestId('rules-panel')).toBeInTheDocument()); + + const settings = t('engine.studio.data.tab.settings', 'en'); + await userEvent.click(trigger); + await userEvent.click(await screen.findByRole('menuitemradio', { name: settings })); + await waitFor(() => expect(screen.getByTestId('settings-panel')).toBeInTheDocument()); + expect(screen.queryByTestId('rules-panel')).toBeNull(); + expect(trigger).toHaveTextContent(ADVANCED); + + const state = await menuState(trigger); + expect(state[settings]).toBe('true'); + expect(state[VALIDATIONS]).toBe('false'); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.celGate.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.celGate.test.tsx index 9c307f4981..e0d9c94bb4 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.celGate.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.celGate.test.tsx @@ -75,6 +75,11 @@ vi.mock('@object-ui/components', async (importOriginal) => { DropdownMenuItem: (p: any) => ( ), + // objectui#11794 — the Advanced items are radio items (the menu checks the open panel). + DropdownMenuRadioGroup: (p: any) =>
{p.children}
, + DropdownMenuRadioItem: (p: any) => ( + + ), }; }); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.designerRegistryPopulated.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.designerRegistryPopulated.test.tsx index 75ec5d67e9..0d5450d705 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.designerRegistryPopulated.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.designerRegistryPopulated.test.tsx @@ -98,6 +98,11 @@ vi.mock('@object-ui/components', async (importOriginal) => { DropdownMenuItem: (p: Passthrough & { onSelect?: () => void }) => ( ), + // objectui#11794 — the Advanced items are radio items (the menu checks the open panel). + DropdownMenuRadioGroup: (p: Passthrough) =>
{p.children}
, + DropdownMenuRadioItem: (p: Passthrough & { onSelect?: () => void }) => ( + + ), }; }); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.hiddenSystemFields-11780.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.hiddenSystemFields-11780.test.tsx index 6f55f2d5e6..f20d06a79c 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.hiddenSystemFields-11780.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.hiddenSystemFields-11780.test.tsx @@ -303,7 +303,8 @@ describe.each(CASES)('Data pillar on an object with $title (objectui#11780)', ({ const before = seenColumns[seenColumns.length - 1]; await userEvent.click(await screen.findByTestId('data-tabs-advanced')); - await userEvent.click(await screen.findByRole('menuitem', { name: 'Settings' })); + // A radio item since objectui#11794: the menu checks the open panel. + await userEvent.click(await screen.findByRole('menuitemradio', { name: 'Settings' })); fireEvent.click(await screen.findByRole('button', { name: 'rename-object' })); fireEvent.click(screen.getByRole('button', { name: 'Records' })); await waitFor(() => expect(screen.getByTestId('grid-columns')).toBeInTheDocument(), { timeout: 4000 }); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.inspectorBelongsToView-11781.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.inspectorBelongsToView-11781.test.tsx index eaa1daca8e..1564973acc 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.inspectorBelongsToView-11781.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.inspectorBelongsToView-11781.test.tsx @@ -66,6 +66,11 @@ vi.mock('@object-ui/components', async (importOriginal) => { DropdownMenuItem: (p: PassthroughProps) => ( ), + // objectui#11794 — the Advanced items are radio items (the menu checks the open panel). + DropdownMenuRadioGroup: (p: PassthroughProps) =>
{p.children}
, + DropdownMenuRadioItem: (p: PassthroughProps) => ( + + ), }; }); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.panelGate.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.panelGate.test.tsx index 7f7bfe0a41..74bef95e2c 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.panelGate.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.panelGate.test.tsx @@ -76,6 +76,11 @@ vi.mock('@object-ui/components', async (importOriginal) => { DropdownMenuItem: (p: any) => ( ), + // objectui#11794 — the Advanced items are radio items (the menu checks the open panel). + DropdownMenuRadioGroup: (p: any) =>
{p.children}
, + DropdownMenuRadioItem: (p: any) => ( + + ), }; }); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.createAppOpensInterfaces-11794.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.createAppOpensInterfaces-11794.test.tsx new file mode 100644 index 0000000000..b3e4a0f0d8 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.createAppOpensInterfaces-11794.test.tsx @@ -0,0 +1,226 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11794 — after *Create app*, Studio opens the Interfaces pillar, where + * the new app lives, instead of leaving its author on the pillar the header + * button was pressed from. + * + * Navigation only. The create's one write — `save('app', NAME, skeleton, + * { mode: 'draft', packageId })` — is pinned byte for byte against + * `buildAppSkeleton`, so the card changes where the author lands and nothing + * the server receives. + * + * The departure goes through the surface's own leave guard, the one a pillar + * link uses (objectui#2600): an unsent edit on the pillar being left asks + * first, and "stay" keeps the author there with the app already created. + * + * Rendered through the real `StudioDesignSurface` + `AccessPillar` + + * `PermissionMatrixEditPage` stack, as `pillarNavGuard` is, so the dirty report + * crosses every real seam; only data I/O and heavy docks are doubled. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; + +let clientImpl: ReturnType; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useMetadataClient: () => clientImpl, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }], + }), + }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + fetchPackages: vi.fn(async () => [{ id: 'app.a', name: 'App A', writable: true, namespace: 'a' }]), + }; +}); + +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { buildAppSkeleton } from './skeletons'; + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +(globalThis as { ResizeObserver?: unknown }).ResizeObserver = + (globalThis as { ResizeObserver?: unknown }).ResizeObserver ?? + class { + observe() {} + unobserve() {} + disconnect() {} + }; + +interface Server { + /** Every `save` call, as the surface made it. */ + saves: Array<[string, string, unknown, unknown]>; + /** The app draft a create left behind, which the Interfaces pillar then reads. */ + appDraft: { name: string; body: Record } | null; +} + +let server: Server; + +function makeClient(s: Server) { + return { + list: vi.fn(async (type: string) => { + if (type === 'permission') return [{ name: 'set_a', label: 'Set A' }]; + if (type === 'object') return [{ name: 'a_account' }]; + return []; + }), + listDrafts: vi.fn(async (opts?: { type?: string }) => + opts?.type === 'app' && s.appDraft ? [{ name: s.appDraft.name }] : [], + ), + layered: vi.fn(async (_type: string, name: string) => ({ + effective: name === 'set_a' ? { name: 'set_a', label: 'Set A', objects: { a_account: { allowRead: true } }, fields: {} } : null, + code: null, + overlay: null, + overlayScope: null, + })), + getDraft: vi.fn(async (type: string, name: string) => + type === 'app' && s.appDraft?.name === name ? { type, name, item: s.appDraft.body } : null, + ), + get: vi.fn(async (type: string) => (type === 'object' ? { fields: [{ name: 'name', label: 'Name' }] } : null)), + save: vi.fn(async (type: string, name: string, body: Record, opts: unknown) => { + s.saves.push([type, name, body, opts]); + if (type === 'app') s.appDraft = { name, body }; + return body; + }), + }; +} + +let confirmSpy: ReturnType; +/** Every pathname the router has rendered, in order. */ +let pathnames: string[]; + +function LocationProbe() { + const { pathname } = useLocation(); + if (pathnames[pathnames.length - 1] !== pathname) pathnames.push(pathname); + return null; +} + +beforeEach(() => { + server = { saves: [], appDraft: null }; + clientImpl = makeClient(server); + pathnames = []; + confirmSpy = vi.fn(); + window.confirm = confirmSpy as unknown as typeof window.confirm; + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => [] })) as unknown as typeof fetch); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +async function renderOn(pillar: 'access' | 'interfaces') { + render( + + + + } /> + + , + ); + const toolbar = await screen.findByRole('banner'); + await waitFor(() => expect(within(toolbar).getByRole('button', { name: 'Create app' })).toBeInTheDocument()); + if (pillar === 'access') await screen.findByText('a_account'); + return toolbar; +} + +/** Create "Field Service" from the toolbar, through the real dialog. */ +async function createApp(toolbar: HTMLElement) { + fireEvent.click(within(toolbar).getByRole('button', { name: 'Create app' })); + const dialog = await screen.findByRole('dialog'); + fireEvent.change(dialog.querySelectorAll('input')[0], { target: { value: 'Field Service' } }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Save as draft' })); + await waitFor(() => expect(server.saves.filter(([type]) => type === 'app')).toHaveLength(1)); +} + +/** The one write, as it was before this card: the package's objects seeded into the nav. */ +function expectTheCreateWriteUnchanged() { + expect(server.saves).toEqual([ + [ + 'app', + 'field_service', + buildAppSkeleton('field_service', 'Field Service', [{ name: 'a_account' }]), + { mode: 'draft', packageId: 'app.a' }, + ], + ]); +} + +/** Flip a matrix cell so the Access pillar reports an unsent edit. */ +function dirtyTheMatrix() { + const row = screen.getByText('a_account').closest('tr')!; + fireEvent.click(within(row).getByRole('button', { name: 'None' })); +} + +describe('Create app opens the Interfaces pillar (objectui#11794)', () => { + it('from another pillar, a clean create lands on Interfaces, which reads the new app; the write is unchanged', async () => { + const toolbar = await renderOn('access'); + await createApp(toolbar); + + await waitFor(() => expect(pathnames[pathnames.length - 1]).toBe('/studio/app.a/interfaces')); + expect(confirmSpy).not.toHaveBeenCalled(); + // The Interfaces pillar resolved the app the create left as a draft. + await waitFor(() => expect(clientImpl.getDraft).toHaveBeenCalledWith('app', 'field_service')); + expectTheCreateWriteUnchanged(); + }); + + it('with an unsent edit on the pillar being left, it asks first; "stay" keeps the author there, app created', async () => { + const toolbar = await renderOn('access'); + dirtyTheMatrix(); + confirmSpy.mockReturnValueOnce(false); + await createApp(toolbar); + + await waitFor(() => expect(confirmSpy).toHaveBeenCalledTimes(1)); + expect(pathnames).toEqual(['/studio/app.a/access']); + // Still on Access, the edit still there. + const row = screen.getByText('a_account').closest('tr')!; + expect(within(row).getByRole('checkbox', { name: 'a_account Read' })).not.toBeChecked(); + expectTheCreateWriteUnchanged(); + }); + + it('with an unsent edit, "leave" lands on Interfaces', async () => { + const toolbar = await renderOn('access'); + dirtyTheMatrix(); + confirmSpy.mockReturnValueOnce(true); + await createApp(toolbar); + + await waitFor(() => expect(pathnames[pathnames.length - 1]).toBe('/studio/app.a/interfaces')); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expectTheCreateWriteUnchanged(); + }); + + it('control: created from Interfaces itself, nothing navigates and nothing asks', async () => { + const toolbar = await renderOn('interfaces'); + await createApp(toolbar); + + await waitFor(() => expect(clientImpl.getDraft).toHaveBeenCalledWith('app', 'field_service')); + expect(pathnames).toEqual(['/studio/app.a/interfaces']); + expect(confirmSpy).not.toHaveBeenCalled(); + expectTheCreateWriteUnchanged(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.servedDraftTakenAsIs-10765.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.servedDraftTakenAsIs-10765.test.tsx index fd80237168..90d88856ba 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.servedDraftTakenAsIs-10765.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.servedDraftTakenAsIs-10765.test.tsx @@ -215,7 +215,8 @@ describe('StudioDesignSurface — every load site takes the served draft as-is, // The first object auto-opens; the Settings panel lives under "Advanced". const advanced = await screen.findByTestId('data-tabs-advanced', undefined, { timeout: 8000 }); await userEvent.click(advanced); - await userEvent.click(await screen.findByRole('menuitem', { name: 'Settings' })); + // A radio item since objectui#11794: the menu checks the open panel. + await userEvent.click(await screen.findByRole('menuitemradio', { name: 'Settings' })); // The dial is the shared Select's trigger (objectui#11865), so what it shows // is its text: a button's `value` is '' whatever the dial holds. diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index ee3ad3cf83..8a64971d56 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -44,6 +44,8 @@ import { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, Badge, Separator, Sheet, @@ -1371,13 +1373,22 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React setAppDraftPending(label); setAppCreating(false); onDraftSaved(); + // objectui#11794 — the app lives in the Interfaces pillar, so that is + // where its author lands, instead of being left on the pillar the + // header button was pressed from. Navigation only: the save above is + // the whole write. Same door as a pillar link: an unsent edit on the + // pillar being left asks first, and a "stay" keeps the author there + // with the app already created. + if (tab !== 'interfaces' && confirmLeavePillar()) { + shellNavigate(`/studio/${scopeSegment}/interfaces`); + } } catch (e) { setAppErr(formatMetadataError(e)); } finally { setAppBusy(false); } }, - [appAddObjects, loadPackageObjects, shellClient, packageId, locale, onDraftSaved], + [appAddObjects, loadPackageObjects, shellClient, packageId, locale, onDraftSaved, tab, confirmLeavePillar, shellNavigate, scopeSegment], ); // objectui#5800, fixed in passing — the topbar's app detection used to @@ -5146,9 +5157,13 @@ export function DataPillar({ {tab.label} ))} - {/* "Advanced" — the five power panels (objectui#5813). When one - is open the trigger wears its NAME and the active pill, so - the collapsed default never hides where you are. */} + {/* "Advanced" — the five power panels (objectui#5813). The + trigger keeps its own name whichever panel is open + (objectui#11794): renamed to the panel ("Validations ▾") it + hid the one word that says sibling panels sit behind it. + Where you are still shows twice — the trigger takes the + active pill while one of its panels is open, and the menu + marks that panel as the checked radio item. */} - {advancedDataTabs.map((tab) => ( - selectViewMode(tab.key)} - className={viewMode === tab.key ? 'font-medium text-primary' : undefined} - > - {tab.label} - - ))} + {/* The group only marks which item is checked; each item + selects its own panel, as the plain items did. */} + + {advancedDataTabs.map((tab) => ( + selectViewMode(tab.key)}> + {tab.label} + + ))} +
@@ -5788,6 +5801,13 @@ export function AutomationsPillar({ const layoutRowRef = React.useRef(null); const configFolded = useIsNarrowerThan(layoutRowRef, AUTOMATIONS_CONFIG_FOLD_WIDTH); const [flows, setFlows] = React.useState([]); + // objectui#11794 — the rail's search: a case-insensitive match on the label + // or the machine name. Ephemeral (AGENTS.md #8): nobody expects it back. + const [railQuery, setRailQuery] = React.useState(''); + const railNeedle = railQuery.trim().toLowerCase(); + const shownFlows = railNeedle + ? flows.filter((f) => f.label.toLowerCase().includes(railNeedle) || f.name.toLowerCase().includes(railNeedle)) + : flows; // objectui#7255 — same live-pulse subscription as the sibling rails; this // one only replaces the flow LIST, so it needs no edit-buffer hold either. const metadataRefreshNonce = useMetadataRefreshNonce(); @@ -6230,37 +6250,52 @@ export function AutomationsPillar({ )}