@object-ui/fields 6.2.3 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/dist/complex-widgets.test.d.ts +1 -0
  3. package/dist/currency.d.ts +13 -0
  4. package/dist/datetime-widgets.test.d.ts +1 -0
  5. package/dist/field-type-coverage.test.d.ts +1 -0
  6. package/dist/index.cjs +4 -3
  7. package/dist/index.d.ts +300 -2
  8. package/dist/index.js +2460 -1274
  9. package/dist/standard-widgets.test.d.ts +0 -0
  10. package/dist/{packages/fields/src/widgets → widgets}/AddressField.d.ts +2 -1
  11. package/dist/{packages/fields/src/widgets → widgets}/AutoNumberField.d.ts +2 -1
  12. package/dist/{packages/fields/src/widgets → widgets}/AvatarField.d.ts +2 -1
  13. package/dist/{packages/fields/src/widgets → widgets}/BooleanField.d.ts +2 -1
  14. package/dist/widgets/CheckboxesField.d.ts +8 -0
  15. package/dist/{packages/fields/src/widgets → widgets}/CodeField.d.ts +2 -1
  16. package/dist/{packages/fields/src/widgets → widgets}/ColorField.d.ts +2 -1
  17. package/dist/{packages/fields/src/widgets → widgets}/CurrencyField.d.ts +2 -1
  18. package/dist/{packages/fields/src/widgets → widgets}/DateField.d.ts +2 -1
  19. package/dist/{packages/fields/src/widgets → widgets}/DateTimeField.d.ts +2 -1
  20. package/dist/{packages/fields/src/widgets → widgets}/EmailField.d.ts +2 -1
  21. package/dist/{packages/fields/src/widgets → widgets}/FileField.d.ts +2 -1
  22. package/dist/{packages/fields/src/widgets → widgets}/FormulaField.d.ts +2 -1
  23. package/dist/{packages/fields/src/widgets → widgets}/GeolocationField.d.ts +2 -1
  24. package/dist/widgets/GridField.d.ts +123 -0
  25. package/dist/widgets/GridField.test.d.ts +1 -0
  26. package/dist/{packages/fields/src/widgets → widgets}/ImageField.d.ts +2 -1
  27. package/dist/{packages/fields/src/widgets → widgets}/LocationField.d.ts +2 -1
  28. package/dist/{packages/fields/src/widgets → widgets}/LookupField.d.ts +2 -1
  29. package/dist/widgets/MarkdownContent.d.ts +9 -0
  30. package/dist/{packages/fields/src/widgets → widgets}/MasterDetailField.d.ts +2 -1
  31. package/dist/widgets/MultiSelectField.d.ts +9 -0
  32. package/dist/{packages/fields/src/widgets → widgets}/NumberField.d.ts +2 -1
  33. package/dist/{packages/fields/src/widgets → widgets}/ObjectField.d.ts +2 -1
  34. package/dist/{packages/fields/src/widgets → widgets}/PasswordField.d.ts +2 -1
  35. package/dist/{packages/fields/src/widgets → widgets}/PercentField.d.ts +2 -1
  36. package/dist/{packages/fields/src/widgets → widgets}/PhoneField.d.ts +2 -1
  37. package/dist/{packages/fields/src/widgets → widgets}/QRCodeField.d.ts +2 -1
  38. package/dist/widgets/RadioField.d.ts +8 -0
  39. package/dist/{packages/fields/src/widgets → widgets}/RatingField.d.ts +2 -1
  40. package/dist/{packages/fields/src/widgets → widgets}/RecordPickerDialog.d.ts +8 -2
  41. package/dist/{packages/fields/src/widgets → widgets}/RichTextField.d.ts +2 -1
  42. package/dist/{packages/fields/src/widgets → widgets}/SelectField.d.ts +2 -1
  43. package/dist/{packages/fields/src/widgets → widgets}/SignatureField.d.ts +2 -1
  44. package/dist/{packages/fields/src/widgets → widgets}/SliderField.d.ts +2 -1
  45. package/dist/{packages/fields/src/widgets → widgets}/SummaryField.d.ts +2 -1
  46. package/dist/widgets/TagsField.d.ts +8 -0
  47. package/dist/{packages/fields/src/widgets → widgets}/TextAreaField.d.ts +2 -1
  48. package/dist/{packages/fields/src/widgets → widgets}/TextField.d.ts +2 -1
  49. package/dist/{packages/fields/src/widgets → widgets}/TimeField.d.ts +2 -1
  50. package/dist/{packages/fields/src/widgets → widgets}/UrlField.d.ts +2 -1
  51. package/dist/{packages/fields/src/widgets → widgets}/UserField.d.ts +2 -1
  52. package/dist/{packages/fields/src/widgets → widgets}/VectorField.d.ts +2 -1
  53. package/dist/widgets/deriveLookupColumns.d.ts +36 -0
  54. package/dist/widgets/deriveLookupColumns.test.d.ts +1 -0
  55. package/dist/widgets/recentLookups.d.ts +13 -0
  56. package/dist/widgets/recentLookups.test.d.ts +1 -0
  57. package/dist/{packages/fields/src/widgets → widgets}/types.d.ts +1 -1
  58. package/package.json +14 -11
  59. package/src/__tests__/registry-collision.test.ts +91 -0
  60. package/src/__tests__/resolveFieldCurrency.test.ts +20 -0
  61. package/src/coerce-safe-value.test.ts +34 -0
  62. package/src/complex-widgets.test.tsx +58 -7
  63. package/src/currency.ts +14 -0
  64. package/src/field-type-coverage.test.ts +65 -0
  65. package/src/index.tsx +372 -57
  66. package/src/widgets/CheckboxesField.tsx +53 -0
  67. package/src/widgets/CurrencyField.tsx +5 -1
  68. package/src/widgets/GridField.test.tsx +309 -0
  69. package/src/widgets/GridField.tsx +907 -33
  70. package/src/widgets/LookupField.tsx +258 -107
  71. package/src/widgets/MarkdownContent.tsx +19 -0
  72. package/src/widgets/MultiSelectField.tsx +59 -0
  73. package/src/widgets/PercentField.tsx +19 -7
  74. package/src/widgets/RadioField.tsx +41 -0
  75. package/src/widgets/RecordPickerDialog.tsx +22 -18
  76. package/src/widgets/SelectField.tsx +23 -2
  77. package/src/widgets/TagsField.tsx +67 -0
  78. package/src/widgets/deriveLookupColumns.test.ts +74 -0
  79. package/src/widgets/deriveLookupColumns.ts +104 -0
  80. package/src/widgets/recentLookups.test.ts +45 -0
  81. package/src/widgets/recentLookups.ts +47 -0
  82. package/src/widgets/useFieldTranslation.ts +30 -0
  83. package/dist/packages/fields/src/fields-edge-cases.stories.d.ts +0 -45
  84. package/dist/packages/fields/src/fields.stories.d.ts +0 -56
  85. package/dist/packages/fields/src/index.d.ts +0 -286
  86. package/dist/packages/fields/src/record-picker-dialog.stories.d.ts +0 -25
  87. package/dist/packages/fields/src/widgets/GridField.d.ts +0 -7
  88. package/src/fields-edge-cases.stories.tsx +0 -407
  89. package/src/fields.stories.tsx +0 -566
  90. package/src/record-picker-dialog.stories.tsx +0 -160
  91. /package/dist/{packages/fields/src/__tests__/validation-feedback.test.d.ts → __tests__/registry-collision.test.d.ts} +0 -0
  92. /package/dist/{packages/fields/src/complex-widgets.test.d.ts → __tests__/resolveFieldCurrency.test.d.ts} +0 -0
  93. /package/dist/{packages/fields/src/standard-widgets.test.d.ts → __tests__/validation-feedback.test.d.ts} +0 -0
  94. /package/dist/{packages/fields/src/datetime-widgets.test.d.ts → coerce-safe-value.test.d.ts} +0 -0
  95. /package/dist/{packages/fields/src/widgets → widgets}/ImageCropperDialog.d.ts +0 -0
  96. /package/dist/{packages/fields/src/widgets → widgets}/_cell-renderer-bridge.d.ts +0 -0
  97. /package/dist/{packages/fields/src/widgets → widgets}/useFieldTranslation.d.ts +0 -0
