diff --git a/.changeset/11865-o-studio-surface-shared-select.md b/.changeset/11865-o-studio-surface-shared-select.md new file mode 100644 index 0000000000..95b466b2c8 --- /dev/null +++ b/.changeset/11865-o-studio-surface-shared-select.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +--- + +Five of the Studio design surface's pickers use the shared `Select`, the control the rest of Studio picks with (objectui#11865, the design surface's part of that card): in the navigation editor's item inspector, *Link object*, the target picker of a page, dashboard, report, action, component or doc entry (its *Doc page* and *Book* included) and a URL entry's *Open in*; in *New object*, the record sharing (OWD) choice; and in *New automation*, the trigger under *Advanced*. + +The five were browser-native selects, so they looked and behaved differently from Studio's other dropdowns. They now use the shared Radix `Select`: the same trigger, dropdown and keyboard behaviour. + +What they write is unchanged. Each object, target and *Open in* choice writes the same navigation entry as before, and "Choose" still unbinds the entry by removing its target key. Each record sharing choice saves the same `sharingModel` on the new object's draft, and each trigger saves the same Start node on the new flow, "Choose later on the Start node" still saving none. Re-picking the shown option writes nothing. Each picker keeps the name its label gave the native select; *Link object* had none and has none. None of the five has a read-only state: a read-only package closes navigation editing and offers neither *New* entry, as before. + +One display change: an entry whose object, or whose *Open in*, is a value the picker does not offer now shows that value, listed first. The native select showed "Choose object" or "Same tab" instead, which is not what the entry holds. + +**Clause-②: no.** No published face moves: the package entry exports the same names, the surface's components take the same props, and no i18n key is added. What moves is the five controls' own markup, described above. diff --git a/packages/app-shell/src/views/studio-design/InterfacesPillar.heldWidget-11910.test.tsx b/packages/app-shell/src/views/studio-design/InterfacesPillar.heldWidget-11910.test.tsx index 506f46fb13..3456bbdb3d 100644 --- a/packages/app-shell/src/views/studio-design/InterfacesPillar.heldWidget-11910.test.tsx +++ b/packages/app-shell/src/views/studio-design/InterfacesPillar.heldWidget-11910.test.tsx @@ -294,11 +294,9 @@ describe('a new dashboard widget is held until it is bound (objectui#11910)', () await addMetric(); fireEvent.click(screen.getByTitle(/^Edit navigation/)); fireEvent.click(await screen.findByRole('button', { name: /Add nav item/ }, { timeout: 8000 })); - const picker = screen - .getAllByRole('combobox') - .find((s) => within(s).queryByRole('option', { name: new RegExp(TASK.name) })); - expect(picker, 'the new entry\'s inspector offers the object to bind it to').toBeTruthy(); - fireEvent.change(picker!, { target: { value: TASK.name } }); + // *Link object* is the shared `Select` since objectui#11865: open its list and pick. + fireEvent.keyDown(screen.getByTestId('nav-link-object'), { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: new RegExp(`\\(${TASK.name}\\)$`) })); await waitFor(() => expect(mockClient.save.mock.calls.filter((c) => c[0] === 'app')).toHaveLength(1), { timeout: 4000, }); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveInFlight-11204.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveInFlight-11204.test.tsx index 10e86b330e..dc63d8571a 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveInFlight-11204.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveInFlight-11204.test.tsx @@ -379,11 +379,11 @@ describe('Interfaces nav autosave — the same claim, in place of its edit gener // Add here is bound to the fixture's object in its inspector, and the rail // shows it by that object's name. const addedItemsInRail = () => within(rail()).queryAllByRole('button', { name: new RegExp(OBJECT.name) }).length; - const addBoundItem = () => { + const addBoundItem = async () => { fireEvent.click(screen.getByRole('button', { name: /Add nav item/ })); - const picker = screen.getAllByRole('combobox').find((s) => within(s).queryByRole('option', { name: new RegExp(OBJECT.name) })); - if (!picker) throw new Error('the new entry\'s inspector offers no object to bind it to'); - fireEvent.change(picker, { target: { value: OBJECT.name } }); + // *Link object* is the shared `Select` since objectui#11865: open its list and pick. + fireEvent.keyDown(screen.getByTestId('nav-link-object'), { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: new RegExp(`\\(${OBJECT.name}\\)$`) })); }; const navEditingOpen = () => screen.queryAllByRole('button', { name: /Add nav item/ }).length > 0; const savedNavEntries = () => @@ -400,9 +400,9 @@ describe('Interfaces nav autosave — the same claim, in place of its edit gener await screen.findByRole('button', { name: /Add nav item/ }, { timeout: 8000 }); const release = holdNextSave(); - addBoundItem(); + await addBoundItem(); await screen.findByTestId('nav-autosaving', undefined, { timeout: 8000 }); - addBoundItem(); + await addBoundItem(); expect(addedItemsInRail()).toBe(2); // Undo it: the buffer is back to exactly what the save in flight carries. const rows = within(rail()).getAllByRole('button', { name: new RegExp(OBJECT.name) }); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveSwitch-11232.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveSwitch-11232.test.tsx index a40b75e4a7..bec771f8de 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveSwitch-11232.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveSwitch-11232.test.tsx @@ -464,11 +464,11 @@ describe('Studio surface — a package switch over an unsaved nav edit (objectui fireEvent.click(screen.getByTitle(/^Edit navigation/)); await screen.findByRole('button', { name: /Add nav item/ }, SLOW); } - const addItem = (objectName: string = TASK.name) => { + const addItem = async (objectName: string = TASK.name) => { fireEvent.click(screen.getByRole('button', { name: /Add nav item/ })); - const picker = screen.getAllByRole('combobox').find((s) => within(s).queryByRole('option', { name: new RegExp(objectName) })); - if (!picker) throw new Error('the new entry\'s inspector offers no object to bind it to'); - fireEvent.change(picker, { target: { value: objectName } }); + // *Link object* is the shared `Select` since objectui#11865: open its list and pick. + fireEvent.keyDown(screen.getByTestId('nav-link-object'), { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: new RegExp(`\\(${objectName}\\)$`) })); }; async function switchTo(name: RegExp, confirmed: boolean): Promise { fireEvent.click(await screen.findByTitle('Switch / create package', undefined, SLOW)); @@ -486,7 +486,7 @@ describe('Studio surface — a package switch over an unsaved nav edit (objectui render(surfaceTree()); await screen.findByRole('button', { name: /Landing menu/ }, SLOW); await openEditing(); - addItem(); + await addItem(); expect(addedItems()).toBe(1); await switchTo(/Beta/, true); @@ -501,7 +501,7 @@ describe('Studio surface — a package switch over an unsaved nav edit (objectui // The control: an edit made after the switch saves normally, to the new package's app. await openEditing(); - addItem(BETA_ITEM.name); + await addItem(BETA_ITEM.name); await waitFor(() => expect(server.saves.filter((s) => s.type === 'app')).toHaveLength(1), SLOW); expect(server.saves.map((s) => [s.type, s.name, s.packageId])).toEqual([['app', 'beta_app', PKG_B]]); expect(savedNav(server.saves[0].body)).toEqual(['Board menu', { id: 'nav_item_2' }]); @@ -511,7 +511,7 @@ describe('Studio surface — a package switch over an unsaved nav edit (objectui render(surfaceTree()); await screen.findByRole('button', { name: /Landing menu/ }, SLOW); await openEditing(); - addItem(); + await addItem(); const release = holdLoad('app', 'beta_app'); await switchTo(/Beta/, true); @@ -549,7 +549,7 @@ describe('Studio surface — a package switch over an unsaved nav edit (objectui render(surfaceTree()); await screen.findByRole('button', { name: /Landing menu/ }, SLOW); await openEditing(); - addItem(); + await addItem(); await switchTo(/Beta/, false); expect(confirmSpy).toHaveBeenCalledTimes(1); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.createObjectOwd.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.createObjectOwd.test.tsx index 7faf6749f6..a2e9c1e7de 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.createObjectOwd.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.createObjectOwd.test.tsx @@ -43,6 +43,22 @@ vi.mock('./packages-io', async (importOriginal) => { }); import { DataPillar } from './StudioDesignSurface'; +import { t } from '../metadata-admin/i18n'; + +/** + * A model's label in the create dialog: the Settings tab's own string, read by + * its key. The picker is the shared `Select` since objectui#11865, so what it + * holds is read off its trigger and its list, by label. + */ +const modelLabel = (key: string) => t(`engine.studio.settings.${key}`, 'en'); + +/** Open the record-sharing picker and return the labels it lists, in order. */ +async function listedModels(trigger: HTMLElement): Promise { + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + return within(await screen.findByRole('listbox')) + .getAllByRole('option') + .map((o) => o.textContent ?? ''); +} beforeEach(() => { vi.clearAllMocks(); @@ -93,8 +109,8 @@ function savedBody(): Record { describe('新建对象 asks for the OWD (objectui#5418)', () => { it('offers the baseline, defaulted to the platform’s own recommended value', async () => { - const select = (await openCreateDialog()) as HTMLSelectElement; - expect(select.value).toBe('private'); + const trigger = await openCreateDialog(); + expect(trigger.textContent).toBe(modelLabel('sharingPrivate')); }); it('offers exactly the three models a brand-new object can author', async () => { @@ -103,10 +119,13 @@ describe('新建对象 asks for the OWD (objectui#5418)', () => { // `security-owd-unset` publish wall for the // `security-controlled-by-parent-no-relation` one. The Settings tab keeps // all four, where the object may since have gained a master-detail field. - const select = (await openCreateDialog()) as HTMLSelectElement; - const values = [...select.options].map((o) => o.value); - expect(values).toEqual(['private', 'public_read', 'public_read_write']); - expect(values).not.toContain('controlled_by_parent'); + const labels = await listedModels(await openCreateDialog()); + expect(labels).toEqual([ + modelLabel('sharingPrivate'), + modelLabel('sharingPublicRead'), + modelLabel('sharingPublicReadWrite'), + ]); + expect(labels).not.toContain(modelLabel('sharingControlledByParent')); }); it('saves the accepted default as an EXPLICIT baseline on the draft', async () => { @@ -120,8 +139,9 @@ describe('新建对象 asks for the OWD (objectui#5418)', () => { }); it('saves a changed choice instead of the default', async () => { - const select = await openCreateDialog(); - fireEvent.change(select, { target: { value: 'public_read' } }); + const trigger = await openCreateDialog(); + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + fireEvent.click(within(await screen.findByRole('listbox')).getByRole('option', { name: modelLabel('sharingPublicRead') })); fillAndSubmit('Visit'); await waitFor(() => expect(mockClient.save).toHaveBeenCalled()); expect(savedBody()).toHaveProperty('sharingModel', 'public_read'); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.flowPresets-11861.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.flowPresets-11861.test.tsx index eecd238718..d0d3dabd47 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.flowPresets-11861.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.flowPresets-11861.test.tsx @@ -23,7 +23,7 @@ import '@testing-library/jest-dom/vitest'; import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup, waitFor, act } from '@testing-library/react'; +import { render, screen, fireEvent, cleanup, waitFor, act, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { FlowSchema, resolveFlowTriggerKind } from '@objectstack/spec/automation'; @@ -115,6 +115,16 @@ async function openNew(label: string): Promise { }); } +/** + * Pick the trigger whose value is `value` under *Advanced*, by the label the + * trigger picker lists it under: the shared `Select` since objectui#11865. + */ +async function pickTrigger(value: string): Promise { + const label = startFields.find((f) => f.id === 'triggerType')!.options!.find((o) => o.value === value)!.label; + fireEvent.keyDown(screen.getByRole('combobox', { name: 'Trigger' }), { key: 'ArrowDown' }); + fireEvent.click(within(await screen.findByRole('listbox')).getByRole('option', { name: label })); +} + /** Name the object in the visible object picker (it commits on blur). */ async function pickObject(name: string): Promise { const object = await screen.findByRole('combobox', { name: 'Object' }); @@ -167,7 +177,8 @@ describe('objectui#11861 — New automation opens on starting points', () => { // The trigger form is not on the page until Advanced is chosen. expect(screen.queryByRole('combobox', { name: 'Trigger' })).toBeNull(); fireEvent.click(screen.getByRole('radio', { name: ADVANCED() })); - expect((screen.getByRole('combobox', { name: 'Trigger' }) as HTMLSelectElement).value).toBe(''); + // Nothing chosen: the picker shows "choose later". + expect(screen.getByRole('combobox', { name: 'Trigger' }).textContent).toBe(en('engine.studio.newAutoTrigger.later')); // Choosing a preset again folds it away. fireEvent.click(screen.getByRole('radio', { name: en(FLOW_PRESETS[1].labelKey) })); expect(screen.queryByRole('combobox', { name: 'Trigger' })).toBeNull(); @@ -218,7 +229,7 @@ describe('objectui#11861 — New automation opens on starting points', () => { // Through Advanced, with the same trigger and object. await openNew('From advanced'); fireEvent.click(screen.getByRole('radio', { name: ADVANCED() })); - fireEvent.change(screen.getByRole('combobox', { name: 'Trigger' }), { target: { value: preset.triggerType } }); + await pickTrigger(preset.triggerType); if (watches) await pickObject('ticket'); create(); const viaAdvanced = await savedBody(2); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navAutosaveFlush-11189.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navAutosaveFlush-11189.test.tsx index 93500e07e2..5cf410d602 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navAutosaveFlush-11189.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navAutosaveFlush-11189.test.tsx @@ -251,11 +251,11 @@ async function openEditing(): Promise { * the fixture's object in its inspector — objectui#11776: an unbound entry is * left out of what a save sends. */ -function addItem(): void { +async function addItem(): Promise { fireEvent.click(screen.getByRole('button', { name: /Add nav item/ })); - const picker = screen.getAllByRole('combobox').find((s) => within(s).queryByRole('option', { name: new RegExp(TASK.name) })); - if (!picker) throw new Error('the new entry\'s inspector offers no object to bind it to'); - fireEvent.change(picker, { target: { value: TASK.name } }); + // *Link object* is the shared `Select` since objectui#11865: open its list and pick. + fireEvent.keyDown(screen.getByTestId('nav-link-object'), { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: new RegExp(`\\(${TASK.name}\\)$`) })); } async function clickDone(): Promise { @@ -280,7 +280,7 @@ describe('Studio surface — "Done" sends a shown nav edit (objectui#11189)', () render(surfaceTree()); await treeLoaded(); await openEditing(); - addItem(); + await addItem(); expect(addedItemsInRail()).toBe(1); const release = holdNextSave(); @@ -319,7 +319,7 @@ describe('Studio surface — "Done" sends a shown nav edit (objectui#11189)', () render(surfaceTree()); await treeLoaded(); await openEditing(); - addItem(); + await addItem(); server.fail = new Error('navigation.2: Invalid input'); await clickDone(); @@ -340,11 +340,11 @@ describe('Studio surface — an edit taken while a nav save is in flight (object await openEditing(); const release = holdNextSave(); - addItem(); + await addItem(); // The autosave fires after its debounce and is held in flight. await screen.findByTestId('nav-autosaving', undefined, { timeout: 8000 }); expect(server.appSaves).toHaveLength(1); - addItem(); + await addItem(); expect(addedItemsInRail()).toBe(2); await release(); @@ -373,9 +373,9 @@ describe('Studio surface — an edit taken while a nav save is in flight (object await openEditing(); const releaseFirst = holdNextSave(); - addItem(); + await addItem(); await screen.findByTestId('nav-autosaving', undefined, { timeout: 8000 }); - addItem(); + await addItem(); await clickDone(); // A save is in flight: "Done" waits for it rather than send beside it. expect(server.appSaves).toHaveLength(1); @@ -410,7 +410,7 @@ describe('Interfaces pillar — a re-read of the same package over an unsent nav const { rerender } = render(pillarTree(0)); await treeLoaded(); await openEditing(); - addItem(); + await addItem(); expect(addedItemsInRail()).toBe(1); // Another editor's save (a page draft, the create-app flow) re-reads the app. diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navEditClosesOnReadOnly-11167.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navEditClosesOnReadOnly-11167.test.tsx index 3ee7e372d2..0acb184c3a 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navEditClosesOnReadOnly-11167.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navEditClosesOnReadOnly-11167.test.tsx @@ -226,9 +226,9 @@ function leaveGuarded(): boolean { async function openEditingAndAddItem(): Promise { fireEvent.click(screen.getByTitle(/^Edit navigation/)); fireEvent.click(await screen.findByRole('button', { name: /Add nav item/ }, { timeout: 8000 })); - const picker = screen.getAllByRole('combobox').find((s) => within(s).queryByRole('option', { name: new RegExp(TASK.name) })); - if (!picker) throw new Error('the new entry\'s inspector offers no object to bind it to'); - fireEvent.change(picker, { target: { value: TASK.name } }); + // *Link object* is the shared `Select` since objectui#11865: open its list and pick. + fireEvent.keyDown(screen.getByTestId('nav-link-object'), { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: new RegExp(`\\(${TASK.name}\\)$`) })); expect(addedItemOnScreen()).toBe(true); } diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspector.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspector.test.tsx index 2b4ce906b0..5e98b6bfc6 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspector.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspector.test.tsx @@ -15,7 +15,7 @@ * so the ordering is defensive, not a live path), and * - the picker nonetheless shows the CANONICAL target, with both names * present as options so the wrong reading would have shown the other one - * rather than an empty select. + * rather than an empty picker. * The third case pins the write side: picking an object emits a nav item that * the spec's own `NavigationItemSchema` accepts whole. */ @@ -79,16 +79,18 @@ describe('StudioNavItemInspector — object binding (objectui#4881)', () => { it('binds the picker to `objectName`, not to the rejected `object`', () => { renderInspector(BOTH_KEYS_NODE); - // Before the flip this select showed `legacy_lead` — a real option here, + // Before the flip this picker showed `legacy_lead` — a real option here, // so this assertion fails loudly on the old reading rather than degrading - // to an empty value. - expect(screen.getByRole('combobox')).toHaveValue('crm_lead'); + // to an empty value. The picker is the shared `Select` since objectui#11865: + // its trigger shows the chosen option's label. + expect(screen.getByRole('combobox').textContent).toBe('Leads (crm_lead)'); }); - it('picking an object writes a nav item the spec accepts whole', () => { + it('picking an object writes a nav item the spec accepts whole', async () => { // The entry as the canvas births it: label-less since objectui#11196. const onNavPatch = renderInspector({ id: 'nav_lead', type: 'object' }); - fireEvent.change(screen.getByRole('combobox'), { target: { value: 'crm_lead' } }); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: 'Leads (crm_lead)' })); expect(onNavPatch).toHaveBeenCalledTimes(1); const patched = (onNavPatch.mock.calls[0][0] as { navigation: Array> }) diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspectorLabel-11196.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspectorLabel-11196.test.tsx index 185c33f916..ffe63d8d67 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspectorLabel-11196.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspectorLabel-11196.test.tsx @@ -124,18 +124,24 @@ describe('objectui#11196 — emptying the field restores inheritance', () => { }); }); +/** Pick an object in *Link object*, the shared `Select` since objectui#11865. */ +async function pickObject(label: string): Promise { + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: label })); +} + describe('objectui#11196 — binding an object leaves the label as it is', () => { - it('a label-less entry stays label-less', () => { + it('a label-less entry stays label-less', async () => { const onNavPatch = renderInspector({ id: 'nav_item_3', type: 'object' }); - fireEvent.change(screen.getByRole('combobox'), { target: { value: 'account' } }); + await pickObject('Customers (account)'); const entry = written(onNavPatch); expect(entry).toMatchObject({ id: 'nav_item_3', type: 'object', objectName: 'account' }); expect(has(entry, 'label')).toBe(false); }); - it('a legacy stored "New item" label is a present label: it is kept, verbatim', () => { + it('a legacy stored "New item" label is a present label: it is kept, verbatim', async () => { const onNavPatch = renderInspector({ id: 'nav_item_3', type: 'object', label: 'New item' }); - fireEvent.change(screen.getByRole('combobox'), { target: { value: 'account' } }); + await pickObject('Customers (account)'); expect(written(onNavPatch).label).toBe('New item'); }); }); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspectorLabelMaps-11148.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspectorLabelMaps-11148.test.tsx index 0b4254be99..04f1243a1e 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspectorLabelMaps-11148.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspectorLabelMaps-11148.test.tsx @@ -158,9 +158,11 @@ describe('StudioNavItemInspector edits only the designer locale entry of a map ( expect(writtenLabel(onNavPatch)).toBe('Customers'); }); - it('binding an object keeps a real map label as authored, and the item still parses', () => { + it('binding an object keeps a real map label as authored, and the item still parses', async () => { const onNavPatch = renderInspector({ id: 'accounts', type: 'object', label: ITEM_LABEL }, 'zh'); - fireEvent.change(screen.getByRole('combobox'), { target: { value: 'account' } }); + // *Link object* is the shared `Select` since objectui#11865: pick through its list. + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: 'Account (account)' })); expect(onNavPatch).toHaveBeenCalledTimes(1); const patched = (onNavPatch.mock.calls[0][0] as NavPatch).navigation[0]; diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemTypes-11790.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemTypes-11790.test.tsx index cafdde2ca0..986830dc2a 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemTypes-11790.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemTypes-11790.test.tsx @@ -231,11 +231,33 @@ function typeChoice(type: string, scope: HTMLElement = document.body): HTMLInput return hit as HTMLInputElement; } -/** Pick `value` in the select named `name`, once the package's list has offered it. */ +/** Open the picker named `name` (the shared `Select` since objectui#11865) and return its listbox. */ +async function openPicker(name: string, scope: HTMLElement = document.body): Promise { + fireEvent.keyDown(within(scope).getByRole('combobox', { name }), { key: 'ArrowDown' }); + return await screen.findByRole('listbox'); +} + +/** Pick `value` in the picker named `name`, once the package's list has offered it. */ async function pick(name: string, value: string, scope: HTMLElement = document.body): Promise { - const select = within(scope).getByRole('combobox', { name }); - await within(select).findByRole('option', { name: new RegExp(`\\(${value}\\)|^${value}$`) }, { timeout: 8000 }); - fireEvent.change(select, { target: { value } }); + const listbox = await openPicker(name, scope); + fireEvent.click( + await within(listbox).findByRole('option', { name: new RegExp(`\\(${value}\\)|^${value}$`) }, { timeout: 8000 }), + ); +} + +/** + * The options the picker named `name` lists, once the package's list has + * offered `loaded`, in order; the list is closed again after. + */ +async function offered(name: string, loaded: RegExp): Promise { + const listbox = await openPicker(name); + await within(listbox).findByRole('option', { name: loaded }, { timeout: 8000 }); + const labels = within(listbox) + .getAllByRole('option') + .map((o) => o.textContent ?? ''); + fireEvent.keyDown(listbox, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull()); + return labels; } // --------------------------------------------------------------------------- @@ -310,11 +332,12 @@ describe('StudioNavItemInspector — each target-bearing type picks a target and expect(navPayloadOf([entry])).toEqual([entry]); }); - it('url: the address is typed, and the entry parses', () => { + it('url: the address is typed, and the entry parses', async () => { render(); fireEvent.click(typeChoice('url')); fireEvent.change(screen.getByRole('textbox', { name: 'URL' }), { target: { value: 'https://example.com/help' } }); - fireEvent.change(screen.getByRole('combobox', { name: 'Open in' }), { target: { value: '_blank' } }); + const listbox = await openPicker('Open in'); + fireEvent.click(within(listbox).getByRole('option', { name: 'New tab' })); const entry = entryNow(); expect(entry).toEqual({ id: 'nav_item_3', type: 'url', url: 'https://example.com/help', target: '_blank' }); expect(NavigationItemSchema.safeParse(entry).success).toBe(true); @@ -332,27 +355,20 @@ describe('StudioNavItemInspector — each target-bearing type picks a target and it('the pickers offer THIS package\'s items: published and draft, less record pages and object-bound actions', async () => { render(); fireEvent.click(typeChoice('page')); - const pagePicker = screen.getByRole('combobox', { name: 'Page' }); - await within(pagePicker).findByRole('option', { name: /draft_page/ }, { timeout: 8000 }); - expect(within(pagePicker).getAllByRole('option').map((o) => (o as HTMLOptionElement).value)).toEqual([ - '', - 'home', - 'landing', - 'draft_page', - ]); + // Each item is listed as `Label (name)`, or by its name alone when it has no label. + expect(await offered('Page', /draft_page/)).toEqual(['— Choose —', 'Home (home)', 'Landing (landing)', 'draft_page']); expect(mockClient.list).toHaveBeenCalledWith('page', { packageId: PKG }); expect(mockClient.listDrafts).toHaveBeenCalledWith({ packageId: PKG, type: 'page' }); fireEvent.click(typeChoice('action')); - const actionPicker = screen.getByRole('combobox', { name: 'Action' }); - await within(actionPicker).findByRole('option', { name: /sync_all/ }, { timeout: 8000 }); - expect(within(actionPicker).getAllByRole('option').map((o) => (o as HTMLOptionElement).value)).toEqual(['', 'sync_all']); + expect(await offered('Action', /sync_all/)).toEqual(['— Choose —', 'Sync all (sync_all)']); }); it('clearing a target unbinds the entry: the key is removed, never written as \'\', and the save leaves it out', async () => { render(); - await within(screen.getByRole('combobox', { name: 'Dashboard' })).findByRole('option', { name: /ops_board/ }, { timeout: 8000 }); - fireEvent.change(screen.getByRole('combobox', { name: 'Dashboard' }), { target: { value: '' } }); + const listbox = await openPicker('Dashboard'); + await within(listbox).findByRole('option', { name: /ops_board/ }, { timeout: 8000 }); + fireEvent.click(within(listbox).getByRole('option', { name: '— Choose —' })); expect(entryNow()).toEqual({ id: 'nav_item_3', type: 'dashboard' }); expect(navPayloadOf([entryNow()])).toEqual([]); }); @@ -394,10 +410,11 @@ describe('StudioNavItemInspector — each target-bearing type picks a target and expect(enabled).toEqual(['group', 'object']); }); - it('the control: *Link to object* binds an object entry as before', () => { + it('the control: *Link to object* binds an object entry as before', async () => { render(); // The object entry's only combobox is its object picker. - fireEvent.change(screen.getByRole('combobox'), { target: { value: 'acme_task' } }); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: 'Task (acme_task)' })); const entry = entryNow(); expect(entry).toEqual({ id: 'nav_item_3', type: 'object', objectName: 'acme_task' }); expect(NavigationItemSchema.safeParse(entry).success).toBe(true); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navPlaceholderSave-11776.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navPlaceholderSave-11776.test.tsx index 91ddf35171..b9fb68eb40 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.navPlaceholderSave-11776.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.navPlaceholderSave-11776.test.tsx @@ -204,12 +204,13 @@ function addItem(): void { fireEvent.click(screen.getByRole('button', { name: /Add nav item/ })); } -/** Pick the object in the inspector of the selected entry. */ +/** Pick the object in the inspector of the selected entry: the shared `Select` since objectui#11865. */ async function bindSelected(objectName: string): Promise { const inspector = screen.getByRole('complementary'); const picker = await within(inspector).findByRole('combobox'); - await within(picker).findByRole('option', { name: new RegExp(objectName) }, { timeout: 8000 }); - fireEvent.change(picker, { target: { value: objectName } }); + fireEvent.keyDown(picker, { key: 'ArrowDown' }); + const listbox = await screen.findByRole('listbox'); + fireEvent.click(await within(listbox).findByRole('option', { name: new RegExp(`\\(${objectName}\\)$`) }, { timeout: 8000 })); } /** The ids a save carried, in order. */ diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.newFlowTrigger-11788.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.newFlowTrigger-11788.test.tsx index 5bbb6553f8..047bfa1fef 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.newFlowTrigger-11788.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.newFlowTrigger-11788.test.tsx @@ -22,7 +22,7 @@ import '@testing-library/jest-dom/vitest'; import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { FlowSchema, resolveFlowTriggerKind } from '@objectstack/spec/automation'; @@ -108,17 +108,33 @@ function renderPillar() { } /** - * Open *New automation*, name it, and return the dialog's trigger select — + * Open *New automation*, name it, and return the dialog's trigger picker — * under *Advanced* since objectui#11861, where the dialog's presets put it. + * The picker is the shared `Select` since objectui#11865. */ -async function openNew(label: string): Promise { +async function openNew(label: string): Promise { renderPillar(); fireEvent.click(await screen.findByTitle(en('engine.studio.auto.newTitle'), undefined, { timeout: 8000 })); fireEvent.change(await screen.findByPlaceholderText(en('engine.studio.auto.namePlaceholder')), { target: { value: label }, }); fireEvent.click(screen.getByRole('radio', { name: en('engine.studio.rules.advanced') })); - return screen.getByRole('combobox', { name: 'Trigger' }) as HTMLSelectElement; + return screen.getByRole('combobox', { name: 'Trigger' }); +} + +/** The Start node's own trigger choices, as the dialog lists them in English. */ +const startOptions = () => fieldsForNodeType('start').find((f) => f.id === 'triggerType')!.options!; + +/** Open the trigger picker and return its listbox. */ +async function openTriggerList(trigger: HTMLElement): Promise { + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + return await screen.findByRole('listbox'); +} + +/** Pick the trigger whose value is `value`, by the label the picker lists it under. */ +async function pickTrigger(trigger: HTMLElement, value: string): Promise { + const label = startOptions().find((o) => o.value === value)!.label; + fireEvent.click(within(await openTriggerList(trigger)).getByRole('option', { name: label })); } async function submitAndReadSave(): Promise> { @@ -137,18 +153,19 @@ const startOf = (flow: Record) => describe('objectui#11788 — New automation asks for the trigger', () => { it('offers the Start node\'s own triggers, after a "choose later" first choice', async () => { - const select = await openNew('Ticket done'); - const startOptions = fieldsForNodeType('start').find((f) => f.id === 'triggerType')!.options!; - const offered = Array.from(select.options).map((o) => o.value); - expect(offered).toEqual(['', ...startOptions.map((o) => o.value)]); - expect(select.options[0].textContent).toBe(en('engine.studio.newAutoTrigger.later')); - expect(select.value).toBe(''); + const trigger = await openNew('Ticket done'); + // "Choose later" is what the picker shows, and the list's first choice. + expect(trigger.textContent).toBe(en('engine.studio.newAutoTrigger.later')); + const offered = within(await openTriggerList(trigger)) + .getAllByRole('option') + .map((o) => o.textContent); + expect(offered).toEqual([en('engine.studio.newAutoTrigger.later'), ...startOptions().map((o) => o.label)]); }); it('a record trigger asks for its object and is saved on the Start node, binding as a record change', async () => { - const select = await openNew('Ticket done'); + const trigger = await openNew('Ticket done'); expect(screen.queryByRole('combobox', { name: 'Object' })).toBeNull(); - fireEvent.change(select, { target: { value: 'record-after-update' } }); + await pickTrigger(trigger, 'record-after-update'); const object = await screen.findByRole('combobox', { name: 'Object' }); fireEvent.change(object, { target: { value: 'ticket' } }); fireEvent.blur(object); @@ -158,8 +175,8 @@ describe('objectui#11788 — New automation asks for the trigger', () => { }); it('a trigger that watches no object shows no object picker and saves the trigger alone', async () => { - const select = await openNew('Run by hand'); - fireEvent.change(select, { target: { value: 'manual' } }); + const trigger = await openNew('Run by hand'); + await pickTrigger(trigger, 'manual'); expect(screen.queryByRole('combobox', { name: 'Object' })).toBeNull(); const body = await submitAndReadSave(); expect(startOf(body).config).toEqual({ triggerType: 'manual' }); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.sharedSelect-11865.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.sharedSelect-11865.test.tsx new file mode 100644 index 0000000000..c21232e61a --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.sharedSelect-11865.test.tsx @@ -0,0 +1,593 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The design surface's own pickers are the shared `Select` (objectui#11865). + * + * `StudioDesignSurface` drew five pickers as browser-native selects, beside + * the shared Radix `Select` the rest of Studio picks with: the nav-item + * inspector's *Link object*, its target picker (`NavTargetSelect`: page, + * dashboard, report, action, component, doc page, book) and a url entry's + * *Open in*, plus *New object*'s record sharing (OWD) and *New automation*'s + * trigger. The card asks for one control for one kind of choice, surface by + * surface; this suite covers those five. + * + * What is pinned: + * - each picker IS the primitive (a Radix combobox trigger), shows what the + * entry or the dialog holds, and no native select is left; + * - each picker keeps the accessible name the native control had (the + * object picker had none, and has none); + * - every option of every picker writes what the native control wrote: + * the inspector's `onNavPatch` value as JSON text plus the keys it holds + * as `undefined` (JSON erases those), the *New object* save and the + * *New automation* save as JSON text; + * - re-picking the current option writes nothing; + * - a value no option carries is what the trigger shows; + * - the keyboard alone opens a picker and selects. + * + * Read-only: none of the five has such a state. A read-only package closes + * nav editing, which unmounts the inspector + * (`StudioDesignSurface.navEditClosesOnReadOnly-11167`, "closes nav editing + * and the linked item’s inspector"), and offers no *New object* + * (`StudioDesignSurface.emptyPackage`, "hides the CTA on a read-only + * package"). The Automations rail's *New* is gated on the same `readOnly` in + * the source; no suite pins that one. + * + * DIRECTION, observed against the native controls: every pin here but two + * is red there, because each one reads a picker as the primitive's trigger + * (the object picker by a test id the native control did not carry). The two + * green there too pin what the conversion kept: the trigger picker's name, + * and the record sharing default saved untouched. What makes the write pins + * guards of "the conversion changed nothing the surface writes" is the + * literal each compares against: a `change` event on the pre-conversion + * surface's native control wrote that same value, read once on that + * component with these fixtures, and so were the names. That + * probe's `change` event fired for the current option too, which a browser's + * native select does not do, so the re-pick rows pin the primitive. On an + * entry whose value no option carried, the native control showed its first + * option while the entry held the other value; the rows for that entry are + * the writes each option's `change` made. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const PKG = 'com.acme.app'; + +type Row = { type: string; pkg: string; item: Record }; + +const server = vi.hoisted(() => ({ + rows: [] as Array<{ type: string; pkg: string; item: Record }>, + draftHeaders: [] as Array<{ type: string; packageId: string; name: string }>, + saves: [] as Array<{ type: string; name: string; body: Record }>, +})); + +const mockClient = vi.hoisted(() => { + const client: Record = { + list: vi.fn(async (type: string, options?: { packageId?: string }) => + server.rows + .filter((r) => r.type === type && (!options?.packageId || r.pkg === options.packageId)) + .map((r) => JSON.parse(JSON.stringify(r.item)) as Record), + ), + listDrafts: vi.fn(async (options?: { packageId?: string; type?: string }) => + server.draftHeaders + .filter((d) => (!options?.type || d.type === options.type) && (!options?.packageId || d.packageId === options.packageId)) + .map((d) => ({ name: d.name, packageId: d.packageId })), + ), + 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) => { + const hit = server.saves.find((s) => s.type === type && s.name === name); + if (hit) return { item: JSON.parse(JSON.stringify(hit.body)) }; + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + }), + save: vi.fn(async (type: string, name: string, item: unknown) => { + server.saves.push({ type, name, body: JSON.parse(JSON.stringify(item)) as Record }); + return { type, name, item }; + }), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), + }; + // The flow dialog's object picker reads its catalog through the draft-aware view. + client.withPreviewDrafts = () => client; + return client; +}); + +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(), info: vi.fn(), dismiss: vi.fn() } })); + +import { AutomationsPillar, DataPillar, StudioNavItemInspector } from './StudioDesignSurface'; +import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource'; +import { registerAppComponent } from '../../services/componentRegistry'; +import { t } from '../metadata-admin/i18n'; + +const dataSource = createEmptyDataSource(); +failOnAbsorbedFetchError(); + +// A screen registered with the console: what a `component` entry picks from. +registerAppComponent({ ref: 'acme:board', label: 'Acme board', component: () => null }); + +const en = (key: string) => t(key, 'en'); + +/** The nav targets the double serves: this package's, a record page and another package's page left out. */ +const NAV_ROWS: Row[] = [ + { type: 'page', pkg: PKG, item: { name: 'home', label: 'Home', type: 'app' } }, + { type: 'page', pkg: PKG, item: { name: 'landing', label: 'Landing', type: 'app' } }, + { type: 'page', pkg: PKG, item: { name: 'task_detail', label: 'Task detail', type: 'record' } }, + { type: 'page', pkg: 'com.other.app', item: { name: 'foreign_page', label: 'Foreign', type: 'app' } }, + { type: 'dashboard', pkg: PKG, item: { name: 'ops_board', label: 'Operations' } }, + { type: 'report', pkg: PKG, item: { name: 'tasks_by_status', label: 'Tasks by status' } }, + { type: 'action', pkg: PKG, item: { name: 'sync_all', label: 'Sync all' } }, + { type: 'action', pkg: PKG, item: { name: 'close_task', label: 'Close task', objectName: 'acme_task' } }, + { type: 'doc', pkg: PKG, item: { name: 'getting_started', label: 'Getting started' } }, + { type: 'book', pkg: PKG, item: { name: 'acme_manual', label: 'Acme manual' } }, +]; + +beforeEach(() => { + server.rows = NAV_ROWS.map((r) => JSON.parse(JSON.stringify(r)) as Row); + server.draftHeaders = [{ type: 'page', packageId: PKG, name: 'draft_page' }]; + server.saves.length = 0; + for (const fn of Object.values(mockClient)) (fn as { mockClear?: () => void }).mockClear?.(); + // No read here goes out over the global `fetch`; one double answers "absent". + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** The object picker's handle: it has no accessible name, as the native control had none. */ +const OBJECT_PICKER = 'nav-link-object'; +const CHOOSE_OBJECT = en('engine.studio.nav.chooseObject'); +const CHOOSE = en('engine.inspector.appNav.choose'); +const SAME_TAB = en('engine.inspector.appNav.urlTargetSelf'); +const NEW_TAB = en('engine.inspector.appNav.urlTargetBlank'); + +/** A picker by its accessible name, or the object picker by its test id. */ +function picker(handle: string): HTMLElement { + return handle === OBJECT_PICKER ? screen.getByTestId(OBJECT_PICKER) : screen.getByRole('combobox', { name: handle }); +} + +/** Open a picker from the keyboard and return its listbox. */ +async function openList(trigger: HTMLElement): Promise { + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + return await screen.findByRole('listbox'); +} + +/** The options an open picker lists, in order, once `waitFor` is among them. */ +async function listed(trigger: HTMLElement, waitFor?: string): Promise { + const listbox = await openList(trigger); + if (waitFor) await within(listbox).findByRole('option', { name: waitFor }, { timeout: 8000 }); + return within(listbox) + .getAllByRole('option') + .map((o) => o.textContent ?? ''); +} + +/** Pick the option labelled `label`, waiting for a package list to offer it. */ +async function pick(trigger: HTMLElement, label: string): Promise { + const listbox = await openList(trigger); + fireEvent.click(await within(listbox).findByRole('option', { name: label }, { timeout: 8000 })); +} + +// --------------------------------------------------------------------------- +// The nav-item inspector: Link object, the target pickers and Open in +// --------------------------------------------------------------------------- + +const OBJECTS = [ + { name: 'crm_lead', label: 'Leads' }, + { name: 'legacy_lead', label: 'Legacy Leads' }, +]; + +const ENTRIES: Record> = { + objBorn: { id: 'nav_item_3', type: 'object' }, + objBound: { id: 'nav_lead', type: 'object', objectName: 'crm_lead', label: 'Leads' }, + objUntyped: { id: 'nav_x', label: 'X' }, + objOutside: { id: 'nav_g', type: 'object', objectName: 'ghost_obj' }, + objNoId: { type: 'object' }, + pageBorn: { id: 'nav_item_3', type: 'page' }, + pageBound: { id: 'nav_p', type: 'page', pageName: 'home', label: 'Home menu' }, + pageOutside: { id: 'nav_p', type: 'page', pageName: 'ghost_page' }, + dashBound: { id: 'nav_d', type: 'dashboard', dashboardName: 'ops_board' }, + reportBorn: { id: 'nav_item_3', type: 'report' }, + actionBorn: { id: 'nav_item_3', type: 'action' }, + actionBound: { id: 'nav_a', type: 'action', actionDef: { actionName: 'sync_all' } }, + compBorn: { id: 'nav_item_3', type: 'component' }, + docBorn: { id: 'nav_item_3', type: 'doc' }, + docBound: { id: 'nav_doc', type: 'doc', doc: 'getting_started', book: 'acme_manual' }, + urlDefault: { id: 'nav_u', type: 'url', url: 'https://example.com' }, + urlBlank: { id: 'nav_u', type: 'url', url: 'https://example.com', target: '_blank' }, + urlOutside: { id: 'nav_u', type: 'url', url: 'https://example.com', target: '_top' }, +}; + +function renderEntry(key: string) { + const onNavPatch = vi.fn(); + const utils = render( + undefined} + />, + ); + return { ...utils, onNavPatch }; +} + +/** + * [entry, picker, option label, the JSON text of the value `onNavPatch` + * received, the keys the written entry holds as `undefined`]. `null`: nothing + * is written — the option is the current one. + */ +const NAV_WRITES: ReadonlyArray = [ + ['objBorn', OBJECT_PICKER, CHOOSE_OBJECT, null, []], + ['objBorn', OBJECT_PICKER, 'Leads (crm_lead)', '{"navigation":[{"id":"nav_item_3","type":"object","objectName":"crm_lead"}]}', ['object','path']], + ['objBorn', OBJECT_PICKER, 'Legacy Leads (legacy_lead)', '{"navigation":[{"id":"nav_item_3","type":"object","objectName":"legacy_lead"}]}', ['object','path']], + ['objBound', OBJECT_PICKER, CHOOSE_OBJECT, '{"navigation":[{"id":"nav_lead","label":"Leads"}]}', ['type','objectName','object']], + ['objBound', OBJECT_PICKER, 'Leads (crm_lead)', null, []], + ['objBound', OBJECT_PICKER, 'Legacy Leads (legacy_lead)', '{"navigation":[{"id":"nav_lead","type":"object","objectName":"legacy_lead","label":"Leads"}]}', ['object','path']], + ['objUntyped', OBJECT_PICKER, CHOOSE_OBJECT, null, []], + ['objUntyped', OBJECT_PICKER, 'Leads (crm_lead)', '{"navigation":[{"id":"nav_x","label":"X","type":"object","objectName":"crm_lead"}]}', ['object','path']], + ['objUntyped', OBJECT_PICKER, 'Legacy Leads (legacy_lead)', '{"navigation":[{"id":"nav_x","label":"X","type":"object","objectName":"legacy_lead"}]}', ['object','path']], + ['objOutside', OBJECT_PICKER, CHOOSE_OBJECT, '{"navigation":[{"id":"nav_g"}]}', ['type','objectName','object']], + ['objOutside', OBJECT_PICKER, 'Leads (crm_lead)', '{"navigation":[{"id":"nav_g","type":"object","objectName":"crm_lead"}]}', ['object','path']], + ['objOutside', OBJECT_PICKER, 'Legacy Leads (legacy_lead)', '{"navigation":[{"id":"nav_g","type":"object","objectName":"legacy_lead"}]}', ['object','path']], + ['objNoId', OBJECT_PICKER, CHOOSE_OBJECT, null, []], + ['objNoId', OBJECT_PICKER, 'Leads (crm_lead)', '{"navigation":[{"type":"object","id":"nav_crm_lead","objectName":"crm_lead"}]}', ['object','path']], + ['objNoId', OBJECT_PICKER, 'Legacy Leads (legacy_lead)', '{"navigation":[{"type":"object","id":"nav_legacy_lead","objectName":"legacy_lead"}]}', ['object','path']], + ['pageBorn', 'Page', CHOOSE, null, []], + ['pageBorn', 'Page', 'Home (home)', '{"navigation":[{"id":"nav_item_3","type":"page","pageName":"home"}]}', []], + ['pageBorn', 'Page', 'Landing (landing)', '{"navigation":[{"id":"nav_item_3","type":"page","pageName":"landing"}]}', []], + ['pageBorn', 'Page', 'draft_page', '{"navigation":[{"id":"nav_item_3","type":"page","pageName":"draft_page"}]}', []], + ['pageBound', 'Page', CHOOSE, '{"navigation":[{"id":"nav_p","type":"page","label":"Home menu"}]}', []], + ['pageBound', 'Page', 'Home (home)', null, []], + ['pageBound', 'Page', 'Landing (landing)', '{"navigation":[{"id":"nav_p","type":"page","pageName":"landing","label":"Home menu"}]}', []], + ['pageBound', 'Page', 'draft_page', '{"navigation":[{"id":"nav_p","type":"page","pageName":"draft_page","label":"Home menu"}]}', []], + ['pageOutside', 'Page', CHOOSE, '{"navigation":[{"id":"nav_p","type":"page"}]}', []], + ['pageOutside', 'Page', 'ghost_page', null, []], + ['pageOutside', 'Page', 'Home (home)', '{"navigation":[{"id":"nav_p","type":"page","pageName":"home"}]}', []], + ['pageOutside', 'Page', 'Landing (landing)', '{"navigation":[{"id":"nav_p","type":"page","pageName":"landing"}]}', []], + ['pageOutside', 'Page', 'draft_page', '{"navigation":[{"id":"nav_p","type":"page","pageName":"draft_page"}]}', []], + ['dashBound', 'Dashboard', CHOOSE, '{"navigation":[{"id":"nav_d","type":"dashboard"}]}', []], + ['dashBound', 'Dashboard', 'Operations (ops_board)', null, []], + ['reportBorn', 'Report', CHOOSE, null, []], + ['reportBorn', 'Report', 'Tasks by status (tasks_by_status)', '{"navigation":[{"id":"nav_item_3","type":"report","reportName":"tasks_by_status"}]}', []], + ['actionBorn', 'Action', CHOOSE, null, []], + ['actionBorn', 'Action', 'Sync all (sync_all)', '{"navigation":[{"id":"nav_item_3","type":"action","actionDef":{"actionName":"sync_all"}}]}', []], + ['actionBound', 'Action', CHOOSE, '{"navigation":[{"id":"nav_a","type":"action"}]}', []], + ['actionBound', 'Action', 'Sync all (sync_all)', null, []], + ['compBorn', 'Component', CHOOSE, null, []], + ['compBorn', 'Component', 'Acme board (acme:board)', '{"navigation":[{"id":"nav_item_3","type":"component","componentRef":"acme:board"}]}', []], + ['docBorn', 'Doc page', CHOOSE, null, []], + ['docBorn', 'Doc page', 'Getting started (getting_started)', '{"navigation":[{"id":"nav_item_3","type":"doc","doc":"getting_started"}]}', []], + ['docBorn', 'Book', CHOOSE, null, []], + ['docBorn', 'Book', 'Acme manual (acme_manual)', '{"navigation":[{"id":"nav_item_3","type":"doc","book":"acme_manual"}]}', []], + ['docBound', 'Doc page', CHOOSE, '{"navigation":[{"id":"nav_doc","type":"doc","book":"acme_manual"}]}', []], + ['docBound', 'Doc page', 'Getting started (getting_started)', null, []], + ['docBound', 'Book', CHOOSE, '{"navigation":[{"id":"nav_doc","type":"doc","doc":"getting_started"}]}', []], + ['docBound', 'Book', 'Acme manual (acme_manual)', null, []], + ['urlDefault', 'Open in', SAME_TAB, null, []], + ['urlDefault', 'Open in', NEW_TAB, '{"navigation":[{"id":"nav_u","type":"url","url":"https://example.com","target":"_blank"}]}', []], + ['urlBlank', 'Open in', SAME_TAB, '{"navigation":[{"id":"nav_u","type":"url","url":"https://example.com","target":"_self"}]}', []], + ['urlBlank', 'Open in', NEW_TAB, null, []], + ['urlOutside', 'Open in', SAME_TAB, '{"navigation":[{"id":"nav_u","type":"url","url":"https://example.com","target":"_self"}]}', []], + ['urlOutside', 'Open in', NEW_TAB, '{"navigation":[{"id":"nav_u","type":"url","url":"https://example.com","target":"_blank"}]}', []], +]; + +function expectNavWrote(onNavPatch: ReturnType, json: string | null, undefinedKeys: readonly string[]): void { + if (json === null) { + expect(onNavPatch).not.toHaveBeenCalled(); + return; + } + expect(onNavPatch).toHaveBeenCalledTimes(1); + const patch = onNavPatch.mock.calls[0][0] as { navigation: Array> }; + expect(JSON.stringify(patch)).toBe(json); + const entry = patch.navigation[0]; + expect(Object.keys(entry).filter((k) => entry[k] === undefined)).toEqual(undefinedKeys); +} + +describe('the nav-item inspector picks with the shared Select (objectui#11865)', () => { + it('renders each picker as the Radix combobox trigger, showing the entry’s value', async () => { + const shown: Record = {}; + for (const [key, handle, loaded] of [ + ['objBorn', OBJECT_PICKER, undefined], + ['objBound', OBJECT_PICKER, undefined], + ['pageBound', 'Page', 'Home (home)'], + ['urlDefault', 'Open in', undefined], + ['urlBlank', 'Open in', undefined], + ] as const) { + const { container } = renderEntry(key); + expect(container.querySelector('select'), key).toBeNull(); + const trigger = picker(handle); + expect(trigger.tagName, key).toBe('BUTTON'); + expect(trigger, key).toHaveAttribute('role', 'combobox'); + if (loaded) await waitFor_(() => expect(trigger).toHaveTextContent(loaded)); + shown[key] = trigger.textContent ?? ''; + cleanup(); + } + expect(shown).toEqual({ + objBorn: CHOOSE_OBJECT, + objBound: 'Leads (crm_lead)', + pageBound: 'Home (home)', + urlDefault: SAME_TAB, + urlBlank: NEW_TAB, + }); + }); + + // The names were read on the native controls; red there only because the object picker is found by its test id. + it('each picker keeps the name its label gave the native control; the object picker had none', async () => { + const names: Record = {}; + for (const key of ['objBorn', 'pageBorn', 'dashBound', 'reportBorn', 'actionBorn', 'compBorn', 'docBorn', 'urlDefault']) { + renderEntry(key); + names[key] = screen.getAllByRole('combobox').map((c) => { + const named = ['Page', 'Dashboard', 'Report', 'Action', 'Component', 'Doc page', 'Book', 'Open in'].find( + (n) => screen.queryByRole('combobox', { name: n }) === c, + ); + return named ?? ''; + }); + cleanup(); + } + expect(names).toEqual({ + objBorn: [''], + pageBorn: ['Page'], + dashBound: ['Dashboard'], + reportBorn: ['Report'], + actionBorn: ['Action'], + compBorn: ['Component'], + docBorn: ['Doc page', 'Book'], + urlDefault: ['Open in'], + }); + renderEntry('objBorn'); + expect(screen.getByTestId(OBJECT_PICKER)).not.toHaveAccessibleName(); + }); + + it('each picker lists its options in the order the native control did', async () => { + renderEntry('objBound'); + expect(await listed(picker(OBJECT_PICKER))).toEqual([CHOOSE_OBJECT, 'Leads (crm_lead)', 'Legacy Leads (legacy_lead)']); + cleanup(); + + renderEntry('pageBorn'); + expect(await listed(picker('Page'), 'Home (home)')).toEqual([CHOOSE, 'Home (home)', 'Landing (landing)', 'draft_page']); + cleanup(); + + renderEntry('actionBorn'); + expect(await listed(picker('Action'), 'Sync all (sync_all)')).toEqual([CHOOSE, 'Sync all (sync_all)']); + cleanup(); + + renderEntry('compBorn'); + expect(await listed(picker('Component'))).toEqual([CHOOSE, 'Acme board (acme:board)']); + cleanup(); + + renderEntry('urlDefault'); + expect(await listed(picker('Open in'))).toEqual([SAME_TAB, NEW_TAB]); + }); + + it.each(NAV_WRITES.map((row) => [`${row[0]}: ${row[1] === OBJECT_PICKER ? 'Link object' : row[1]} → ${row[2]}`, ...row] as const))( + '%s', + async (_name, key, handle, label, json, undefinedKeys) => { + const { onNavPatch } = renderEntry(key); + await pick(picker(handle), label); + expectNavWrote(onNavPatch, json, undefinedKeys); + }, + ); +}); + +describe('a value no option carries is what the nav picker shows', () => { + it('an object the package does not offer: shown and listed first, and re-picking it writes nothing', async () => { + const { onNavPatch } = renderEntry('objOutside'); + // The native control showed "— Choose object —" here, as if the entry were unbound. + expect(picker(OBJECT_PICKER).textContent).toBe('ghost_obj'); + expect(await listed(picker(OBJECT_PICKER))).toEqual(['ghost_obj', CHOOSE_OBJECT, 'Leads (crm_lead)', 'Legacy Leads (legacy_lead)']); + fireEvent.click(screen.getAllByRole('option')[0]); + expect(onNavPatch).not.toHaveBeenCalled(); + }); + + it('an Open in the picker does not offer is shown, not "Same tab"', async () => { + const { onNavPatch } = renderEntry('urlOutside'); + expect(picker('Open in').textContent).toBe('_top'); + expect(await listed(picker('Open in'))).toEqual(['_top', SAME_TAB, NEW_TAB]); + fireEvent.click(screen.getAllByRole('option')[0]); + expect(onNavPatch).not.toHaveBeenCalled(); + }); + + it('a target the package does not list is shown, as the native control showed it, and listed after Choose', async () => { + renderEntry('pageOutside'); + expect(picker('Page').textContent).toBe('ghost_page'); + expect(await listed(picker('Page'), 'Home (home)')).toEqual([CHOOSE, 'ghost_page', 'Home (home)', 'Landing (landing)', 'draft_page']); + }); + + it('while the package list loads, a bound target shows its own name, as the native control did', () => { + renderEntry('pageBound'); + expect(picker('Page').textContent).toBe('home'); + }); +}); + +describe('the keyboard alone picks in the nav inspector', () => { + it('Enter opens a picker and Enter on an option selects it', async () => { + const { onNavPatch } = renderEntry('objBorn'); + fireEvent.keyDown(picker(OBJECT_PICKER), { key: 'Enter' }); + const listbox = await screen.findByRole('listbox'); + fireEvent.keyDown(within(listbox).getByRole('option', { name: 'Leads (crm_lead)' }), { key: 'Enter' }); + expectNavWrote( + onNavPatch, + '{"navigation":[{"id":"nav_item_3","type":"object","objectName":"crm_lead"}]}', + ['object', 'path'], + ); + }); +}); + +// --------------------------------------------------------------------------- +// New object: record sharing (OWD) +// --------------------------------------------------------------------------- + +const OWD_PRIVATE = en('engine.studio.settings.sharingPrivate'); +const OWD_PUBLIC_READ = en('engine.studio.settings.sharingPublicRead'); +const OWD_PUBLIC_READ_WRITE = en('engine.studio.settings.sharingPublicReadWrite'); + +/** The name the native control had: its wrapping label's whole text, the gloss of the chosen model included. */ +const owdName = (descKey: string) => + `${en('engine.studio.data.owdLabel')} ${en(descKey)} ${en('engine.studio.data.owdHint')}`; + +/** The package the base reading of these saves was taken in. */ +const OBJECT_PKG = 'com.test.crmext'; + +async function openNewObject(): Promise { + // An empty package: the pillar offers *New object* from its empty state. + server.rows = []; + render( + + + , + ); + fireEvent.click(await screen.findByTestId('empty-state-new-object', undefined, { timeout: 8000 })); + return await screen.findByTestId('create-object-owd'); +} + +/** Name the object, submit, and return the JSON text of the one save call. */ +async function submitObject(): Promise { + const dialog = screen.getByRole('dialog'); + fireEvent.change(dialog.querySelectorAll('input')[0], { target: { value: 'Visit' } }); + fireEvent.click(within(dialog).getByRole('button', { name: en('engine.studio.createDraft') })); + await waitFor_(() => expect(mockClient.save).toHaveBeenCalledTimes(1)); + return JSON.stringify((mockClient.save as ReturnType).mock.calls[0]); +} + +/** [option label, the JSON text of the save the create made]. */ +const OWD_WRITES: ReadonlyArray = [ + [OWD_PRIVATE, '["object","visit",{"name":"visit","label":"Visit","sharingModel":"private","fields":{"name":{"type":"text","label":"Name"}}},{"mode":"draft","packageId":"com.test.crmext"}]'], + [OWD_PUBLIC_READ, '["object","visit",{"name":"visit","label":"Visit","sharingModel":"public_read","fields":{"name":{"type":"text","label":"Name"}}},{"mode":"draft","packageId":"com.test.crmext"}]'], + [OWD_PUBLIC_READ_WRITE, '["object","visit",{"name":"visit","label":"Visit","sharingModel":"public_read_write","fields":{"name":{"type":"text","label":"Name"}}},{"mode":"draft","packageId":"com.test.crmext"}]'], +]; + +describe('New object picks its record sharing with the shared Select (objectui#11865)', () => { + it('the picker is the Radix combobox trigger, on the recommended default, and no native select is left', async () => { + const trigger = await openNewObject(); + expect(screen.getByRole('dialog').querySelector('select')).toBeNull(); + expect(trigger.tagName).toBe('BUTTON'); + expect(trigger).toHaveAttribute('role', 'combobox'); + expect(trigger.textContent).toBe(OWD_PRIVATE); + expect(await listed(trigger)).toEqual([OWD_PRIVATE, OWD_PUBLIC_READ, OWD_PUBLIC_READ_WRITE]); + }); + + // The two names were read on the native control; red there only because the second is read after a pick in the list. + it('keeps the name its wrapping label gave the native control, the chosen model’s gloss included', async () => { + const trigger = await openNewObject(); + expect(trigger).toHaveAccessibleName(owdName('engine.studio.settings.sharingDescPrivate')); + await pick(trigger, OWD_PUBLIC_READ); + expect(screen.getByTestId('create-object-owd')).toHaveAccessibleName(owdName('engine.studio.settings.sharingDescPublicRead')); + }); + + it.each(OWD_WRITES.map((row) => [`→ ${row[0]}`, ...row] as const))('%s', async (_name, label, json) => { + const trigger = await openNewObject(); + await pick(trigger, label); + expect(await submitObject()).toBe(json); + }); + + it('left untouched, the default is saved, as before', async () => { + await openNewObject(); + expect(await submitObject()).toBe(OWD_WRITES[0][1]); + }); +}); + +// --------------------------------------------------------------------------- +// New automation: the trigger, under Advanced +// --------------------------------------------------------------------------- + +const LATER = en('engine.studio.newAutoTrigger.later'); + +async function openNewFlow(): Promise { + server.rows = [{ type: 'object', pkg: PKG, item: { name: 'ticket', label: 'Ticket' } }]; + render( + + + , + ); + fireEvent.click(await screen.findByTitle(en('engine.studio.auto.newTitle'), undefined, { timeout: 8000 })); + fireEvent.change(await screen.findByPlaceholderText(en('engine.studio.auto.namePlaceholder')), { + target: { value: 'Probe' }, + }); + fireEvent.click(screen.getByRole('radio', { name: en('engine.studio.rules.advanced') })); + return screen.getByTestId('create-flow-trigger'); +} + +/** Submit and return the JSON text of the saved flow. */ +async function submitFlow(): Promise { + fireEvent.click(screen.getByRole('button', { name: en('engine.studio.createDraft') })); + await waitFor_(() => expect(server.saves).toHaveLength(1)); + return JSON.stringify(server.saves[0].body); +} + +const flowWith = (config: string) => + `{"name":"probe","label":"Probe","type":"autolaunched","nodes":[{"id":"start","type":"start","label":"Start"${config}},{"id":"end","type":"end","label":"End"}],"edges":[{"id":"e1","source":"start","target":"end"}],"status":"obsolete"}`; + +/** [option label, the JSON text of the flow the create saved, no object named]. */ +const TRIGGER_WRITES: ReadonlyArray = [ + [LATER, flowWith('')], + ['Record created', flowWith(',"config":{"triggerType":"record-after-create"}')], + ['Record updated', flowWith(',"config":{"triggerType":"record-after-update"}')], + ['Record created or updated', flowWith(',"config":{"triggerType":"record-after-write"}')], + ['Record before update', flowWith(',"config":{"triggerType":"record-before-update"}')], + ['Record deleted', flowWith(',"config":{"triggerType":"record-after-delete"}')], + ['Schedule (cron)', flowWith(',"config":{"triggerType":"schedule"}')], + ['Time-relative (date sweep)', flowWith(',"config":{"triggerType":"time_relative"}')], + ['Manual / autolaunched', flowWith(',"config":{"triggerType":"manual"}')], + ['Webhook / API', flowWith(',"config":{"triggerType":"api"}')], +]; + +describe('New automation picks its trigger with the shared Select (objectui#11865)', () => { + it('the picker is the Radix combobox trigger, on "choose later", and no native select is left', async () => { + const trigger = await openNewFlow(); + expect(screen.getByRole('dialog').querySelector('select')).toBeNull(); + expect(trigger.tagName).toBe('BUTTON'); + expect(trigger).toHaveAttribute('role', 'combobox'); + expect(trigger.textContent).toBe(LATER); + expect(await listed(trigger)).toEqual(TRIGGER_WRITES.map((row) => row[0])); + }); + + // Green against the native control too, by design: it pins what the conversion kept. + it('keeps the name its wrapping label gave the native control', async () => { + const trigger = await openNewFlow(); + expect(screen.getByRole('combobox', { name: 'Trigger' })).toBe(trigger); + }); + + it.each(TRIGGER_WRITES.map((row) => [`→ ${row[0]}`, ...row] as const))('%s', async (_name, label, json) => { + const trigger = await openNewFlow(); + await pick(trigger, label); + expect(await submitFlow()).toBe(json); + }); + + it('Enter opens the picker and Enter on an option selects it', async () => { + const trigger = await openNewFlow(); + fireEvent.keyDown(trigger, { key: 'Enter' }); + const listbox = await screen.findByRole('listbox'); + fireEvent.keyDown(within(listbox).getByRole('option', { name: 'Manual / autolaunched' }), { key: 'Enter' }); + expect(await submitFlow()).toBe(flowWith(',"config":{"triggerType":"manual"}')); + }); +}); + +/** `waitFor` with the slow-box budget this file's other waits use. */ +function waitFor_(assertion: () => void): Promise { + return waitFor(assertion, { timeout: 8000 }); +} diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.standardNavEntries-11201.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.standardNavEntries-11201.test.tsx index b41206b870..95bcb31067 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.standardNavEntries-11201.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.standardNavEntries-11201.test.tsx @@ -159,7 +159,11 @@ const PICKER_OBJECTS = [ { name: 'draft_thing', label: 'draft_thing' }, ]; -function bind(node: Record, objectName: string): Record { +/** + * Bind `objectName` in *Link object*, the shared `Select` since objectui#11865: + * open its list and pick the option the picker labels `Label (name)`. + */ +async function bind(node: Record, objectName: string): Promise> { const onNavPatch = vi.fn(); render( , objectName: string): Record, ); - fireEvent.change(screen.getByRole('combobox'), { target: { value: objectName } }); + const object = PICKER_OBJECTS.find((o) => o.name === objectName)!; + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: `${object.label} (${object.name})` })); expect(onNavPatch).toHaveBeenCalledTimes(1); return (onNavPatch.mock.calls[0][0] as { navigation: Array> }).navigation[0]; } @@ -180,21 +186,21 @@ describe('objectui#11201 — binding an object to a placeholder entry leaves it // objectui#11196 re-judged the placeholder: a new canvas entry is born with // no `label` (it was born "New item", which this bind then removed). for (const objectName of ['account', 'draft_thing']) { - it(`a new canvas entry, born label-less, bound to ${objectName} has NO \`label\` key, and parses`, () => { - const entry = bind({ id: 'nav_item_1', type: 'object' }, objectName); + it(`a new canvas entry, born label-less, bound to ${objectName} has NO \`label\` key, and parses`, async () => { + const entry = await bind({ id: 'nav_item_1', type: 'object' }, objectName); expect(entry).toMatchObject({ id: 'nav_item_1', type: 'object', objectName }); expect(Object.prototype.hasOwnProperty.call(entry, 'label')).toBe(false); expect(NavigationItemSchema.safeParse(JSON.parse(JSON.stringify(entry))).success).toBe(true); }); } - it('CONTROL — a label the author typed is kept as authored', () => { - const entry = bind({ id: 'nav_clients', type: 'object', label: 'Clients' }, 'account'); + it('CONTROL — a label the author typed is kept as authored', async () => { + const entry = await bind({ id: 'nav_clients', type: 'object', label: 'Clients' }, 'account'); expect(entry.label).toBe('Clients'); }); - it('a legacy stored "New item" label is a present label: kept verbatim, no sentinel matching (objectui#11196)', () => { - const entry = bind({ id: 'nav_item_2', type: 'object', label: 'New item' }, 'account'); + it('a legacy stored "New item" label is a present label: kept verbatim, no sentinel matching (objectui#11196)', async () => { + const entry = await bind({ id: 'nav_item_2', type: 'object', label: 'New item' }, 'account'); expect(entry.label).toBe('New item'); }); }); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 9d6ced425b..656f270d46 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -50,6 +50,11 @@ import { SheetContent, SheetHeader, SheetTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from '@object-ui/components'; import { ObjectView as PluginObjectView } from '@object-ui/plugin-view'; import { ListView } from '@object-ui/plugin-list'; @@ -2311,10 +2316,13 @@ export function StudioNavItemInspector({ {(kind === 'object' || kind === null) && (
- + className={PICKER_XS} + testId="nav-link-object" + />

{boundObject ? t('engine.studio.nav.boundHint', locale) : t('engine.studio.nav.unboundHint', locale)}

@@ -2509,6 +2511,80 @@ function usePackageNavTargets( return state?.key === key ? { rows: state.rows, loading: false } : { rows: [], loading: true }; } +/** The item a value none of a picker's options carries is shown by. */ +const OUTSIDE_OPTIONS = 'outside'; + +/** The classes a nav-inspector picker takes, at the size the native control had. */ +const PICKER_XS = 'h-auto rounded px-2 py-1 text-xs'; + +/** + * objectui#11865 — one of the design surface's own pickers, drawn with the + * shared `Select`, the control the rest of Studio picks with: the nav-item + * inspector's object, target and *Open in* pickers, and the record sharing + * and trigger pickers of the *New object* and *New automation* dialogs. They + * used to be browser-native select elements. What a pick writes is unchanged: + * `onPick` receives the picked option's own `value`, the string the native + * control's `change` carried, and re-picking the current option writes + * nothing, as it did there. + * + * - Items carry their option's INDEX, not its value. The object, target and + * trigger pickers open on an option whose value is `''` ("Choose object", + * "Choose", "Choose later on the Start node"), which `SelectItem` refuses; + * an index cannot collide with an object's or a target's name, as any + * stand-in string could. + * - A value none of the options carries gets an item of its own, labelled + * with the value, so the trigger shows what the entry holds. The native + * control showed its first option there ("Choose object", "Same tab"). + * Picking that item writes nothing. A nav target picker never needs it: it + * lists the entry's own target already ({@link NavTargetSelect}). + * - Read-only has no state here: a read-only package closes nav editing, which + * unmounts the inspector, and offers neither *New* entry, so neither dialog + * opens there. + * - Each caller keeps the name its native control had: a `
); @@ -5644,18 +5715,13 @@ export function DataPillar({ {t('engine.studio.data.owdLabel', locale)} - + options={OWD_CREATE_MODELS.map((m) => ({ value: m, label: t(OWD_OPTION_LABEL_KEY[m], locale) }))} + onPick={(m) => setCreateOwd(m as OwdCreateModel)} + className="h-auto rounded px-2 py-1 text-[12px]" + testId="create-object-owd" + /> {t(OWD_OPTION_DESC_KEY[createOwd], locale)} @@ -6571,19 +6637,16 @@ export function AutomationsPillar({
{startTrigger.trigger.help && (

{startTrigger.trigger.help}