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
9 changes: 9 additions & 0 deletions .changeset/12854-line-items-hydrate.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 = (
<SchemaRendererProvider dataSource={dataSource(childSchema) as never}>
<LineItemsPanel schema={panel(columns) as never} />
</SchemaRendererProvider>
);
return render(
language ? (
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>{tree}</I18nProvider>
) : (
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');
});
});
22 changes: 19 additions & 3 deletions packages/plugin-form/src/LineItemsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, any> } | null>(null);
useEffect(() => {
const ds: any = dataSource;
Expand Down Expand Up @@ -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
Expand All @@ -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<GridFieldMetadata>) 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
Expand Down
Loading