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
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>,
}));

// 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<Record<string, unknown>>('@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<string, unknown>;

function renderBlock(type: string, properties: Row) {
return render(
<PageBlockInspector
type="page"
name="lead_page"
draft={{
name: 'lead_page',
type: 'record',
object: 'lead',
kind: 'full',
regions: [{ name: 'main', components: [{ id: 'b1', type, properties }] }],
}}
selection={{ kind: 'block', id: 'regions[0].components[0]' }}
onPatch={() => {}}
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'));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,28 @@ function FieldPickerField({ label, objectName, value, onCommit, disabled }: {
return <InspectorSelectField label={label} value={value || undefined} options={options} onCommit={onCommit} disabled={disabled} />;
}

/**
* 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 (
<div role="group" aria-labelledby={labelId} className={className}>
<Label id={labelId} className="text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}

/** 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;
Expand All @@ -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 (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{label}</Label>
<LabelledListGroup label={label} className="space-y-1.5">
{arr.map((s, i) => (
<div key={i} className="flex items-center gap-1.5">
{options.length > 0 ? (
Expand All @@ -137,6 +158,7 @@ function FieldListField({ label, objectName, value, onChange, disabled, locale }
</div>
) : (
<Input className="h-8 text-sm" value={String(s ?? '')} placeholder={t('engine.inspector.pageBlock.fieldPlaceholder', locale)} disabled={disabled}
aria-label={`${label} ${i + 1}`}
onChange={(e) => { const n = [...arr]; n[i] = e.target.value; onChange(n); }} />
)}
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" disabled={disabled}
Expand All @@ -151,7 +173,7 @@ function FieldListField({ label, objectName, value, onChange, disabled, locale }
<Plus className="mr-1 h-3.5 w-3.5" /> {t('engine.inspector.pageBlock.list.add', locale)}
</Button>
)}
</div>
</LabelledListGroup>
);
}

Expand Down Expand Up @@ -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 (
<div key={k} className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{fieldLabel(f.label)}</Label>
<LabelledListGroup key={k} label={label} className="space-y-1.5">
{arr.map((s, i) => (
<div key={i} className="flex items-center gap-1.5">
<Input className="h-8 text-sm" value={String(s ?? '')} placeholder={placeholderText(f.placeholder)} disabled={readOnly}
aria-label={`${label} ${i + 1}`}
onChange={(e) => { const next = [...arr]; next[i] = e.target.value; write(f.name, next); }} />
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" disabled={readOnly}
aria-label={t('engine.inspector.pageBlock.list.remove', locale)}
Expand All @@ -644,7 +669,7 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
<Plus className="mr-1 h-3.5 w-3.5" /> {t('engine.inspector.pageBlock.list.add', locale)}
</Button>
)}
</div>
</LabelledListGroup>
);
}
case 'array': {
Expand All @@ -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 (
<div key={k} className="space-y-2">
<Label className="text-xs text-muted-foreground">{fieldLabel(f.label)}</Label>
<LabelledListGroup key={k} label={fieldLabel(f.label)} className="space-y-2">
{arr.map((item, i) => {
const itemObj = item && typeof item === 'object' ? (item as Record<string, unknown>) : {};
return (
Expand Down Expand Up @@ -684,7 +708,7 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
<Plus className="mr-1 h-3.5 w-3.5" /> {fieldLabel(f.addLabel)}
</Button>
)}
</div>
</LabelledListGroup>
);
}
case 'object-picker':
Expand Down
Loading