Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/12516-add-field-popover.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3439,6 +3439,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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':
Expand Down Expand Up @@ -6656,6 +6657,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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':
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[m] = m === 'hasPointerCapture' ? () => false : () => {};
}
}
if (!('scrollIntoView' in Element.prototype)) {
(Element.prototype as unknown as Record<string, unknown>).scrollIntoView = () => {};
}
});

afterEach(cleanup);

async function open(locale: SupportedLocale = 'en-US', value = 'text') {
const onCommit = vi.fn();
render(<FieldTypePicker label="Type" value={value} onCommit={onCommit} locale={locale} />);
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -198,7 +238,8 @@ export function FieldTypePicker({ label, value, onCommit, disabled, locale: loca
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0">
{/* 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. */}
<Command
Expand Down
142 changes: 142 additions & 0 deletions packages/app-shell/src/views/studio-design/AddFieldPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* AddFieldPopover — what the Data pillar's "Add field" opens (objectui#12516).
*
* "Add field" used to create a field at once, named "New field" with a
* `field_<N>` 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<FieldTypeId>('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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
align={align}
aria-labelledby={titleId}
data-testid="add-field-popover"
className="w-72 p-3"
onCloseAutoFocus={(e) => {
const target = returnFocusTo?.();
if (target) {
e.preventDefault();
target.focus();
}
}}
>
<form onSubmit={submit} className="space-y-3">
<p id={titleId} className="text-[13px] font-semibold">
{t('engine.studio.data.addField', locale)}
</p>
<div className="space-y-1">
<Label htmlFor={labelId} className="text-xs text-muted-foreground">
{t('designer.field.label', locale)}
</Label>
<Input
id={labelId}
value={label}
onChange={(e) => 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"
/>
</div>
<FieldTypePicker label={t('designer.field.type', locale)} value={type} onCommit={setType} locale={locale} />
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" className="h-7 px-2.5 text-xs" onClick={() => setOpen(false)}>
{t('engine.studio.cancel', locale)}
</Button>
<Button
type="submit"
size="sm"
disabled={!trimmed}
data-testid="add-field-create"
className="h-7 px-2.5 text-xs"
>
{t('engine.studio.data.addFieldCreate', locale)}
</Button>
</div>
</form>
</PopoverContent>
</Popover>
);
}
Loading
Loading