diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listGroupLabels-27256.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listGroupLabels-27256.test.tsx new file mode 100644 index 00000000000..7948cd7fdcd --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listGroupLabels-27256.test.tsx @@ -0,0 +1,137 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The page block inspector's three LIST editors are groups named by the label + * drawn above them: the field list (`FieldListField`, e.g. Columns), the + * string list and the array editor. Each label heads several rows, so it is + * not a `for` pairing — the container takes `role="group"` and answers the + * label's id through `aria-labelledby`, the WAI-ARIA group pattern + * `ColorPropField` already uses. Before this, `getByLabelText('Columns')` found + * the label and no control, and a screen reader heard the rows with no group + * name. + * + * The id is minted per instance (`React.useId()`), because these editors also + * render inside array rows: the last case draws one field list per + * `record:details` section and pins that each group answers to its own label. + * + * Row inputs that carry only a repeated placeholder are named from the list's + * label plus their position ("Action names 1"). + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, within, cleanup } from '@testing-library/react'; + +const state = vi.hoisted(() => ({ + fields: [{ name: 'subject', label: 'Subject', type: 'text', hidden: false }] as Array>, +})); + +// The filter builder lives inside a Radix popover, which does not open in +// jsdom; the popover parts render inline (this directory's existing way). +vi.mock('@object-ui/components', async () => { + const actual = await vi.importActual>('@object-ui/components'); + const Pass = ({ children }: { children?: unknown }) => children as never; + return { ...actual, Popover: Pass, PopoverTrigger: Pass, PopoverContent: Pass }; +}); +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: state.fields, loading: false, error: null }), +})); +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => ({ get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) }), +})); + +import { PageBlockInspector } from './PageBlockInspector'; + +afterEach(() => { + cleanup(); + state.fields = [{ name: 'subject', label: 'Subject', type: 'text', hidden: false }]; +}); + +type Row = Record; + +function renderBlock(type: string, properties: Row) { + return render( + {}} + onClearSelection={() => {}} + readOnly={false} + locale="en-US" + />, + ); +} + +/** The group's IDREF resolves to the label element that carries the name. */ +function expectNamedByItsLabel(group: HTMLElement, label: string) { + const labelId = group.getAttribute('aria-labelledby'); + expect(labelId).toBeTruthy(); + const labelEl = document.getElementById(labelId!); + expect(labelEl).not.toBeNull(); + expect(labelEl!.textContent).toBe(label); + expect(group).toContainElement(labelEl); +} + +describe('page block inspector — list editors are groups named by their label', () => { + it('names the field list (Columns) and the array editor (Sort), with their rows inside', () => { + renderBlock('record:related_list', { + objectName: 'task', + relationshipField: 'lead', + columns: ['subject'], + sort: [{ field: 'subject', order: 'asc' }], + }); + + const columns = screen.getByRole('group', { name: 'Columns' }); + expectNamedByItsLabel(columns, 'Columns'); + expect(within(columns).getAllByRole('combobox')).toHaveLength(1); + expect(within(columns).getByRole('button', { name: 'Remove' })).toBeInTheDocument(); + + const sort = screen.getByRole('group', { name: 'Sort' }); + expectNamedByItsLabel(sort, 'Sort'); + expect(within(sort).getByText('#1')).toBeInTheDocument(); + expect(within(sort).getByRole('button', { name: 'Remove item' })).toBeInTheDocument(); + + expect(columns.getAttribute('aria-labelledby')).not.toBe(sort.getAttribute('aria-labelledby')); + }); + + it('names the string list, and each row input by the label and its position', () => { + renderBlock('record:quick_actions', { actionNames: ['approve', 'reject'] }); + + const list = screen.getByRole('group', { name: 'Action names' }); + expectNamedByItsLabel(list, 'Action names'); + expect(within(list).getByRole('textbox', { name: 'Action names 1' })).toHaveValue('approve'); + expect(within(list).getByRole('textbox', { name: 'Action names 2' })).toHaveValue('reject'); + }); + + it('names the field list text fallback rows the same way when the object has no fields', () => { + state.fields = []; + renderBlock('record:related_list', { objectName: 'task', columns: ['subject'] }); + + const columns = screen.getByRole('group', { name: 'Columns' }); + expect(within(columns).getByRole('textbox', { name: 'Columns 1' })).toHaveValue('subject'); + }); + + it('mints a distinct id per instance when the same list repeats inside array rows', () => { + renderBlock('record:details', { + sections: [ + { name: 'main', label: 'Main', fields: ['subject'] }, + { name: 'more', label: 'More', fields: ['subject'] }, + ], + }); + + const sections = screen.getByRole('group', { name: 'Sections' }); + const fieldLists = within(sections).getAllByRole('group', { name: 'Fields' }); + expect(fieldLists).toHaveLength(2); + fieldLists.forEach((g) => expectNamedByItsLabel(g, 'Fields')); + expect(fieldLists[0].getAttribute('aria-labelledby')).not.toBe(fieldLists[1].getAttribute('aria-labelledby')); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx index e552a33e1ea..4885e6448e4 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx @@ -114,6 +114,28 @@ function FieldPickerField({ label, objectName, value, onCommit, disabled }: { return ; } +/** + * The visible label over a LIST of rows, and the group it names. A label that + * heads several controls is not a `for` pairing (that names one control), so + * the container takes `role="group"` and answers the label's id by IDREF — + * `ColorPropField`'s pattern. Its own component for the same reason as that + * one: the id is minted with `React.useId()` per instance, and `renderField` + * (a function run in a loop over array items, not a component) cannot call a + * hook, while a caller-derived id would repeat across two array rows carrying + * the same property and resolve to the first group. + */ +function LabelledListGroup({ label, className, children }: { + label: string; className: string; children: React.ReactNode; +}) { + const labelId = React.useId(); + return ( +
+ + {children} +
+ ); +} + /** Editable list of field names — each row a field dropdown (or text fallback). */ function FieldListField({ label, objectName, value, onChange, disabled, locale }: { label: string; objectName: string | undefined; value: unknown; onChange: (v: string[]) => void; disabled?: boolean; @@ -122,8 +144,7 @@ function FieldListField({ label, objectName, value, onChange, disabled, locale } const options = useFieldOptions(objectName); const arr: string[] = Array.isArray(value) ? (value as string[]) : []; return ( -
- + {arr.map((s, i) => (
{options.length > 0 ? ( @@ -137,6 +158,7 @@ function FieldListField({ label, objectName, value, onChange, disabled, locale }
) : ( { const n = [...arr]; n[i] = e.target.value; onChange(n); }} /> )} )} -
+ ); } @@ -625,12 +647,15 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection ); case 'string-list': { const arr = Array.isArray(read(f.name)) ? (read(f.name) as unknown[]) : []; + // Each row's input is named from the list's label plus its position: + // the placeholder is the only other text it carries, and it repeats. + const label = fieldLabel(f.label); return ( -
- + {arr.map((s, i) => (
{ const next = [...arr]; next[i] = e.target.value; write(f.name, next); }} /> )} -
+
); } case 'array': { @@ -654,8 +679,7 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection if (read(f.name) !== undefined && !Array.isArray(read(f.name))) return jsonFallback(); const arr = Array.isArray(read(f.name)) ? (read(f.name) as unknown[]) : []; return ( -
- + {arr.map((item, i) => { const itemObj = item && typeof item === 'object' ? (item as Record) : {}; return ( @@ -684,7 +708,7 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection {fieldLabel(f.addLabel)} )} -
+ ); } case 'object-picker':