diff --git a/.changeset/12516-add-field-popover.md b/.changeset/12516-add-field-popover.md new file mode 100644 index 0000000000..d0bcfdab5a --- /dev/null +++ b/.changeset/12516-add-field-popover.md @@ -0,0 +1,8 @@ +--- +'@object-ui/app-shell': minor +--- + +Studio's Add field asks for the label and type before it creates the field, and the field type picker also searches descriptions (objectui#12516). + +- **Add field opens a small form.** On the Data pillar, the Form toolbar's Add field and the Records grid's "+" column open the same popover: a required Label, a Type control (the field inspector's searchable type picker, Text preselected), Cancel and Create. Create adds the field with that label and type and opens it in the field inspector, as before. Its api name is derived from the label the way the inspector derives it; when the label derives no name (no Latin letters) or names an existing field, the field gets the next free `field_N`. Cancel, Escape or a click outside creates nothing. A read-only package still offers no Add field. +- **The type picker searches descriptions.** Its search, focused when the list opens, already matched a type's name, category, id and English name; it now also matches the one-line description, on whole words, so `masked` finds Password and Secret while `pick` still finds Picklist alone. A query in Han or Kana matches a description as a substring. Groups keep their headings over their matches, and the arrow keys and Enter pick a type. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 7a878aace1..b0f87a7ce4 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -3439,6 +3439,7 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.hooks.pick': 'Select a hook to edit it.', 'engine.studio.hooks.newLabel': 'New hook', 'engine.studio.data.addField': 'Add field', + 'engine.studio.data.addFieldCreate': 'Create', 'engine.studio.data.editFieldProps': 'Edit field properties', 'engine.studio.data.draftObjectTitle': 'Unpublished new object', 'engine.studio.data.draftObjectHint': @@ -6656,6 +6657,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.hooks.pick': '选择一个钩子进行编辑。', 'engine.studio.hooks.newLabel': '新钩子', 'engine.studio.data.addField': '添加字段', + 'engine.studio.data.addFieldCreate': '创建', 'engine.studio.data.editFieldProps': '编辑字段属性', 'engine.studio.data.draftObjectTitle': '未发布的新对象', 'engine.studio.data.draftObjectHint': diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FieldTypePicker.search-12516.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FieldTypePicker.search-12516.test.tsx new file mode 100644 index 0000000000..9025f4c8cf --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FieldTypePicker.search-12516.test.tsx @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12516 — the type picker's search: the list opens with its search + * input focused at the top; a query filters on the type's name, api value and + * description (`pick` leaves Picklist alone, `look` leaves Lookup); each group + * keeps its heading over its matches and a group with none is dropped; the + * arrow keys move through the matches and Enter picks one. + * + * The search over names, categories and ids is objectui#11793's and is pinned + * in `ObjectFieldInspector.typePicker-11793.test.tsx`; this suite pins what + * #12516 adds and the queries its card names, on the picker alone. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { t, type SupportedLocale } from '../i18n'; +import { FieldTypePicker } from './FieldTypePicker'; + +beforeEach(() => { + // Radix reads pointer capture, which the DOM double does not implement. + for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) { + if (!(m in Element.prototype)) { + (Element.prototype as unknown as Record)[m] = m === 'hasPointerCapture' ? () => false : () => {}; + } + } + if (!('scrollIntoView' in Element.prototype)) { + (Element.prototype as unknown as Record).scrollIntoView = () => {}; + } +}); + +afterEach(cleanup); + +async function open(locale: SupportedLocale = 'en-US', value = 'text') { + const onCommit = vi.fn(); + render(); + await userEvent.click(screen.getByRole('combobox', { name: 'Type' })); + const search = await screen.findByRole('combobox', { name: t('designer.canvas.searchFieldType', locale) }); + return { onCommit, search }; +} + +/** The listed type ids, in order. */ +const shownTypes = () => screen.getAllByRole('option').map((o) => o.getAttribute('data-field-type')); +/** The group headings over the list, in order. */ +const shownHeadings = () => + Array.from(document.querySelectorAll('[cmdk-group-heading]')).map((h) => h.textContent); + +describe('FieldTypePicker — the search (objectui#12516)', () => { + it('opens with the search input focused, above the list', async () => { + const { search } = await open(); + expect(search).toHaveFocus(); + const list = screen.getByRole('listbox'); + expect(search.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('typing "pick" shows Picklist only, under its Selection heading', async () => { + const { search } = await open(); + await userEvent.type(search, 'pick'); + expect(shownTypes()).toEqual(['select']); + expect(shownHeadings()).toEqual([t('engine.fieldCategory.selection', 'en-US')]); + }); + + it('typing "look" shows Lookup only, under its Relation heading', async () => { + const { search } = await open(); + await userEvent.type(search, 'look'); + expect(shownTypes()).toEqual(['lookup']); + expect(shownHeadings()).toEqual([t('engine.fieldCategory.relation', 'en-US')]); + }); + + it('a whole word of a description finds the type; case does not matter', async () => { + const { search } = await open(); + // Password ("Text that is masked when it is read back.") and Secret + // ("… masked when read back."): no name, category or id holds "masked". + await userEvent.type(search, 'MASKED'); + expect(shownTypes()).toEqual(['password', 'secret']); + }); + + it('a description matches on whole words only: "picked" finds Slider, "pick" does not', async () => { + const { search } = await open(); + await userEvent.type(search, 'picked'); + expect(shownTypes()).toEqual(['slider']); + await userEvent.clear(search); + await userEvent.type(search, 'pick'); + expect(shownTypes()).not.toContain('slider'); + }); + + it('the groups keep their headings over the matches, in catalog order, and a group with none is dropped', async () => { + const { search } = await open(); + // "address" is in Email's description ("An email address.") and is the + // Address type's own name, in two different groups. + await userEvent.type(search, 'address'); + const types = shownTypes(); + expect(types).toContain('email'); + expect(types).toContain('address'); + const headings = shownHeadings(); + expect(headings[0]).toBe(t('engine.fieldCategory.text', 'en-US')); + expect(headings).not.toContain(t('engine.fieldCategory.number', 'en-US')); + for (const heading of document.querySelectorAll('[cmdk-group]')) { + expect(within(heading as HTMLElement).queryAllByRole('option').length).toBeGreaterThan(0); + } + }); + + it('the arrow keys move through the matches and Enter picks the highlighted one', async () => { + const { search, onCommit } = await open(); + await userEvent.type(search, 'select'); + // The Selection group, by its heading, in catalog order: Picklist first. + expect(shownTypes()).toEqual(['select', 'multiselect', 'radio', 'checkboxes']); + await userEvent.keyboard('{ArrowDown}{Enter}'); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith('multiselect'); + }); + + it('zh: a query in Han script finds a type by a substring of its zh description', async () => { + const { search } = await open('zh-CN'); + const desc = t('engine.fieldTypeDesc.password', 'zh-CN'); + const name = t('engine.fieldType.password', 'zh-CN'); + // A run of the description that the name does not hold. + const query = [...desc].find((c) => /\p{Script=Han}/u.test(c) && !name.includes(c)); + expect(query).toBeDefined(); + await userEvent.type(search, query!); + expect(shownTypes()).toContain('password'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FieldTypePicker.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FieldTypePicker.tsx index b688266253..d12b1a77fb 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FieldTypePicker.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FieldTypePicker.tsx @@ -38,7 +38,16 @@ * designer's "(not found)" flag, never as blank and never as another type. * - `disabled` (a read-only field) disables the trigger. * - * Module-private: imported by `ObjectFieldInspector`, exported from no barrel. + * ## The search (objectui#12516) + * + * The list opens with its search input focused at the top. A query keeps the + * types whose name, category, id or English name contains it, or whose + * description holds it as whole words; each group keeps its heading over its + * matches and a group with none is dropped. The arrow keys move through what + * is left and Enter picks the highlighted type. + * + * Module-private: imported by `ObjectFieldInspector` and the Studio's Add field + * popover (`studio-design/AddFieldPopover`), exported from no barrel. */ import * as React from 'react'; @@ -92,10 +101,41 @@ interface TypeGroup { rows: TypeRow[]; } +/** A letter or a digit in any script: what a word is made of. */ +const WORD_CHAR = /[\p{L}\p{N}]/u; + +/** + * Scripts written without spaces between words, where a whole-word match has + * no word edge to find: a query in one of them matches a description as a + * substring. + */ +const UNSPACED_SCRIPT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u; + +/** + * Whether `query` (lower-cased) is in a type's description as whole words: + * starting and ending on a word edge (objectui#12516). A description is prose, + * so a substring match would pull in types by an inflection of the query — + * `pick` would find Slider through "A number picked on a slider". + */ +function inDescription(description: string, query: string): boolean { + const text = description.toLowerCase(); + if (UNSPACED_SCRIPT.test(query)) return text.includes(query); + for (let at = text.indexOf(query); at !== -1; at = text.indexOf(query, at + 1)) { + const before = text[at - 1]; + const after = text[at + query.length]; + if ((before === undefined || !WORD_CHAR.test(before)) && (after === undefined || !WORD_CHAR.test(after))) { + return true; + } + } + return false; +} + /** * Whether a type matches the search: its name as shown, its category as shown, - * its id (`master_detail` also as `master detail`), and the catalog's English - * name, so an author finds a type by any of the words they know it by. + * its id (`master_detail` also as `master detail`) and the catalog's English + * name, as substrings, so an author finds a type by any of the words they know + * it by while still typing them; and its one-line description, as whole words + * (see {@link inDescription}). */ function matches(row: TypeRow, heading: string, query: string): boolean { if (!query) return true; @@ -106,7 +146,7 @@ function matches(row: TypeRow, heading: string, query: string): boolean { row.id.replace(/_/g, ' '), FIELD_TYPE_META[row.id].label, ]; - return haystacks.some((h) => h.toLowerCase().includes(query)); + return haystacks.some((h) => h.toLowerCase().includes(query)) || inDescription(row.description, query); } export function FieldTypePicker({ label, value, onCommit, disabled, locale: localeProp }: FieldTypePickerProps) { @@ -198,7 +238,8 @@ export function FieldTypePicker({ label, value, onCommit, disabled, locale: loca {/* Filtered here rather than by cmdk, so the match is the one - `matches` states: name, category, id and English name. */} + `matches` states: name, category, id, English name and + description. */} {/* cmdk names its search input through the `label` it is given (`aria-labelledby`), so the name goes there, not on the input. */} ` api name and type Text, for the author to rename and retype in + * the inspector afterwards. The popover asks for the two things first: a Label + * (required) and a Type (the inspector's searchable `FieldTypePicker`, Text + * preselected). Create hands both to the host's `onCreate`, which adds the + * field through the pillar's one add-field path (the object buffer and its + * autosave) and opens it in the inspector; Cancel, Escape or a click outside + * creates nothing. + * + * The popover names nothing itself: the api name is derived by the host from + * the label, as the field inspector derives it. + * + * Two placements, one form: + * - **trigger** (the Form toolbar): `children` is the button that opens it; + * - **controlled** (the Records grid's "+" column, which the grid draws from + * its authoring context with no element to hand over): the host passes + * `open` / `onOpenChange`, `children` is the element it is placed against, + * and `returnFocusTo` names where focus goes back when it closes. + */ + +import * as React from 'react'; +import { + Button, + Input, + Label, + Popover, + PopoverContent, + PopoverTrigger, +} from '@object-ui/components'; +import type { FieldTypeId } from '../metadata-admin/previews/field-types.js'; +import { FieldTypePicker } from '../metadata-admin/inspectors/FieldTypePicker.js'; +import { t, useMetadataLocale } from '../metadata-admin/i18n.js'; + +export interface AddFieldPopoverProps { + /** Create the field: the label as typed (trimmed) and the chosen type. */ + onCreate: (label: string, type: FieldTypeId) => void; + /** The element the popover opens from (trigger) or is placed against (controlled). */ + children: React.ReactElement; + /** Controlled open state; omit both to let `children` open it. */ + open?: boolean; + onOpenChange?: (open: boolean) => void; + /** Where focus returns on close, when `children` cannot take it. */ + returnFocusTo?: () => HTMLElement | null; + /** Which edge of `children` the popover lines up with. */ + align?: 'start' | 'end'; +} + +export function AddFieldPopover({ + onCreate, + children, + open: openProp, + onOpenChange, + returnFocusTo, + align = 'end', +}: AddFieldPopoverProps): React.ReactElement { + const locale = useMetadataLocale(); + const [openState, setOpenState] = React.useState(false); + const open = openProp ?? openState; + const [label, setLabel] = React.useState(''); + const [type, setType] = React.useState('text'); + const titleId = React.useId(); + const labelId = React.useId(); + + const setOpen = (next: boolean) => { + if (openProp === undefined) setOpenState(next); + onOpenChange?.(next); + // Every open starts from an empty label and Text. + if (!next) { + setLabel(''); + setType('text'); + } + }; + + const trimmed = label.trim(); + const submit = (e: React.FormEvent) => { + e.preventDefault(); + if (!trimmed) return; + onCreate(trimmed, type); + setOpen(false); + }; + + return ( + + {children} + { + const target = returnFocusTo?.(); + if (target) { + e.preventDefault(); + target.focus(); + } + }} + > +
+