@@ -0,0 +1,53 @@
1
+ import React, { useId } from 'react';
2
+ import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
3
+ import { FieldWidgetProps } from './types';
4
+
5
+ interface Option { label: string; value: string }
6
+
7
+ /**
8
+ * CheckboxesField - select zero or more values from a fixed option set, rendered
9
+ * as a list of checkboxes. The stored value is a string[]. Used for the
10
+ * `checkboxes` field type.
11
+ */
12
+ export function CheckboxesField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string[]>) {
13
+ const config = (field || (props as any).schema) as any;
14
+ const options: Option[] = config?.options || [];
15
+ const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
16
+ const groupId = useId();
17
+
18
+ if (readonly) {
19
+ if (selected.length === 0) return <EmptyValue />;
20
+ return (
21
+ <div className="flex flex-wrap gap-1">
22
+ {selected.map((v) => {
23
+ const opt = options.find((o) => o.value === v);
24
+ return <Badge key={v} variant="outline">{opt?.label || v}</Badge>;
25
+ })}
26
+ </div>
27
+ );
28
+ }
29
+
30
+ const toggle = (v: string, checked: boolean) => {
31
+ const next = checked ? [...selected.filter((x) => x !== v), v] : selected.filter((x) => x !== v);
32
+ onChange(next);
33
+ };
34
+
35
+ return (
36
+ <div className={className}>
37
+ {options.map((opt) => {
38
+ const id = `${groupId}-${opt.value}`;
39
+ return (
40
+ <div key={opt.value} className="flex items-center space-x-2 py-0.5">
41
+ <Checkbox
42
+ id={id}
43
+ checked={selected.includes(opt.value)}
44
+ onCheckedChange={(checked) => toggle(opt.value, !!checked)}
45
+ disabled={(props as any).disabled}
46
+ />
47
+ <Label htmlFor={id} className="font-normal">{opt.label}</Label>
48
+ </div>
49
+ );
50
+ })}
51
+ </div>
52
+ );
53
+ }
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetProps } from './types';
4
+ import { useLocalization } from '@object-ui/i18n';
5
+ import { resolveFieldCurrency } from '../currency';
4
6
 
