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
15 changes: 15 additions & 0 deletions .changeset/field-locators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@object-ui/components": patch
"@object-ui/types": patch
"@object-ui/plugin-form": patch
---

feat(components): metadata-derived field locators on generated forms (ADR-0054 Phase 4)

The form renderer now emits a stable `data-testid="field:{objectName}.{field}"`
(plus `data-field`) on every field wrapper, derived from the form's `objectName`
and each field's name — closing the locator gap at the source so every generated
form (`ObjectForm`/`ModalForm`/`DrawerForm`/`SplitForm`/`WizardForm`) inherits
testable fields with zero per-app work (ADR-0054 C4). `FormSchema` gains an
optional `objectName`; the object prefix is omitted (`field:{field}`) when a form
has none. `FormItem` now accepts `data-*` attributes.
21 changes: 21 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,27 @@ list view and record-picker results set `aria-busy` while fetching and
`data-state="loading|idle"`, complementing the Radix `data-state` already on
overlays.

## Field locators (`field:{object}.{field}`)

Generated forms emit a metadata-derived stable locator on every field wrapper, so
an automated (AI) driver can target a field without relying on i18n-fragile labels
or positional selectors (ADR-0054 C4). The form renderer derives it from the
form's `objectName` and each field's name — every form (`ObjectForm`, `ModalForm`,
`DrawerForm`, `SplitForm`, `WizardForm`) inherits it with zero per-app work:

```html
<div data-testid="field:account.industry" data-field="industry"> … input … </div>
```

```js
// e2e / AI driver:
await page.getByTestId('field:account.industry').locator('input').fill('SaaS');
```

The object prefix is omitted (`field:{field}`) when a form has no owning object.
This complements the action/overlay locators already emitted by the renderer
(`overlay:command-palette`, `action:command-palette:open`, …).

## Compatibility

- **React:** 18.x or 19.x
Expand Down
27 changes: 27 additions & 0 deletions packages/components/src/__tests__/form-renderers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,4 +361,31 @@ describe('Form Renderers - Display Issue Detection', () => {
expect(label?.textContent).toContain('Form Label');
});
});

describe('Field locators (ADR-0054 C4)', () => {
it('emits a metadata-derived field:{object}.{field} locator on each field', () => {
renderComponent({
type: 'form',
objectName: 'account',
fields: [
{ name: 'industry', label: 'Industry', type: 'input' },
{ name: 'annual_revenue', label: 'Annual Revenue', type: 'input' },
],
} as any);

const industry = screen.getByTestId('field:account.industry');
expect(industry).toBeInTheDocument();
expect(industry.getAttribute('data-field')).toBe('industry');
expect(screen.getByTestId('field:account.annual_revenue')).toBeInTheDocument();
});

it('omits the object prefix when the form has no objectName', () => {
renderComponent({
type: 'form',
fields: [{ name: 'email', label: 'Email', type: 'input' }],
} as any);

expect(screen.getByTestId('field:email')).toBeInTheDocument();
});
});
});
12 changes: 11 additions & 1 deletion packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -623,14 +623,24 @@ ComponentRegistry.register('form',
: ''
: '';

// Metadata-derived stable locator (ADR-0054 C4): the renderer
// emits it from the object + field name so every generated form
// inherits it with zero per-app work. Object prefix omitted when
// the form schema has no owning object.
const fieldTestId = `field:${schema.objectName ? `${schema.objectName}.` : ''}${name}`;

return (
<FormField
key={fieldKey}
control={form.control}
name={name}
rules={rules}
render={({ field: formField }) => (
<FormItem className={colSpanClass || undefined}>
<FormItem
className={colSpanClass || undefined}
data-testid={fieldTestId}
data-field={name}
>
{label && (
<FormLabel className="text-xs sm:text-sm">
{label}
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/ui/form.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/plugin-form/src/DrawerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
// Build base form schema
const baseFormSchema = {
type: 'form' as const,
objectName: schema.objectName,
layout: formLayout,
defaultValues: formData,
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({

const baseFormSchema = {
type: 'form' as const,
objectName: schema.objectName,
layout: formLayout,
defaultValues: formData,
submitLabel,
Expand Down
2 changes: 2 additions & 0 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,7 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
<SchemaRenderer
schema={{
type: 'form',
objectName: schema.objectName,
fields: groupedFields,
layout: formLayout,
defaultValues: finalDefaultValues,
Expand Down Expand Up @@ -843,6 +844,7 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
// Default flat form (no sections)
const formSchema: FormSchema = {
type: 'form',
objectName: schema.objectName,
fields: fieldsWithMobile,
layout: formLayout,
columns: autoLayoutResult.columns,
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-form/src/SplitForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export const SplitForm: React.FC<SplitFormProps> = ({
// Build base form schema for SchemaRenderer
const baseFormSchema = {
type: 'form' as const,
objectName: schema.objectName,
layout: 'vertical' as const,
defaultValues: formData,
onSubmit: handleSubmit,
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-form/src/WizardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ export const WizardForm: React.FC<WizardFormProps> = ({
key={resetNonce}
schema={{
type: 'form' as const,
objectName: schema.objectName,
id: stepFormId,
fields: currentSectionFields,
layout: 'vertical' as const,
Expand Down
5 changes: 5 additions & 0 deletions packages/types/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,11 @@ export interface FormField {
*/
export interface FormSchema extends BaseSchema {
type: 'form';
/**
* Owning object name. When set, the renderer derives metadata-based field
* locators `data-testid="field:{objectName}.{field}"` (ADR-0054 C4).
*/
objectName?: string;
/**
* Form fields configuration
*/
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/zod/form.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ export const FormFieldSchema = z.object({
*/
export const FormSchema = BaseSchema.extend({
type: z.literal('form'),
objectName: z.string().optional().describe('Owning object name (drives field locators)'),
fields: z.array(FormFieldSchema).describe('Form fields'),
defaultValues: z.record(z.string(), z.any()).optional().describe('Default form values'),
submitLabel: z.string().optional().describe('Submit button label'),
Expand Down
Loading