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