+ {t('engine.studio.data.addField', locale)} +

+
+ + setLabel(e.target.value)} + placeholder={t('engine.studio.data.newFieldLabel', locale)} + required + aria-required="true" + autoComplete="off" + data-testid="add-field-label-input" + className="h-8 text-sm" + /> +
+ +
+ + +
+ +
+
+ ); +} diff --git a/packages/app-shell/src/views/studio-design/DataPillar.addFieldPopover-12516.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.addFieldPopover-12516.test.tsx new file mode 100644 index 0000000000..7a6da110f5 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/DataPillar.addFieldPopover-12516.test.tsx @@ -0,0 +1,240 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12516 — "Add field" asks for the label and type before it creates + * the field. Every entry (the Form toolbar and the Records grid's "+" column) + * opens the same popover: a required Label, a Type control (the inspector's + * searchable picker, Text preselected) and Create. Create adds the field + * through the pillar's one add-field path, named as the field inspector's + * Label names it, and opens it in the inspector; Cancel creates nothing. + * + * The REAL `DataPillar`; the records grid is stubbed to its "+" column, drawn + * from the grid's authoring context the way the real grid draws it. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, 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', required: true } }, +}; + +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>(); + const { useGridFieldAuthoring } = await import('@object-ui/components'); + function GridDouble() { + const authoring = useGridFieldAuthoring(); + return ( +
+ {authoring?.onAddColumn && ( + + )} +
+ ); + } + return { ...mod, ObjectView: GridDouble }; +}); + +import { DataPillar } from './StudioDesignSurface'; +import { t } from '../metadata-admin/i18n'; +import { readFields } from '../metadata-admin/previews/object-fields-io'; +import { addFieldFromFormToolbar, addFieldFromGrid } from './__tests__/dataPillarAddField'; + +const FORM = t('engine.studio.data.tab.form', 'en'); +const ADD_FIELD = t('engine.studio.data.addField', 'en'); + +beforeEach(() => { + for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) { + if (!(m in Element.prototype)) { + (Element.prototype as unknown as Record)[m] = m === 'hasPointerCapture' ? () => false : () => {}; + } + } + if (!('scrollIntoView' in Element.prototype)) { + (Element.prototype as unknown as Record).scrollIntoView = () => {}; + } +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +async function renderPillar(props: { readOnly?: boolean } = {}) { + render( + + + , + ); + await waitFor(() => expect(screen.getByTestId('records-panel')).toBeInTheDocument(), { timeout: 8000 }); +} + +async function openForm() { + await renderPillar(); + await userEvent.click(screen.getAllByRole('button', { name: FORM })[0]); + await screen.findByRole('button', { name: 'Layout', pressed: true }); +} + +const toolbarAddField = () => within(screen.getByTestId('form-toolbar')).getByRole('button', { name: ADD_FIELD }); + +/** The fields of the latest object draft the pillar saved. */ +async function savedFields(): Promise>> { + await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 8000 }); + const calls = mockClient.save.mock.calls as unknown as Array<[string, string, Record]>; + const body = calls[calls.length - 1][2]; + return Object.fromEntries(readFields(body.fields).entries.map((e) => [e.name, e.def])); +} + +describe('Data pillar — Add field asks for the label and type first (objectui#12516)', () => { + it('the popover: a focused, required Label, the Type picker on Text, and Create off until a label is typed', async () => { + await openForm(); + await userEvent.click(toolbarAddField()); + const popover = await screen.findByTestId('add-field-popover'); + const label = within(popover).getByRole('textbox', { name: t('designer.field.label', 'en') }); + expect(label).toHaveFocus(); + expect(label).toBeRequired(); + const type = within(popover).getByRole('combobox', { name: t('designer.field.type', 'en') }); + expect(type).toHaveAttribute('data-field-type', 'text'); + const create = within(popover).getByTestId('add-field-create'); + expect(create).toBeDisabled(); + await userEvent.type(label, ' '); + expect(create).toBeDisabled(); + await userEvent.type(label, 'Score'); + expect(create).toBeEnabled(); + }); + + it('Create adds the field named as typed with the chosen type, and opens it in the inspector', async () => { + await openForm(); + // Email, a type the save door takes with no further config (a Picklist + // without options is held until it has some, objectui#11786). + await addFieldFromFormToolbar(undefined, { label: 'Health Score', type: /^Email$/ }); + expect(screen.queryByTestId('add-field-popover')).toBeNull(); + // The api name is the inspector's derivation of the label; the inspector + // opens on the new field, its header naming it. + const inspector = await screen.findByTestId('data-field-inspector'); + expect(within(inspector).getByText('health_score')).toBeInTheDocument(); + const fields = await savedFields(); + expect(fields.health_score).toMatchObject({ type: 'email', label: 'Health Score' }); + }); + + it('the type search works in the popover: "pick" leaves Picklist alone', async () => { + await openForm(); + await userEvent.click(toolbarAddField()); + const popover = await screen.findByTestId('add-field-popover'); + await userEvent.click(within(popover).getByRole('combobox', { name: t('designer.field.type', 'en') })); + const search = await screen.findByRole('combobox', { name: t('designer.canvas.searchFieldType', 'en') }); + expect(search).toHaveFocus(); + await userEvent.type(search, 'pick'); + expect(screen.getAllByRole('option').map((o) => o.getAttribute('data-field-type'))).toEqual(['select']); + await userEvent.keyboard('{Enter}'); + // Picking a type closes the list, not the popover. + expect(screen.getByTestId('add-field-popover')).toBeInTheDocument(); + expect(within(popover).getByRole('combobox', { name: t('designer.field.type', 'en') })).toHaveAttribute( + 'data-field-type', + 'select', + ); + }); + + it('Cancel, and Escape, create nothing', async () => { + await openForm(); + const cardsBefore = document.querySelectorAll('[aria-roledescription]').length; + await userEvent.click(toolbarAddField()); + let popover = await screen.findByTestId('add-field-popover'); + await userEvent.type(within(popover).getByTestId('add-field-label-input'), 'Not wanted'); + await userEvent.click(within(popover).getByRole('button', { name: t('engine.studio.cancel', 'en') })); + expect(screen.queryByTestId('add-field-popover')).toBeNull(); + + await userEvent.click(toolbarAddField()); + popover = await screen.findByTestId('add-field-popover'); + // Every open starts from an empty label. + expect(within(popover).getByTestId('add-field-label-input')).toHaveValue(''); + await userEvent.type(within(popover).getByTestId('add-field-label-input'), 'Not wanted'); + await userEvent.keyboard('{Escape}'); + expect(screen.queryByTestId('add-field-popover')).toBeNull(); + + expect(document.querySelectorAll('[aria-roledescription]')).toHaveLength(cardsBefore); + expect(screen.queryByTestId('data-field-inspector')).toBeNull(); + expect(mockClient.save).not.toHaveBeenCalled(); + }); + + it('a label whose name is taken, or that derives none, gets the next field_N, as the inspector keeps it', async () => { + await openForm(); + // `name` is the served field's api name. + await addFieldFromFormToolbar(undefined, { label: 'Name' }); + const inspector = await screen.findByTestId('data-field-inspector'); + expect(within(inspector).getByText('field_2')).toBeInTheDocument(); + await addFieldFromFormToolbar(undefined, { label: '健康分' }); + await waitFor(() => expect(within(screen.getByTestId('data-field-inspector')).getByText('field_3')).toBeInTheDocument()); + const fields = await waitFor(async () => { + const f = await savedFields(); + expect(Object.keys(f)).toEqual(['name', 'field_2', 'field_3']); + return f; + }, { timeout: 8000 }); + expect(fields.field_2).toMatchObject({ type: 'text', label: 'Name' }); + expect(fields.field_3).toMatchObject({ type: 'text', label: '健康分' }); + }); + + it('the Records grid "+" opens the same popover, and focus goes back to it on Cancel', async () => { + await renderPillar(); + const plus = screen.getByTestId('grid-add-column'); + await userEvent.click(plus); + const popover = await screen.findByTestId('add-field-popover'); + expect(within(popover).getByTestId('add-field-label-input')).toHaveFocus(); + await userEvent.click(within(popover).getByRole('button', { name: t('engine.studio.cancel', 'en') })); + await waitFor(() => expect(plus).toHaveFocus()); + + await addFieldFromGrid(undefined, { label: 'Region', type: /^Lookup$/ }); + const inspector = await screen.findByTestId('data-field-inspector'); + expect(within(inspector).getByText('region')).toBeInTheDocument(); + }); + + it('leaving the Records grid with the popover open does not bring it back open on return', async () => { + await renderPillar(); + await userEvent.click(screen.getByTestId('grid-add-column')); + await screen.findByTestId('add-field-popover'); + // The tab row is outside the popover: a click there dismisses it (the + // host's open state with it) before the grid unmounts with the tab. + await userEvent.click(screen.getAllByRole('button', { name: FORM })[0]); + await screen.findByTestId('form-toolbar'); + await userEvent.click(screen.getAllByRole('button', { name: t('engine.studio.data.tab.records', 'en') })[0]); + await screen.findByTestId('grid-add-column'); + expect(screen.queryByTestId('add-field-popover')).toBeNull(); + }); + + it('CONTROL — read-only: no Add field, so no popover', async () => { + await renderPillar({ readOnly: true }); + expect(screen.queryByTestId('grid-add-column')).toBeNull(); + expect(screen.queryByTestId('add-field-popover')).toBeNull(); + await userEvent.click(screen.getAllByRole('button', { name: FORM })[0]); + expect(within(await screen.findByTestId('form-toolbar')).queryByRole('button', { name: ADD_FIELD })).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.formToolbar-12451.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.formToolbar-12451.test.tsx index 3716e6ac8b..972bb4d131 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.formToolbar-12451.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.formToolbar-12451.test.tsx @@ -8,8 +8,9 @@ * control is each item's title (pinned in `DataPillar.layoutBadge.test.tsx`). * * The toolbar's Add field and the Records grid's "+" column are one action: - * each adds one field card and opens its inspector (objectui#12511 — the - * object header's More → Add field, this pin's other entry before, retired). + * each opens the Add field popover (objectui#12516), and its Create adds one + * field card and opens its inspector (objectui#12511 — the object header's + * More → Add field, this pin's other entry before, retired). * * The REAL `DataPillar`; the records grid is stubbed to its "+" column, drawn * from the grid's authoring context the way the real grid draws it. @@ -70,6 +71,7 @@ vi.mock('@object-ui/plugin-view', async (importOriginal) => { import { DataPillar } from './StudioDesignSurface'; import { t } from '../metadata-admin/i18n'; +import { addFieldFromFormToolbar, addFieldFromGrid } from './__tests__/dataPillarAddField'; const FORM = t('engine.studio.data.tab.form', 'en'); const RECORDS = t('engine.studio.data.tab.records', 'en'); @@ -127,12 +129,12 @@ describe('Data pillar Form tab — one toolbar row (objectui#12451)', () => { it('the toolbar Add field and the Records grid "+" are one action', async () => { await openForm(); const before = cards().length; - await userEvent.click(within(screen.getByTestId('form-toolbar')).getByRole('button', { name: ADD_FIELD })); + await addFieldFromFormToolbar(undefined, { label: 'From toolbar' }); expect(cards()).toHaveLength(before + 1); expect(await screen.findByTestId('data-field-inspector')).toBeInTheDocument(); await userEvent.click(screen.getAllByRole('button', { name: RECORDS })[0]); - await userEvent.click(await screen.findByTestId('grid-add-column')); + await addFieldFromGrid(undefined, { label: 'From grid' }); expect(await screen.findByTestId('data-field-inspector')).toBeInTheDocument(); await userEvent.click(screen.getAllByRole('button', { name: FORM })[0]); await waitFor(() => expect(cards()).toHaveLength(before + 2)); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.layoutBadge.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.layoutBadge.test.tsx index d6114d24c4..a2a36adab9 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.layoutBadge.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.layoutBadge.test.tsx @@ -25,6 +25,7 @@ import '@testing-library/jest-dom/vitest'; import * as React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { completeAddFieldPopover } from './__tests__/dataPillarAddField'; import { MemoryRouter } from 'react-router-dom'; const objectDef = { @@ -116,8 +117,10 @@ describe('DataPillar form tab — the layout caption tells the truth (#4036)', ( // "+ Add field" is an edit path — it sets the pillar's `dirty` flag. Two // buttons drive the same `addField` callback (the pillar toolbar's and the - // form designer's own); either does. + // form designer's own); either does. Since objectui#12516 it opens a + // popover that asks for the label first. fireEvent.click(screen.getAllByRole('button', { name: /Add field/i })[0]); + await completeAddFieldPopover(); expect(await screen.findByTitle(CLAIM)).toBeInTheDocument(); }); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.savedAtDisplayLocale-10232.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.savedAtDisplayLocale-10232.test.tsx index 4e833483d5..54f5ace386 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.savedAtDisplayLocale-10232.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.savedAtDisplayLocale-10232.test.tsx @@ -26,6 +26,7 @@ import '@testing-library/jest-dom/vitest'; import * as React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { completeAddFieldPopover } from './__tests__/dataPillarAddField'; import { MemoryRouter } from 'react-router-dom'; import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; import { isMachineLocale, recordLocaleArgumentsAsync } from '@object-ui/test-support'; @@ -89,6 +90,8 @@ async function readSavedHint(locale: string): Promise { fireEvent.click(await screen.findByRole('button', { name: 'Form' })); // An edit sets `dirty`; the debounced auto-save lands it and shows the hint. fireEvent.click((await screen.findAllByRole('button', { name: /Add field/i }))[0]); + // objectui#12516 — Add field asks for the label first. + await completeAddFieldPopover(); const hint = await screen.findByTestId('data-saved-at', undefined, { timeout: 5000 }); const text = hint.textContent ?? ''; cleanup(); diff --git a/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx b/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx index fd2b71df28..2b6ae3ae22 100644 --- a/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx @@ -54,6 +54,8 @@ import { useSafeFieldLabel } from '@object-ui/i18n'; import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js'; import { isStudioHiddenSystemField } from './studioHiddenSystemField.js'; import { formDndAccessibility, type FormDndLookups, type FormDndSlot } from './formDndAnnouncements.js'; +import { AddFieldPopover } from './AddFieldPopover.js'; +import type { FieldTypeId } from '../metadata-admin/previews/field-types.js'; const UNGROUPED = '__ungrouped__'; @@ -238,8 +240,12 @@ export interface ObjectFormDesignerProps { selectedField?: string | null; /** Select a field → opens the shared field inspector. */ onSelectField: (name: string) => void; - /** Append a new field (reuses the pillar's add-field). Omit to hide the button — e.g. a read-only package. */ - onAddField?: () => void; + /** + * Add a field with the label and type the toolbar's Add field popover asked + * for (objectui#12516; reuses the pillar's add-field). Omit to hide the + * button — e.g. a read-only package. + */ + onAddField?: (label: string, type: FieldTypeId) => void; /** Currently selected group key (its section is highlighted). */ selectedGroup?: string | null; /** Select a group (section) → opens the group property inspector. Omit to hide the affordance. */ @@ -821,13 +827,14 @@ export function ObjectFormDesigner({ {t('engine.studio.designer.addGroup', locale)} {onAddField && ( - + + + )} )} 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 fada560b8b..3731dc1fd9 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 @@ -288,7 +288,7 @@ describe('Data pillar — an edit taken while an object save is in flight (objec const release = holdNextSave(); await addField(); await screen.findByTestId('data-autosaving', undefined, { timeout: 8000 }); - expect(savesOf('object').map(fieldNames)).toEqual([['title', 'status', 'field_3']]); + expect(savesOf('object').map(fieldNames)).toEqual([['title', 'status', 'new_field']]); await addField(); expect(fieldCountShown()).toBe('4 fields'); @@ -296,8 +296,8 @@ describe('Data pillar — an edit taken while an object save is in flight (objec await settle(); expect(fieldCountShown()).toBe('4 fields'); expect(savesOf('object').map(fieldNames)).toEqual([ - ['title', 'status', 'field_3'], - ['title', 'status', 'field_3', 'field_4'], + ['title', 'status', 'new_field'], + ['title', 'status', 'new_field', 'field_4'], ]); }); @@ -317,7 +317,7 @@ describe('Data pillar — an edit taken while an object save is in flight (objec expect(fieldCountShown()).toBe('3 fields'); expect(savesOf('object').map(fieldNames)).toEqual([ ['status', 'title'], - ['status', 'title', 'field_3'], + ['status', 'title', 'new_field'], ]); }); 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 dac44cc458..b936a4880d 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 @@ -452,7 +452,7 @@ describe('Data pillar — an object switch inside the debounce, the control (obj await addFieldFromGrid(); await waitFor(() => expect(server.saves).toHaveLength(1), SLOW); expect(savesOf('object', 'acme_note').map((b) => readFields(b.fields).entries.map((e) => e.name))).toEqual([ - ['body', 'field_2'], + ['body', 'new_field'], ]); }, 30000); }); @@ -706,7 +706,7 @@ describe("the load window: nothing of the previous item is offered, shown or sav await addFieldFromGrid(); await waitFor(() => expect(server.saves).toHaveLength(1), SLOW); expect(server.saves.map((s) => [s.type, s.name, s.body.label, fieldNamesOf(s.body)])).toEqual([ - ['object', 'acme_note', 'Note', ['body', 'field_2']], + ['object', 'acme_note', 'Note', ['body', 'new_field']], ]); // No field count or add-field of `acme_task` was shown under `acme_note`. expect(shownDuringLoad).toEqual({ fieldCount: false, addField: false }); @@ -760,7 +760,7 @@ describe('a package switch: Automations and Data open nothing of the previous pa await addFieldFromGrid(); await waitFor(() => expect(server.saves).toHaveLength(1), SLOW); expect(server.saves.map((s) => [s.type, s.name, s.packageId, s.body.label, fieldNamesOf(s.body)])).toEqual([ - ['object', 'beta_item', PKG_B, 'Beta item', ['name', 'field_2']], + ['object', 'beta_item', PKG_B, 'Beta item', ['name', 'new_field']], ]); }, 30000); }); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.draftVersionConflict-11773.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.draftVersionConflict-11773.test.tsx index 15e4b27a09..5c55bda381 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.draftVersionConflict-11773.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.draftVersionConflict-11773.test.tsx @@ -237,11 +237,11 @@ async function reorderAndSave(editor: HTMLElement): Promise { async function raceTwoEditors(): Promise<{ a: HTMLElement; b: HTMLElement }> { const a = openEditor(); await loaded(a, '2 fields'); - await addFieldAndSave(a); // A: + field_3 + await addFieldAndSave(a); // A: + new_field const b = openEditor(); await loaded(b, '3 fields'); await reorderAndSave(b); // B: status before title - expect(serverDraftFields()).toEqual(['status', 'title', 'field_3']); + expect(serverDraftFields()).toEqual(['status', 'title', 'new_field']); await addFieldFromGrid(a); // A's stale copy + field_4 await screen.findByTestId('draft-conflict-dialog', undefined, { timeout: 8000 }); return { a, b }; @@ -270,14 +270,14 @@ describe('Data pillar — a draft saved elsewhere is not overwritten in silence await loaded(a, '2 fields'); const b = openEditor(); await loaded(b, '2 fields'); - await addFieldAndSave(a); // A: + field_3, the first draft - expect(serverDraftFields()).toEqual(['title', 'status', 'field_3']); + await addFieldAndSave(a); // A: + new_field, the first draft + expect(serverDraftFields()).toEqual(['title', 'status', 'new_field']); await addFieldFromGrid(b); // B's first save, built on the published object await screen.findByTestId('draft-conflict-dialog', undefined, { timeout: 8000 }); const refused = server.saves[server.saves.length - 1]!; expect([refused.ifMatch, refused.ifNoneMatch]).toEqual([undefined, '*']); // A's field is still in the draft: B's stale copy was not written over it. - expect(serverDraftFields()).toEqual(['title', 'status', 'field_3']); + expect(serverDraftFields()).toEqual(['title', 'status', 'new_field']); }); it('an editor that opens on a pending draft pins its first save to the version that read served', async () => { @@ -292,9 +292,9 @@ describe('Data pillar — a draft saved elsewhere is not overwritten in silence const { a } = await raceTwoEditors(); const refused = server.saves[server.saves.length - 1]!; expect(refused.ifMatch).toBe('hmac-sha256:v1'); - expect(fieldNames(refused.body)).toEqual(['title', 'status', 'field_3', 'field_4']); + expect(fieldNames(refused.body)).toEqual(['title', 'status', 'new_field', 'field_4']); // Nothing was written over B's draft. - expect(serverDraftFields()).toEqual(['status', 'title', 'field_3']); + expect(serverDraftFields()).toEqual(['status', 'title', 'new_field']); expect(screen.getByTestId('draft-conflict-dialog')).toHaveTextContent('acme_task'); fireEvent.click(screen.getByTestId('draft-conflict-cancel')); @@ -302,7 +302,7 @@ describe('Data pillar — a draft saved elsewhere is not overwritten in silence // Keep editing: A's edit stays on screen, unsaved, and the refusal says so. expect(fieldCount(a)).toBe('4 fields'); expect(within(a).getByText(/^Not saved:/)).toBeInTheDocument(); - expect(serverDraftFields()).toEqual(['status', 'title', 'field_3']); + expect(serverDraftFields()).toEqual(['status', 'title', 'new_field']); }); it('"reload" replaces the stale buffer with the saved draft, and the next save is pinned to that draft\'s version', async () => { @@ -317,7 +317,7 @@ describe('Data pillar — a draft saved elsewhere is not overwritten in silence const next = server.saves[server.saves.length - 1]!; expect(next.ifMatch).toBe('hmac-sha256:v2'); // Built on B's draft: B's column order is kept. - expect(fieldNames(next.body)).toEqual(['status', 'title', 'field_3', 'field_4']); + expect(fieldNames(next.body)).toEqual(['status', 'title', 'new_field', 'field_4']); }); it('"overwrite" asks again, then re-sends pinned to the refusal\'s currentVersion and wins; the next save is pinned to it', async () => { @@ -334,7 +334,7 @@ describe('Data pillar — a draft saved elsewhere is not overwritten in silence const overwrite = server.saves[server.saves.length - 1]!; // B's draft is the version the refusal named. expect([overwrite.ifMatch, overwrite.ifNoneMatch]).toEqual(['hmac-sha256:v2', undefined]); - expect(serverDraftFields()).toEqual(['title', 'status', 'field_3', 'field_4']); + expect(serverDraftFields()).toEqual(['title', 'status', 'new_field', 'field_4']); await waitFor(() => expect(within(a).queryByTestId('data-autosaving')).toBeNull(), { timeout: 8000 }); await addFieldAndSave(a); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 54029e947b..bffd402ebf 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -255,8 +255,11 @@ import { readFields, writeFields, newField, + toFieldNameLoose, } from '../metadata-admin/previews/object-fields-io.js'; +import type { FieldTypeId } from '../metadata-admin/previews/field-types.js'; import { CreateItemDialog } from './CreateItemDialog.js'; +import { AddFieldPopover } from './AddFieldPopover.js'; // objectui#11787 — the shared draft autosave, moved to its own module unchanged. import { useDraftAutoSave, type DraftSend } from './useDraftAutoSave.js'; import { FLOW_PRESETS, flowPresetTrigger, type FlowPreset } from './flowPresets.js'; @@ -4691,6 +4694,17 @@ function nextFieldName(existing: string[]): string { return name; } +/** + * The api name of a field added with `label` (objectui#12516): the name the + * field inspector's Label derives from it (`toFieldNameLoose`), or, as the + * inspector keeps the default when that derivation is empty (a label with no + * Latin letters) or names another field, the next free `field_`. + */ +function addedFieldName(label: string, existing: string[]): string { + const derived = toFieldNameLoose(label); + return derived && !existing.includes(derived) ? derived : nextFieldName(existing); +} + /** * Data pillar — the package's objects: a records grid (Airtable parity) plus * table-based field management. Add a field, or click a column header's edit @@ -5443,20 +5457,29 @@ export function DataPillar({ const objHeld = objLoaded ? objIncomplete : null; useHeldEditReport(onHeldEditChange, objHeld?.clause ?? null); - // "+ add field": append a fresh text field and select it for editing in the panel. + // "+ add field": append a field with the label and type the Add field + // popover asked for (objectui#12516) and select it for editing in the panel. // Guarded in addition to being hidden — it's also reachable through // GridFieldAuthoringProvider/ObjectFormDesigner. - const addField = React.useCallback(() => { + const addField = React.useCallback((label: string, type: FieldTypeId) => { // objectui#11272 — never onto another object's buffer (not offered then). if (readOnly || !objLoaded) return; const view = readFields(objDraft.fields); - const name = nextFieldName(view.entries.map((e) => e.name)); - view.entries.push(newField(name, 'text', t('engine.studio.data.newFieldLabel', locale))); + const name = addedFieldName(label, view.entries.map((e) => e.name)); + view.entries.push(newField(name, type, label)); setObjDraft((d) => ({ ...d, fields: writeFields(view) })); setDirty(true); setFieldSel({ kind: 'field', id: name }); }, [objDraft, readOnly, objLoaded]); + // objectui#12516 — the Records grid's "+" column opens the same popover. The + // grid draws that button from its authoring context and hands no element + // back, so the popover is placed against the records body's top-right + // corner, and the button is remembered (from the click on its way down) to + // take focus back when the popover closes. + const [gridAddFieldOpen, setGridAddFieldOpen] = React.useState(false); + const gridAddFieldButton = React.useRef(null); + // Drag-reorder columns → reorder the object's `fields` metadata (field display // order follows metadata order), saved as a DRAFT. Published later via the // package release — NOT auto-published per reorder as it used to be. @@ -5729,7 +5752,8 @@ export function DataPillar({ it reads the same on every tab. Object settings retired: the Settings tab is that control. Add field is a field action, offered where fields are laid out: the Form toolbar and the - Records grid's own "+" column, both running `addField`. The + Records grid's own "+" column, both opening the Add field + popover that runs `addField` (objectui#12516). The More menu went with it, since the header has no object-level action left to put in one. */} {/* objectui#12453 — on the Form tab it carries the layout's @@ -5869,7 +5893,27 @@ export function DataPillar({ {/* Records grid — fields are the columns. Header "+" adds a field, the * per-column edit affordance opens the field editor, and dragging a * column header reorders the object's fields (all via the context). */} -
+
{ + // React carries a click inside the (portalled) popover here + // too; only a click in the body's own DOM is the grid's. + if (e.target instanceof Element && e.currentTarget.contains(e.target)) { + gridAddFieldButton.current = e.target.closest('button'); + } + }} + > + {!readOnly && ( + gridAddFieldButton.current} + > + + )} setGridAddFieldOpen(true), addColumnLabel: t('engine.studio.data.addField', locale), onReorderFields: doReorderFields, }), diff --git a/packages/app-shell/src/views/studio-design/__tests__/dataPillarAddField.ts b/packages/app-shell/src/views/studio-design/__tests__/dataPillarAddField.ts index a8b349b3d7..adf9ea9385 100644 --- a/packages/app-shell/src/views/studio-design/__tests__/dataPillarAddField.ts +++ b/packages/app-shell/src/views/studio-design/__tests__/dataPillarAddField.ts @@ -4,7 +4,7 @@ * Add a field the way the Data pillar offers it (objectui#12511). * * The object header holds no Add field: the action lives where fields are laid - * out, and both entries run the pillar's one `addField`: + * out, and both entries open the popover that runs the pillar's one `addField`: * * - the Records grid's own "+" column, the `grid-add-column` button the grid * draws from its authoring context (`GridFieldAuthoringProvider` hands it @@ -13,6 +13,9 @@ * that column; * - the Form tab's toolbar in Layout, `form-toolbar` in `ObjectFormDesigner`. * + * Both open the Add field popover (objectui#12516), which asks for the label + * and type before the field is created; the helpers below fill it. + * * {@link ADD_FIELD_OFFERED} is the id a Records-tab suite reads to ask "is Add * field offered here?": the grid draws it exactly when the object is loaded * on a writable package. @@ -26,21 +29,54 @@ import { t } from '../../metadata-admin/i18n'; export const ADD_FIELD_OFFERED = 'grid-add-column'; /** - * Pick the Records grid's "+" column. `scope` is the editor whose grid to use - * when a suite renders more than one. + * What the Add field popover is filled with (objectui#12516). The label is + * typed into the popover's Label box; the field's api name is the one the + * pillar derives from it (`New field` → `new_field`). `type`, when given, is + * the visible name of the type to pick in the popover's Type control; Text is + * preselected. + */ +export interface AddFieldInput { + label?: string; + type?: RegExp; +} + +/** The label a suite's added field gets when it names none. */ +export const ADDED_FIELD_LABEL = 'New field'; + +/** + * Fill the open Add field popover and press Create (objectui#12516). Every + * entry opens the same popover, so this is the step after any of them. + */ +export async function completeAddFieldPopover({ label = ADDED_FIELD_LABEL, type }: AddFieldInput = {}): Promise { + const popover = await screen.findByTestId('add-field-popover'); + await userEvent.type(within(popover).getByTestId('add-field-label-input'), label); + if (type) { + await userEvent.click(within(popover).getByRole('combobox', { name: t('designer.field.type', 'en') })); + await userEvent.click(await screen.findByRole('option', { name: type })); + } + await userEvent.click(within(popover).getByTestId('add-field-create')); +} + +/** + * Pick the Records grid's "+" column and create the field from the popover it + * opens. `scope` is the editor whose grid to use when a suite renders more + * than one. */ -export async function addFieldFromGrid(scope?: HTMLElement): Promise { +export async function addFieldFromGrid(scope?: HTMLElement, input?: AddFieldInput): Promise { const where = scope ? within(scope) : screen; await userEvent.click(await where.findByTestId(ADD_FIELD_OFFERED)); + await completeAddFieldPopover(input); } /** - * Pick Add field on the Form tab's toolbar (Layout). The suite has opened the - * Form tab already; the toolbar's button carries no test id, so it is read by - * its English label, the locale these suites render in. + * Pick Add field on the Form tab's toolbar (Layout) and create the field from + * the popover it opens. The suite has opened the Form tab already; the + * toolbar's button carries no test id, so it is read by its English label, the + * locale these suites render in. */ -export async function addFieldFromFormToolbar(scope?: HTMLElement): Promise { +export async function addFieldFromFormToolbar(scope?: HTMLElement, input?: AddFieldInput): Promise { const where = scope ? within(scope) : screen; const toolbar = await where.findByTestId('form-toolbar'); await userEvent.click(within(toolbar).getByRole('button', { name: t('engine.studio.data.addField', 'en') })); + await completeAddFieldPopover(input); }