5
7
  /**
6
8
  * Format currency value for display. When `currency` is undefined the value
@@ -36,7 +38,9 @@ const CURRENCY_SYMBOLS: Record<string, string> = {
36
38
 
37
39
  export function CurrencyField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetProps<number>) {
38
40
  const currencyField = (field || (props as any).schema) as any;
39
- const currency: string | undefined = currencyField?.currency;
41
+ // Shared precedence: field currency → currencyConfig → tenant default (ADR-0053).
42
+ const { currency: tenantCurrency } = useLocalization();
43
+ const currency: string | undefined = resolveFieldCurrency(currencyField, tenantCurrency);
40
44
  const precision = currencyField?.precision ?? 2;
41
45
 
42
46
  if (readonly) {
@@ -0,0 +1,309 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import React from 'react';
4
+ import { GridField, LineItemsField, sumColumn, lookupAutofillPatch } from './GridField';
5
+
6
+ const columns = [
7
+ { field: 'description', label: 'Description', type: 'text' as const },
8
+ { field: 'amount', label: 'Amount', type: 'currency' as const },
9
+ ];
10
+
11
+ const field = { columns, total_field: 'amount' } as any;
12
+
13
+ describe('GridField / LineItemsField — editable line items', () => {
14
+ it('is exported under both names', () => {
15
+ expect(LineItemsField).toBe(GridField);
16
+ });
17
+
18
+ it('renders a column header per config and an empty hint', () => {
19
+ render(<GridField value={[]} onChange={() => {}} field={field} />);
20
+ expect(screen.getByText('Description')).toBeTruthy();
21
+ expect(screen.getByText('Amount')).toBeTruthy();
22
+ expect(screen.getByRole('button', { name: /Add line/i })).toBeTruthy();
23
+ });
24
+
25
+ it('Add line appends a blank row keyed by columns', () => {
26
+ const onChange = vi.fn();
27
+ render(<GridField value={[]} onChange={onChange} field={field} />);
28
+ fireEvent.click(screen.getByRole('button', { name: /Add line/i }));
29
+ expect(onChange).toHaveBeenCalledWith([{ description: null, amount: null }]);
30
+ });
31
+
32
+ describe('column chooser (defaultHidden columns)', () => {
33
+ const withHidden = {
34
+ columns: [
35
+ { field: 'title', label: 'Title', type: 'text' as const, required: true },
36
+ { field: 'amount', label: 'Amount', type: 'currency' as const },
37
+ { field: 'notes', label: 'Notes', type: 'text' as const, defaultHidden: true },
38
+ ],
39
+ } as any;
40
+
41
+ it('hides defaultHidden columns by default but keeps required/visible ones', () => {
42
+ render(<GridField value={[]} onChange={() => {}} field={withHidden} />);
43
+ expect(screen.getByText('Title')).toBeTruthy();
44
+ expect(screen.getByText('Amount')).toBeTruthy();
45
+ expect(screen.queryByText('Notes')).toBeNull(); // collapsed into the chooser
46
+ expect(screen.getByTestId('line-items-columns')).toBeTruthy();
47
+ });
48
+
49
+ it('reveals an optional column when toggled in the chooser', () => {
50
+ render(<GridField value={[]} onChange={() => {}} field={withHidden} />);
51
+ fireEvent.click(screen.getByTestId('line-items-columns'));
52
+ fireEvent.click(screen.getByLabelText('Notes'));
53
+ expect(screen.getAllByText('Notes').length).toBeGreaterThan(0);
54
+ });
55
+
56
+ it('shows no chooser when there are no optional columns', () => {
57
+ render(<GridField value={[]} onChange={() => {}} field={field} />);
58
+ expect(screen.queryByTestId('line-items-columns')).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe('list mode (displayMode="list" — form-factor for fat children)', () => {
63
+ const listField = {
64
+ columns: [
65
+ { field: 'title', label: 'Title', type: 'text' as const, required: true },
66
+ { field: 'status', label: 'Status', type: 'select' as const, options: [{ label: 'To Do', value: 'todo' }] },
67
+ ],
68
+ } as any;
69
+
70
+ it('renders rows read-only (no cell inputs) and an Add button', () => {
71
+ const onAdd = vi.fn();
72
+ render(
73
+ <GridField
74
+ value={[{ title: 'Ship it', status: 'todo' }]}
75
+ onChange={() => {}}
76
+ field={listField}
77
+ displayMode="list"
78
+ onRowExpand={() => {}}
79
+ onAdd={onAdd}
80
+ />,
81
+ );
82
+ // Read-only display: the status renders its option label, not a combobox.
83
+ expect(screen.getByText('To Do')).toBeTruthy();
84
+ expect(screen.queryByLabelText('Title')).toBeNull(); // no editable input
85
+ expect(screen.getByRole('button', { name: /Open row/i })).toBeTruthy(); // per-row edit
86
+ });
87
+
88
+ it('Add calls onAdd (host opens the full form) instead of inserting a blank row', () => {
89
+ const onAdd = vi.fn();
90
+ const onChange = vi.fn();
91
+ render(
92
+ <GridField value={[]} onChange={onChange} field={listField} displayMode="list" onRowExpand={() => {}} onAdd={onAdd} />,
93
+ );
94
+ fireEvent.click(screen.getByTestId('line-items-add'));
95
+ expect(onAdd).toHaveBeenCalledTimes(1);
96
+ expect(onChange).not.toHaveBeenCalled(); // did NOT insert a blank inline row
97
+ });
98
+ });
99
+
100
+ it('editing a text cell emits the raw string', () => {
101
+ const onChange = vi.fn();
102
+ render(<GridField value={[{ description: '', amount: null }]} onChange={onChange} field={field} />);
103
+ // [0] = the data row ([1] would be the always-present trailing ghost row).
104
+ fireEvent.change(screen.getAllByLabelText('Description')[0], { target: { value: 'Taxi' } });
105
+ expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', amount: null }]);
106
+ });
107
+
108
+ it('editing a currency cell coerces to a number', () => {
109
+ const onChange = vi.fn();
110
+ render(<GridField value={[{ description: 'Taxi', amount: null }]} onChange={onChange} field={field} />);
111
+ fireEvent.change(screen.getAllByLabelText('Amount')[0], { target: { value: '42.5' } });
112
+ expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', amount: 42.5 }]);
113
+ });
114
+
115
+ describe('trailing ghost row (start-with-one + auto-append)', () => {
116
+ it('renders a trailing empty row so an empty grid still has one input line', () => {
117
+ render(<GridField value={[]} onChange={() => {}} field={field} />);
118
+ // No "No items" empty-state in grid mode — the ghost row IS the first line.
119
+ expect(screen.getByText('Description')).toBeTruthy();
120
+ expect(screen.getAllByLabelText('Description')).toHaveLength(1); // just the ghost
121
+ });
122
+
123
+ it('typing in the ghost row materialises a new row (no Add click needed)', () => {
124
+ const onChange = vi.fn();
125
+ render(<GridField value={[{ description: 'A', amount: 1 }]} onChange={onChange} field={field} />);
126
+ const inputs = screen.getAllByLabelText('Description');
127
+ expect(inputs).toHaveLength(2); // data row + ghost
128
+ fireEvent.change(inputs[1], { target: { value: 'B' } }); // type in the ghost
129
+ expect(onChange).toHaveBeenCalledWith([
130
+ { description: 'A', amount: 1 },
131
+ { description: 'B', amount: null },
132
+ ]);
133
+ });
134
+ });
135
+
136
+ describe('computed columns (amount = qty × unit_price)', () => {
137
+ const computedField = {
138
+ columns: [
139
+ { field: 'product', label: 'Product', type: 'text' as const },
140
+ { field: 'quantity', label: 'Qty', type: 'number' as const },
141
+ { field: 'unit_price', label: 'Unit Price', type: 'currency' as const },
142
+ { field: 'amount', label: 'Amount', type: 'currency' as const, computed: true, expr: 'record.quantity * record.unit_price', scale: 2 },
143
+ ],
144
+ total_field: 'amount',
145
+ } as any;
146
+
147
+ it('renders a computed column read-only (no input) and recomputes on edit', () => {
148
+ const onChange = vi.fn();
149
+ render(<GridField value={[{ product: 'Widget', quantity: 3, unit_price: 10, amount: 30 }]} onChange={onChange} field={computedField} />);
150
+ // Amount is display-only — there is no editable Amount cell.
151
+ expect(screen.queryByLabelText('Amount')).toBeNull();
152
+ // Editing quantity recomputes amount in the emitted row.
153
+ fireEvent.change(screen.getAllByLabelText('Qty')[0], { target: { value: '4' } });
154
+ expect(onChange).toHaveBeenCalledWith([{ product: 'Widget', quantity: 4, unit_price: 10, amount: 40 }]);
155
+ });
156
+
157
+ it('shows a dash for a computed cell whose inputs are blank', () => {
158
+ render(<GridField value={[{ product: 'Widget', quantity: null, unit_price: null, amount: null }]} onChange={() => {}} field={computedField} />);
159
+ // The computed amount cell reads "—" until its inputs exist.
160
+ expect(screen.getAllByText('—').length).toBeGreaterThan(0);
161
+ });
162
+ });
163
+
164
+ describe('keyboard navigation', () => {
165
+ it('Enter moves focus to the same column in the next row', () => {
166
+ render(<GridField value={[{ description: 'A', amount: 1 }, { description: 'B', amount: 2 }]} onChange={() => {}} field={field} />);
167
+ const row0 = screen.getAllByLabelText('Description')[0];
168
+ row0.focus();
169
+ fireEvent.keyDown(row0, { key: 'Enter' });
170
+ expect(document.activeElement).toBe(screen.getAllByLabelText('Description')[1]);
171
+ });
172
+ });
173
+
174
+ it('removing a row emits the array without it', () => {
175
+ const onChange = vi.fn();
176
+ render(
177
+ <GridField
178
+ value={[{ description: 'A', amount: 1 }, { description: 'B', amount: 2 }]}
179
+ onChange={onChange}
180
+ field={field}
181
+ />,
182
+ );
183
+ const removeButtons = screen.getAllByLabelText('Remove row');
184
+ fireEvent.click(removeButtons[0]);
185
+ expect(onChange).toHaveBeenCalledWith([{ description: 'B', amount: 2 }]);
186
+ });
187
+
188
+ it('readonly mode shows values and a summed total footer', () => {
189
+ render(
190
+ <GridField
191
+ value={[{ description: 'A', amount: 10 }, { description: 'B', amount: 20 }]}
192
+ onChange={() => {}}
193
+ field={field}
194
+ readonly
195
+ />,
196
+ );
197
+ expect(screen.getByTestId('line-items-readonly')).toBeTruthy();
198
+ expect(screen.getByText('Total')).toBeTruthy();
199
+ expect(screen.getByText('30')).toBeTruthy();
200
+ });
201
+
202
+ describe('lookupAutofillPatch (item typeahead auto-fill)', () => {
203
+ const cols = [
204
+ { field: 'product', type: 'lookup' as const, reference: 'product' },
205
+ { field: 'description', type: 'text' as const },
206
+ { field: 'quantity', type: 'number' as const },
207
+ { field: 'unit_price', type: 'currency' as const },
208
+ { field: 'amount', type: 'currency' as const, computed: true, expr: 'record.quantity * record.unit_price' },
209
+ ];
210
+ const product = { value: 'p1', label: 'Widget A', name: 'Widget A', description: 'Standard widget', unit_price: 29.99, sku: 'WIDGET-A' };
211
+
212
+ it('sets the FK id and copies same-named sibling fields from the record', () => {
213
+ const patch = lookupAutofillPatch(cols, cols[0], product);
214
+ expect(patch).toEqual({ product: 'p1', description: 'Standard widget', unit_price: 29.99 });
215
+ // quantity (not on the record) and computed amount are left to the row/compute.
216
+ expect(patch).not.toHaveProperty('quantity');
217
+ expect(patch).not.toHaveProperty('amount');
218
+ });
219
+
220
+ it('copies only the FK id when autofill is disabled', () => {
221
+ const patch = lookupAutofillPatch(cols, { ...cols[0], autofill: false }, product);
222
+ expect(patch).toEqual({ product: 'p1' });
223
+ });
224
+ });
225
+
226
+ describe('P1 affordances (duplicate / validation)', () => {
227
+ it('duplicates a row (id stripped) directly below the original', () => {
228
+ const onChange = vi.fn();
229
+ render(<GridField value={[{ id: 'r1', description: 'A', amount: 5 }]} onChange={onChange} field={field} />);
230
+ fireEvent.click(screen.getByTestId('line-items-duplicate-0'));
231
+ expect(onChange).toHaveBeenCalledWith([
232
+ { id: 'r1', description: 'A', amount: 5 },
233
+ { description: 'A', amount: 5 }, // copy without the id → persists as a new record
234
+ ]);
235
+ });
236
+
237
+ it('flags a required, empty cell on a real row (not the ghost row)', () => {
238
+ const reqField = { columns: [{ field: 'description', label: 'Description', type: 'text' as const, required: true }] } as any;
239
+ render(<GridField value={[{ description: '' }]} onChange={() => {}} field={reqField} />);
240
+ // The data row's required-empty cell is flagged...
241
+ expect(screen.getByTestId('line-items-invalid-0-description')).toBeTruthy();
242
+ // ...but the trailing ghost row (index 1) is not.
243
+ expect(screen.queryByTestId('line-items-invalid-1-description')).toBeNull();
244
+ });
245
+ });
246
+
247
+ it('sumColumn ignores blanks and NaN', () => {
248
+ expect(sumColumn([{ amount: 1 }, { amount: 2 }, { amount: null }], 'amount')).toBe(3);
249
+ });
250
+
251
+ describe('parent-scoped conditional rules (B2 follow-up — "paid invoice → lock lines")', () => {
252
+ const lockField = {
253
+ columns: [
254
+ { field: 'product', label: 'Product', type: 'text' as const },
255
+ { field: 'qty', label: 'Qty', type: 'number' as const, readonlyWhen: "parent.status == 'paid'" },
256
+ { field: 'unit_price', label: 'Unit Price', type: 'currency' as const, readonlyWhen: "parent.status == 'paid'" },
257
+ ],
258
+ } as any;
259
+
260
+ it('leaves cells editable when the parent rule is FALSE', () => {
261
+ render(
262
+ <GridField
263
+ value={[{ product: 'Widget', qty: 2, unit_price: 10 }]}
264
+ onChange={() => {}}
265
+ field={lockField}
266
+ contextRecord={{ status: 'draft' }}
267
+ />,
268
+ );
269
+ expect((screen.getAllByLabelText('Qty')[0] as HTMLInputElement).disabled).toBe(false);
270
+ expect((screen.getAllByLabelText('Unit Price')[0] as HTMLInputElement).disabled).toBe(false);
271
+ });
272
+
273
+ it('locks cells whose readonlyWhen references the parent header', () => {
274
+ render(
275
+ <GridField
276
+ value={[{ product: 'Widget', qty: 2, unit_price: 10 }]}
277
+ onChange={() => {}}
278
+ field={lockField}
279
+ contextRecord={{ status: 'paid' }}
280
+ />,
281
+ );
282
+ // The header is paid → quantity / unit price lock; product (no rule) stays editable.
283
+ expect((screen.getAllByLabelText('Qty')[0] as HTMLInputElement).disabled).toBe(true);
284
+ expect((screen.getAllByLabelText('Unit Price')[0] as HTMLInputElement).disabled).toBe(true);
285
+ expect((screen.getAllByLabelText('Product')[0] as HTMLInputElement).disabled).toBe(false);
286
+ });
287
+
288
+ it('re-evaluates per row, mixing the parent header with row data', () => {
289
+ const rowRule = {
290
+ columns: [
291
+ { field: 'qty', label: 'Qty', type: 'number' as const },
292
+ // Locks only when the header is paid AND this row is already invoiced.
293
+ { field: 'note', label: 'Note', type: 'text' as const, readonlyWhen: "parent.status == 'paid' && record.invoiced == true" },
294
+ ],
295
+ } as any;
296
+ render(
297
+ <GridField
298
+ value={[{ qty: 1, note: 'a', invoiced: true }, { qty: 2, note: 'b', invoiced: false }]}
299
+ onChange={() => {}}
300
+ field={rowRule}
301
+ contextRecord={{ status: 'paid' }}
302
+ />,
303
+ );
304
+ const notes = screen.getAllByLabelText('Note') as HTMLInputElement[];
305
+ expect(notes[0].disabled).toBe(true); // invoiced row → locked
306
+ expect(notes[1].disabled).toBe(false); // not-yet-invoiced row → editable
307
+ });
308
+ });
309
+ });