diff --git a/.changeset/12854-line-items-hydrate.md b/.changeset/12854-line-items-hydrate.md new file mode 100644 index 0000000000..b7e4e76d30 --- /dev/null +++ b/.changeset/12854-line-items-hydrate.md @@ -0,0 +1,9 @@ +--- +'@object-ui/plugin-form': patch +--- + +`record:line_items` fills an untyped column from the child object's field (objectui#12854, the objectui half of objectstack-ai/objectstack#24140). + +A column listed only by `name` was headed by the bare field name and edited as plain text, on screen and on a print page. The panel now passes its columns through `hydrateColumns`, the function the master-detail form already uses: a column without a `type` takes its `label`, `type` and `options` (and the field's `required`, lookup reference and formula) from the child object's field. The child schema is the one the panel already loads, which the object-metadata endpoint serves in the session locale, so a heading translates the same way a master-detail grid's does. + +An authored `label` or `type` still wins, and a column whose field the child object does not declare renders as before. Nothing is added to the package entry: no export, prop, type member or language-pack key. diff --git a/packages/plugin-form/src/LineItemsPanel.childFieldHydration-12854.test.tsx b/packages/plugin-form/src/LineItemsPanel.childFieldHydration-12854.test.tsx new file mode 100644 index 0000000000..2721e8e3b6 --- /dev/null +++ b/packages/plugin-form/src/LineItemsPanel.childFieldHydration-12854.test.tsx @@ -0,0 +1,151 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A `record:line_items` column the author left untyped takes its `label`, + * `type` and `options` from the child object's field (objectui#12854), through + * the same `hydrateColumns` the master-detail carriers call. + * + * Before this, an identity-only column (`{ name }`) was headed by the bare + * field name and drew its value as text, on screen and on a print page. The + * spec's `RecordLineItemsProps.columns` describe declares the hydration default + * (objectstack-ai/objectstack#24140). + * + * The heading's translation is the master-detail carriers' path: the + * object-metadata endpoint serves the child object translated into the session + * locale (`translateObject`, from `objects.CHILD.fields.F.label`), and the + * hydrated label is the served one. The zh case therefore serves the child + * schema the way that endpoint answers a zh session. + * + * The `CONTROL` cases pin the other half: an authored `label` is kept, and a + * column whose field the child object does not declare renders as written. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as React from 'react'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { registerAllFields } from '@object-ui/fields'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { LineItemsPanel } from './LineItemsPanel'; + +registerAllFields(); + +afterEach(() => cleanup()); + +const CHILD_EN = { + name: 'invoice_line', + fields: { + invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice' }, + quantity: { type: 'number', label: 'Quantity' }, + status: { + type: 'select', + label: 'Line status', + options: [ + { value: 'open', label: 'Open' }, + { value: 'billed', label: 'Billed' }, + ], + }, + }, +}; + +/** The same child object as the metadata endpoint serves it to a zh session. */ +const CHILD_ZH = { + name: 'invoice_line', + fields: { + invoice: { type: 'master_detail', label: '发票', reference: 'invoice' }, + quantity: { type: 'number', label: '数量' }, + status: { + type: 'select', + label: '行状态', + options: [ + { value: 'open', label: '未结' }, + { value: 'billed', label: '已开票' }, + ], + }, + }, +}; + +const ROWS = [{ id: 'l1', quantity: 3, status: 'open', note: 'rush' }]; + +function panel(columns: unknown[]) { + return { + childObject: 'invoice_line', + relationshipField: 'invoice', + parentObject: 'invoice', + parentId: 'inv1', + columns, + }; +} + +function dataSource(childSchema: unknown) { + return { + getObjectSchema: vi.fn().mockResolvedValue(childSchema), + find: vi.fn(async () => ({ data: ROWS })), + }; +} + +function mount(columns: unknown[], childSchema: unknown, language?: string) { + const tree = ( + + + + ); + return render( + language ? ( + {tree} + ) : ( + tree + ), + ); +} + +describe('record:line_items hydrates an untyped column from the child field (objectui#12854)', () => { + it('an identity-only column is headed by the child field label, and draws through its type and options', async () => { + mount([{ name: 'quantity' }, { name: 'status' }], CHILD_EN); + + expect(await screen.findByRole('columnheader', { name: 'Quantity' })).toBeInTheDocument(); + expect(await screen.findByRole('columnheader', { name: 'Line status' })).toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: 'quantity' })).toBeNull(); + expect(screen.queryByRole('columnheader', { name: 'status' })).toBeNull(); + // `type: 'select'` and `options` came from the field: the cell is a picker + // named by the hydrated label, showing the option's label rather than the + // stored value. + const [status] = await screen.findAllByRole('combobox', { name: 'Line status' }); + expect(status).toHaveTextContent('Open'); + expect(status).not.toHaveTextContent('open'); + }); + + it('zh: the heading and the option label are the child field\'s translation as served', async () => { + mount([{ name: 'quantity' }, { name: 'status' }], CHILD_ZH, 'zh'); + + expect(await screen.findByRole('columnheader', { name: '数量' })).toBeInTheDocument(); + expect(await screen.findByRole('columnheader', { name: '行状态' })).toBeInTheDocument(); + expect((await screen.findAllByRole('combobox', { name: '行状态' }))[0]).toHaveTextContent('未结'); + expect(screen.queryByRole('columnheader', { name: 'quantity' })).toBeNull(); + }); + + it('CONTROL — an authored label is kept; the type and options still come from the field', async () => { + mount([{ name: 'quantity', label: 'Units' }, { name: 'status', label: 'State' }], CHILD_EN); + + expect(await screen.findByRole('columnheader', { name: 'Units' })).toBeInTheDocument(); + expect(await screen.findByRole('columnheader', { name: 'State' })).toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: 'Quantity' })).toBeNull(); + expect(screen.queryByRole('columnheader', { name: 'Line status' })).toBeNull(); + expect((await screen.findAllByRole('combobox', { name: 'State' }))[0]).toHaveTextContent('Open'); + }); + + it('CONTROL — a column whose field the child object does not declare renders as written', async () => { + mount([{ name: 'quantity' }, { name: 'note' }], CHILD_EN); + + expect(await screen.findByRole('columnheader', { name: 'Quantity' })).toBeInTheDocument(); + expect(await screen.findByRole('columnheader', { name: 'note' })).toBeInTheDocument(); + // As before: no type, so a plain text input over the stored value. + expect(((await screen.findAllByLabelText('note'))[0] as HTMLInputElement).value).toBe('rush'); + }); +}); diff --git a/packages/plugin-form/src/LineItemsPanel.tsx b/packages/plugin-form/src/LineItemsPanel.tsx index 3b94b85f39..892b35f0be 100644 --- a/packages/plugin-form/src/LineItemsPanel.tsx +++ b/packages/plugin-form/src/LineItemsPanel.tsx @@ -33,6 +33,7 @@ import { useSchemaContext, useRecordContext, useFilterScope, useResolvedFilter, import { usePermissions } from '@object-ui/permissions'; import { buildMasterDetailEditBatch, sumRows } from './masterDetailTx'; import { applyColumnPermissions } from './fieldWriteGate'; +import { hydrateColumns } from './deriveMasterDetail'; import { runBatchTransaction, mergeFilterNodes, @@ -311,7 +312,8 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch // Child object schema — used to strip computed / read-only columns from each // row before persisting (parity with the parent form's sanitize). Rows are // loaded from a full read, so an edit would otherwise round-trip formula / - // summary columns the server rejects as unknown fields. + // summary columns the server rejects as unknown fields. The grid's untyped + // columns are hydrated from it too (`gridField` below, objectui#12854). const [childSchema, setChildSchema] = useState<{ fields?: Record } | null>(null); useEffect(() => { const ds: any = dataSource; @@ -745,7 +747,21 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch // the surrounding form already disables that field (objectui#10163). // Adding and removing lines are the map rows above, on top of // `schema.readonly` below. - columns: applyColumnPermissions(schema.columns, { perms, objectName: schema.childObject }), + // + // Before that pass, a column the author left untyped (`{ name }`, or + // `{ name, label }`) takes its `label`, `type` and `options` from the + // child object's field, through the same `hydrateColumns` the + // master-detail carriers call (objectui#12854). The child schema is + // the one loaded above; the object-metadata endpoint serves it + // translated into the session locale, so an identity-only column's + // heading reads the child field's translated label, as a + // master-detail grid's does. An authored `label` or `type` still wins, + // and a column whose field the child does not declare is kept as + // written. Until the schema arrives the columns render as authored. + columns: applyColumnPermissions(hydrateColumns(schema.columns, childSchema ?? undefined), { + perms, + objectName: schema.childObject, + }), // The grid's `totalField` is the CHILD column summed (this block's // `amountField`), shown whenever one is named, exactly as // `MasterDetailForm` maps it. This block's own `totalField` is only @@ -760,7 +776,7 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch // the grid does not declare, or one of its retired snake_case // spellings, fails to compile here. } satisfies Partial) as GridFieldMetadata, - [schema, perms, lineAddGranted, lineRemoveGranted], + [schema, childSchema, perms, lineAddGranted, lineRemoveGranted], ); // The config hint names the property to set as code, and the property name