@object-ui/fields 11.2.0 → 11.4.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 (48) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/dist/FieldEditWidget.d.ts +21 -0
  3. package/dist/FieldEditWidget.test.d.ts +8 -0
  4. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  5. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.d.ts +8 -0
  8. package/dist/index.js +2155 -1438
  9. package/dist/widgets/LookupField.dependsOn.test.d.ts +8 -0
  10. package/dist/widgets/PeoplePicker.d.ts +63 -0
  11. package/dist/widgets/PeoplePicker.test.d.ts +8 -0
  12. package/dist/widgets/PeoplePickerComponents.test.d.ts +8 -0
  13. package/dist/widgets/PersonRow.d.ts +23 -0
  14. package/dist/widgets/RecordPickerDialog.d.ts +9 -1
  15. package/dist/widgets/SelectionTray.d.ts +27 -0
  16. package/dist/widgets/UserField.d.ts +0 -13
  17. package/dist/widgets/UserFieldPicker.test.d.ts +8 -0
  18. package/dist/widgets/deriveLookupColumns.d.ts +12 -4
  19. package/dist/widgets/personDisplay.d.ts +37 -0
  20. package/dist/widgets/personDisplay.test.d.ts +8 -0
  21. package/dist/widgets/useRecordQuery.d.ts +87 -0
  22. package/dist/widgets/useRecordQuery.test.d.ts +8 -0
  23. package/package.json +11 -11
  24. package/src/FieldEditWidget.test.ts +61 -0
  25. package/src/FieldEditWidget.tsx +146 -0
  26. package/src/__tests__/NumberCellRenderer.test.tsx +60 -0
  27. package/src/__tests__/lookupCellReferenceKey.test.tsx +57 -0
  28. package/src/complex-widgets.test.tsx +241 -0
  29. package/src/index.tsx +33 -4
  30. package/src/widgets/GridField.test.tsx +14 -0
  31. package/src/widgets/GridField.tsx +7 -8
  32. package/src/widgets/LookupField.dependsOn.test.tsx +233 -0
  33. package/src/widgets/LookupField.tsx +397 -199
  34. package/src/widgets/NumberField.tsx +4 -2
  35. package/src/widgets/PeoplePicker.test.tsx +334 -0
  36. package/src/widgets/PeoplePicker.tsx +552 -0
  37. package/src/widgets/PeoplePickerComponents.test.tsx +104 -0
  38. package/src/widgets/PersonRow.tsx +117 -0
  39. package/src/widgets/RecordPickerDialog.tsx +58 -129
  40. package/src/widgets/SelectionTray.tsx +110 -0
  41. package/src/widgets/UserField.tsx +20 -1
  42. package/src/widgets/UserFieldPicker.test.tsx +78 -0
  43. package/src/widgets/deriveLookupColumns.test.ts +25 -0
  44. package/src/widgets/deriveLookupColumns.ts +24 -10
  45. package/src/widgets/personDisplay.test.ts +113 -0
  46. package/src/widgets/personDisplay.ts +92 -0
  47. package/src/widgets/useRecordQuery.test.ts +174 -0
  48. package/src/widgets/useRecordQuery.ts +264 -0
@@ -0,0 +1,60 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import React from 'react';
11
+ import { render, screen } from '@testing-library/react';
12
+ import '@testing-library/jest-dom';
13
+ import { NumberCellRenderer } from '../index';
14
+
15
+ const renderNumber = (value: unknown, field: Record<string, unknown> = {}) =>
16
+ render(<NumberCellRenderer value={value as any} field={{ type: 'number', ...field } as any} />);
17
+
18
+ describe('NumberCellRenderer', () => {
19
+ it('does not pad trailing zeros using `precision` (total digits, not decimal places)', () => {
20
+ // Regression: a decimal(10, 0) column exposes precision: 10, which used to
21
+ // render `1` as "1.0000000000".
22
+ renderNumber(1, { precision: 10 });
23
+ expect(screen.getByText('1')).toBeInTheDocument();
24
+ });
25
+
26
+ it('renders an integer without decimals when no scale is declared', () => {
27
+ renderNumber(16);
28
+ expect(screen.getByText('16')).toBeInTheDocument();
29
+ });
30
+
31
+ it('preserves the value\'s natural decimals when no scale is declared', () => {
32
+ renderNumber(16.5);
33
+ expect(screen.getByText('16.5')).toBeInTheDocument();
34
+ });
35
+
36
+ it('pads to a fixed number of decimals when `scale` is declared', () => {
37
+ renderNumber(16, { scale: 2 });
38
+ expect(screen.getByText('16.00')).toBeInTheDocument();
39
+ });
40
+
41
+ it('honours a scale of 3 with zero padding', () => {
42
+ renderNumber(3.14, { scale: 3 });
43
+ expect(screen.getByText('3.140')).toBeInTheDocument();
44
+ });
45
+
46
+ it('rounds to the declared scale', () => {
47
+ renderNumber(3.14159, { scale: 2 });
48
+ expect(screen.getByText('3.14')).toBeInTheDocument();
49
+ });
50
+
51
+ it('treats scale: 0 as an integer display', () => {
52
+ renderNumber(2, { scale: 0 });
53
+ expect(screen.getByText('2')).toBeInTheDocument();
54
+ });
55
+
56
+ it('renders an empty indicator for nullish values', () => {
57
+ const { container } = renderNumber(null);
58
+ expect(container.textContent).not.toContain('0');
59
+ });
60
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Regression: LookupCellRenderer must resolve a bare foreign-key id to a
3
+ * display name when the field metadata carries the ObjectStack `reference`
4
+ * key (as produced by `Field.lookup('...')` in @objectstack/spec) — not only
5
+ * the objectui `reference_to` alias.
6
+ *
7
+ * Real-world symptom (framework app-showcase): inline-edit a lookup cell, pick
8
+ * a record, click another row → the cell showed a muted "—" forever because
9
+ * the just-picked opaque id could not be resolved. Every other reader in the
10
+ * codebase (LookupField, UserField, DetailSection, RelatedList, …) already
11
+ * accepts `reference_to || reference`; this read cell used to read only
12
+ * `reference_to`.
13
+ */
14
+ import { describe, it, expect, vi } from 'vitest';
15
+ import { render, screen, waitFor } from '@testing-library/react';
16
+ import '@testing-library/jest-dom';
17
+ import React from 'react';
18
+
19
+ import { LookupCellRenderer } from '../index';
20
+ import { SchemaRendererProvider } from '@object-ui/react';
21
+
22
+ // A ULID-style id: `isLikelyOpaqueId` returns true for it, so the OLD code
23
+ // (which failed to resolve) would fall back to the muted "—" placeholder.
24
+ const OPAQUE_ID = '01ARZ3NDEKTSV4RRFFQ69G5FAV';
25
+
26
+ function makeDataSource() {
27
+ const findOne = vi.fn(async (object: string, id: string) => {
28
+ if (object === 'showcase_account' && id === OPAQUE_ID) {
29
+ return { id, name: 'Globex' };
30
+ }
31
+ return null;
32
+ });
33
+ return { findOne, find: vi.fn() } as any;
34
+ }
35
+
36
+ describe('LookupCellRenderer — reference key resolution', () => {
37
+ it('resolves an opaque id to a name via the `reference` key (not just reference_to)', async () => {
38
+ const ds = makeDataSource();
39
+ render(
40
+ <SchemaRendererProvider dataSource={ds}>
41
+ <LookupCellRenderer
42
+ value={OPAQUE_ID}
43
+ // ObjectStack convention: the lookup target lives under `reference`.
44
+ field={{ type: 'lookup', reference: 'showcase_account' } as any}
45
+ />
46
+ </SchemaRendererProvider>,
47
+ );
48
+
49
+ // The related record's display name must appear…
50
+ await waitFor(() => {
51
+ expect(screen.getByText('Globex')).toBeInTheDocument();
52
+ });
53
+ // …and the muted placeholder must NOT be what the user sees.
54
+ expect(screen.queryByText('—')).not.toBeInTheDocument();
55
+ expect(ds.findOne).toHaveBeenCalledWith('showcase_account', OPAQUE_ID);
56
+ });
57
+ });
@@ -1,6 +1,8 @@
1
1
  import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
2
2
  import { describe, it, expect, vi, beforeEach } from 'vitest';
3
3
  import { LookupField } from './widgets/LookupField';
4
+ import { ActionProvider } from '@object-ui/react';
5
+ import { FieldEditWidget } from './FieldEditWidget';
4
6
  import { MasterDetailField } from './widgets/MasterDetailField';
5
7
  import { GridField } from './widgets/GridField';
6
8
  import { FileField } from './widgets/FileField';
@@ -343,6 +345,85 @@ describe('Complex & Relationship Widgets', () => {
343
345
  });
344
346
  });
345
347
 
348
+ it('defaults inline-create ON for a user-facing relation (no allow_create needed)', async () => {
349
+ // dynamicField.reference_to === 'customers' (user-facing) with NO
350
+ // allow_create flag → the capability is ON by default (standard).
351
+ mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
352
+ mockDataSource.create.mockResolvedValue({ id: 'new-2', name: 'Zeta' });
353
+ const onChange = vi.fn();
354
+
355
+ render(<LookupField {...dynamicProps} onChange={onChange} />);
356
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Select/i })); });
357
+ const input = await screen.findByRole('combobox');
358
+ await act(async () => { fireEvent.change(input, { target: { value: 'Zeta' } }); });
359
+
360
+ expect(await screen.findByText('Create new "Zeta"')).toBeInTheDocument();
361
+ // No ActionProvider in scope → the create falls back to a direct
362
+ // dataSource.create (the modal-open path is exercised in the E2E).
363
+ await act(async () => { fireEvent.click(screen.getByText('Create new "Zeta"')); });
364
+ await waitFor(() => { expect(mockDataSource.create).toHaveBeenCalledWith('customers', { name: 'Zeta' }); });
365
+ });
366
+
367
+ it('does NOT default inline-create for a system / user-directory reference', async () => {
368
+ // A picker into the platform user directory must not offer "+ create"
369
+ // (those rows are org members, not inline-created from a field).
370
+ mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
371
+ const field = { ...dynamicField, reference_to: 'sys_user' } as any;
372
+
373
+ render(<LookupField {...dynamicProps} field={field} />);
374
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Select/i })); });
375
+ const input = await screen.findByRole('combobox');
376
+ await act(async () => { fireEvent.change(input, { target: { value: 'Somebody' } }); });
377
+
378
+ await waitFor(() => { expect(screen.getByText('No options found')).toBeInTheDocument(); });
379
+ expect(screen.queryByText(/Create new/)).not.toBeInTheDocument();
380
+ });
381
+
382
+ it('honors an explicit allow_create:false opt-out on a user-facing relation', async () => {
383
+ mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
384
+ const field = { ...dynamicField, allow_create: false } as any;
385
+
386
+ render(<LookupField {...dynamicProps} field={field} />);
387
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Select/i })); });
388
+ const input = await screen.findByRole('combobox');
389
+ await act(async () => { fireEvent.change(input, { target: { value: 'Nope' } }); });
390
+
391
+ await waitFor(() => { expect(screen.getByText('No options found')).toBeInTheDocument(); });
392
+ expect(screen.queryByText(/Create new/)).not.toBeInTheDocument();
393
+ });
394
+
395
+ it('under an ActionProvider, "+ create" opens the referenced object\'s create form (not a one-field create) and selects the result', async () => {
396
+ // The standard behaviour (founder ask): rather than blindly creating a
397
+ // record from the typed text, open the referenced object's OWN create
398
+ // form so the user fills every required field, then select what they
399
+ // made. Routed through the ActionProvider modal handler — the console
400
+ // wires one globally (ConsoleShell.GlobalCreateModalProvider).
401
+ mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
402
+ mockDataSource.create.mockClear();
403
+ const onChange = vi.fn();
404
+ // Stand-in for the console's global create-modal host: resolves with
405
+ // the record the user created in the opened form.
406
+ const onModal = vi.fn(async (_schema: any) => ({ success: true, reload: true, data: { id: 'bk-1', name: 'Deep Sea Mechanics' } }));
407
+
408
+ render(
409
+ <ActionProvider onModal={onModal}>
410
+ <LookupField {...dynamicProps} onChange={onChange} />
411
+ </ActionProvider>
412
+ );
413
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Select/i })); });
414
+ const input = await screen.findByRole('combobox');
415
+ await act(async () => { fireEvent.change(input, { target: { value: 'Deep Sea Mechanics' } }); });
416
+ await act(async () => { fireEvent.click(await screen.findByText('Create new "Deep Sea Mechanics"')); });
417
+
418
+ // It opened the referenced object's CREATE FORM (modal), addressed by
419
+ // object name + create mode — NOT a silent one-field dataSource.create.
420
+ await waitFor(() => { expect(onModal).toHaveBeenCalled(); });
421
+ expect(onModal.mock.calls[0][0]).toMatchObject({ objectName: 'customers', mode: 'create' });
422
+ expect(mockDataSource.create).not.toHaveBeenCalled();
423
+ // The record the user created in the form is selected back into the field.
424
+ await waitFor(() => { expect(onChange).toHaveBeenCalledWith('bk-1'); });
425
+ });
426
+
346
427
  it('shows a recently-used section on empty focus', async () => {
347
428
  localStorage.setItem('objectui:lookup:recent:customers', JSON.stringify(['r1']));
348
429
  mockDataSource.find.mockResolvedValue({ data: [{ id: 'a', name: 'Acme Corp' }], total: 1 });
@@ -532,6 +613,166 @@ describe('Complex & Relationship Widgets', () => {
532
613
  });
533
614
  });
534
615
 
616
+ describe('LookupField — expanded-reference ($expand) value resolution (#2125)', () => {
617
+ // The data grid requests `$expand` for visible reference columns, so a
618
+ // lookup cell's value arrives as the related record OBJECT ({ id, name }),
619
+ // not a bare id. The inline editor must resolve that to the record's name —
620
+ // like the read cell (LookupCellRenderer) — instead of the "Select…"
621
+ // placeholder. Regression test for #2125.
622
+ const mockDataSource = {
623
+ find: vi.fn(),
624
+ findOne: vi.fn(),
625
+ create: vi.fn(),
626
+ update: vi.fn(),
627
+ delete: vi.fn(),
628
+ };
629
+
630
+ beforeEach(() => {
631
+ vi.clearAllMocks();
632
+ try { localStorage.clear(); } catch { /* jsdom */ }
633
+ });
634
+
635
+ it('resolves an expanded-object value into the record name (compact trigger) without a bogus fetch', () => {
636
+ render(
637
+ <LookupField
638
+ value={{ id: 'Z63', name: 'Northwind' }}
639
+ onChange={vi.fn()}
640
+ field={{ name: 'account', type: 'lookup', reference: 'showcase_account' } as any}
641
+ readonly={false}
642
+ dataSource={mockDataSource}
643
+ compact
644
+ />
645
+ );
646
+
647
+ // The name resolves directly from the expanded object — shown in the
648
+ // compact trigger, never the placeholder.
649
+ expect(screen.getByText('Northwind')).toBeInTheDocument();
650
+
651
+ // Must never fetch by passing the whole object as an id.
652
+ expect(mockDataSource.findOne).not.toHaveBeenCalled();
653
+ expect(mockDataSource.find).not.toHaveBeenCalled();
654
+ });
655
+
656
+ it('resolves an expanded-object value in full (badge) mode', () => {
657
+ render(
658
+ <LookupField
659
+ value={{ id: 'Z63', name: 'Northwind' }}
660
+ onChange={vi.fn()}
661
+ field={{ name: 'account', type: 'lookup', reference: 'showcase_account' } as any}
662
+ readonly={false}
663
+ dataSource={mockDataSource}
664
+ />
665
+ );
666
+ expect(screen.getByText('Northwind')).toBeInTheDocument();
667
+ });
668
+
669
+ it('still hydrates a bare-id value via findOne (existing path preserved)', async () => {
670
+ mockDataSource.findOne.mockResolvedValue({ id: 'a1', name: 'Acme Corp' });
671
+ render(
672
+ <LookupField
673
+ value="a1"
674
+ onChange={vi.fn()}
675
+ field={{ name: 'account', type: 'lookup', reference: 'showcase_account' } as any}
676
+ readonly={false}
677
+ dataSource={mockDataSource}
678
+ compact
679
+ />
680
+ );
681
+ await waitFor(() => {
682
+ expect(mockDataSource.findOne).toHaveBeenCalledWith('showcase_account', 'a1');
683
+ });
684
+ await waitFor(() => {
685
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
686
+ });
687
+ });
688
+
689
+ it('FieldEditWidget renders a lookup cell compact and resolves the expanded object', () => {
690
+ // Proves FieldEditWidget forwards `compact` to the relational widget, so
691
+ // the grid cell shows the record name in the trigger (single line).
692
+ render(
693
+ <FieldEditWidget
694
+ field={{ name: 'account', type: 'lookup', reference: 'showcase_account' } as any}
695
+ value={{ id: 'Z63', name: 'Northwind' }}
696
+ onChange={vi.fn()}
697
+ />
698
+ );
699
+ expect(screen.getByText('Northwind')).toBeInTheDocument();
700
+ });
701
+ });
702
+
703
+ describe('LookupField — inline value-shape alignment with the read cell', () => {
704
+ // Rounds out #2125: user/owner inline editing, JSON-encoded external-id
705
+ // reference values, and String()-tolerant id matching — so the inline
706
+ // editor resolves every value shape the read cell (LookupCellRenderer)
707
+ // does, instead of falling back to the "Select…" placeholder.
708
+ const mockDataSource = {
709
+ find: vi.fn(),
710
+ findOne: vi.fn(),
711
+ create: vi.fn(),
712
+ update: vi.fn(),
713
+ delete: vi.fn(),
714
+ };
715
+
716
+ beforeEach(() => {
717
+ vi.clearAllMocks();
718
+ try { localStorage.clear(); } catch { /* jsdom */ }
719
+ });
720
+
721
+ it('user field resolves an expanded-object value inline (via UserField → LookupField)', () => {
722
+ render(
723
+ <FieldEditWidget
724
+ field={{ name: 'assignee', type: 'user' } as any}
725
+ value={{ id: 'u1', name: 'Ada Lovelace' }}
726
+ onChange={vi.fn()}
727
+ />
728
+ );
729
+ expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();
730
+ });
731
+
732
+ it('owner field resolves an expanded-object value inline', () => {
733
+ render(
734
+ <FieldEditWidget
735
+ field={{ name: 'owner', type: 'owner' } as any}
736
+ value={{ id: 'u2', name: 'Grace Hopper' }}
737
+ onChange={vi.fn()}
738
+ />
739
+ );
740
+ expect(screen.getByText('Grace Hopper')).toBeInTheDocument();
741
+ });
742
+
743
+ it('resolves a JSON-encoded external-id reference string without a bogus fetch', () => {
744
+ render(
745
+ <LookupField
746
+ value={'{"externalId":"Website Relaunch"}'}
747
+ onChange={vi.fn()}
748
+ field={{ name: 'project', type: 'lookup', reference: 'projects' } as any}
749
+ readonly={false}
750
+ dataSource={mockDataSource}
751
+ compact
752
+ />
753
+ );
754
+ // The external id is used as the display label (mirrors the read cell).
755
+ expect(screen.getByText('Website Relaunch')).toBeInTheDocument();
756
+ // Never fetch by passing the raw JSON string as an id.
757
+ expect(mockDataSource.findOne).not.toHaveBeenCalled();
758
+ expect(mockDataSource.find).not.toHaveBeenCalled();
759
+ });
760
+
761
+ it('resolves a numeric cell value against a string-keyed option (tolerant match)', () => {
762
+ render(
763
+ <LookupField
764
+ value={1}
765
+ onChange={vi.fn()}
766
+ field={{ name: 'category', type: 'lookup', options: [{ value: '1', label: 'One' }, { value: '2', label: 'Two' }] } as any}
767
+ readonly={false}
768
+ compact
769
+ />
770
+ );
771
+ // Strict === misses (number 1 vs string '1'); the loose fallback resolves it.
772
+ expect(screen.getByText('One')).toBeInTheDocument();
773
+ });
774
+ });
775
+
535
776
  describe('MasterDetailField', () => {
536
777
  const items = [
537
778
  { id: '1', label: 'Item 1' },
package/src/index.tsx CHANGED
@@ -10,7 +10,7 @@ import React from 'react';
10
10
  import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
11
11
  import { ComponentRegistry, percentDisplayValue } from '@object-ui/core';
12
12
  import { useLocalization } from '@object-ui/i18n';
13
- import { Badge, Avatar, AvatarFallback, Button, Checkbox, EmptyValue, cn } from '@object-ui/components';
13
+ import { Badge, Avatar, AvatarImage, AvatarFallback, Button, Checkbox, EmptyValue, cn } from '@object-ui/components';
14
14
  import { Check, X, Copy, Phone as PhoneIcon, MapPin } from 'lucide-react';
15
15
  import { useObjectTranslation } from '@object-ui/react';
16
16
  import { SchemaRendererContext as _SchemaRendererContext } from '@object-ui/react';
@@ -453,10 +453,21 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
453
453
 
454
454
  const safe = coerceToSafeValue(value);
455
455
  const numField = field as any;
456
- const precision = numField.precision ?? 0;
456
+ // Decimal places come from `scale` (the `s` in a `decimal(p, s)` column),
457
+ // NOT `precision` — `precision` is the TOTAL digit count (`p`), and reading
458
+ // it here padded every value out to that width (e.g. `1` from a
459
+ // decimal(10, 0) column rendered as "1.0000000000"). When `scale` is
460
+ // declared we pad to it so a fixed display is honoured (e.g. an amount with
461
+ // scale 2 → "16.00", a field with scale 3 → "3.140"); when it is absent we
462
+ // keep the minimum at 0 so trailing zeros are trimmed and only cap the
463
+ // maximum (20 = Intl max) to preserve the value's natural precision.
464
+ const scale = typeof numField.scale === 'number' ? numField.scale : undefined;
457
465
  const num = Number(safe);
458
466
  const formatted = !isNaN(num)
459
- ? new Intl.NumberFormat('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(num)
467
+ ? new Intl.NumberFormat('en-US', {
468
+ minimumFractionDigits: scale ?? 0,
469
+ maximumFractionDigits: scale ?? 20,
470
+ }).format(num)
460
471
  : String(safe);
461
472
 
462
473
  return <span className="tabular-nums">{formatted}</span>;
@@ -1152,7 +1163,14 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
1152
1163
  * Falls back to a muted placeholder while pending and on failure.
1153
1164
  */
1154
1165
  export function LookupCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
1155
- const referenceTo = (field as { reference_to?: string }).reference_to;
1166
+ // ObjectStack object metadata uses `reference` for the lookup target while the
1167
+ // objectui types call it `reference_to`. Every other reader (LookupField,
1168
+ // UserField, DetailSection, RelatedList, …) accepts both; this read cell must
1169
+ // too, or a picked/opaque id never resolves to a name and the cell shows the
1170
+ // muted "—" placeholder forever (e.g. after inline-editing a lookup).
1171
+ const referenceTo =
1172
+ (field as { reference_to?: string }).reference_to ||
1173
+ (field as { reference?: string }).reference;
1156
1174
 
1157
1175
  // Pick the FIRST primitive id we see (for arrays, only the first one is auto-resolved
1158
1176
  // to keep the cell cheap; multi-value lookups should generally be expanded server-side).
@@ -1316,6 +1334,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
1316
1334
  className="size-8 border-2 border-white"
1317
1335
  title={name}
1318
1336
  >
1337
+ {user.image && <AvatarImage src={user.image} alt={name} />}
1319
1338
  <AvatarFallback className="bg-blue-500 text-white text-xs">
1320
1339
  {initials}
1321
1340
  </AvatarFallback>
@@ -1339,6 +1358,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
1339
1358
  return (
1340
1359
  <div className="flex items-center gap-2">
1341
1360
  <Avatar className="size-8">
1361
+ {value.image && <AvatarImage src={value.image} alt={name} />}
1342
1362
  <AvatarFallback className="bg-blue-500 text-white text-xs">
1343
1363
  {initials}
1344
1364
  </AvatarFallback>
@@ -1976,6 +1996,14 @@ const fieldWidgetMap: Record<string, () => Promise<{ default: React.ComponentTyp
1976
1996
  'qrcode': () => import('./widgets/QRCodeField').then(m => ({ default: m.QRCodeField })),
1977
1997
  };
1978
1998
 
1999
+ /**
2000
+ * Every field type the form can render (the canonical list of supported types).
2001
+ * Exported so inline editing can be checked against it — a form type must
2002
+ * either have an inline editor or be explicitly excluded, see
2003
+ * `INLINE_EXCLUDED_FIELD_TYPES` and its drift-guard test.
2004
+ */
2005
+ export const FORM_FIELD_TYPES: readonly string[] = Object.freeze(Object.keys(fieldWidgetMap));
2006
+
1979
2007
  /**
1980
2008
  * Register a specific field type lazily
1981
2009
  * @param fieldType - The field type to register (e.g., 'text', 'number')
@@ -2130,6 +2158,7 @@ export function registerFields() {
2130
2158
  }
2131
2159
 
2132
2160
  export * from './widgets/types';
2161
+ export * from './FieldEditWidget';
2133
2162
  export * from './widgets/TextField';
2134
2163
  export * from './widgets/NumberField';
2135
2164
  export * from './widgets/BooleanField';
@@ -185,6 +185,20 @@ describe('GridField / LineItemsField — editable line items', () => {
185
185
  expect(onChange).toHaveBeenCalledWith([{ description: 'B', amount: 2 }]);
186
186
  });
187
187
 
188
+ it('row action buttons are always visible in grid mode (not hover-revealed)', () => {
189
+ render(
190
+ <GridField
191
+ value={[{ description: 'A', amount: 1 }]}
192
+ onChange={() => {}}
193
+ field={field}
194
+ />,
195
+ );
196
+ // Grid-mode rows previously gated these behind opacity-0/group-hover, so they
197
+ // were invisible until hover and unreachable on touch — they must always show.
198
+ expect(screen.getByLabelText('Remove row').className).not.toContain('opacity-0');
199
+ expect(screen.getByTestId('line-items-duplicate-0').className).not.toContain('opacity-0');
200
+ });
201
+
188
202
  it('readonly mode shows values and a summed total footer', () => {
189
203
  render(
190
204
  <GridField
@@ -857,10 +857,11 @@ export function GridField({
857
857
  type="button"
858
858
  variant="ghost"
859
859
  size="icon"
860
- className={cn(
861
- 'h-8 w-8 text-muted-foreground hover:text-foreground',
862
- !isList && 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100',
863
- )}
860
+ // Always visible (not hover-revealed): row actions must be
861
+ // discoverable and reachable on touch/coarse-pointer devices,
862
+ // which have no hover. The action column width is reserved
863
+ // regardless, so this adds no layout shift.
864
+ className="h-8 w-8 text-muted-foreground hover:text-foreground"
864
865
  aria-label="Duplicate row"
865
866
  title="Duplicate line"
866
867
  data-testid={`line-items-duplicate-${rowIdx}`}
@@ -875,10 +876,8 @@ export function GridField({
875
876
  type="button"
876
877
  variant="ghost"
877
878
  size="icon"
878
- className={cn(
879
- 'h-8 w-8 text-muted-foreground hover:text-destructive',
880
- !isList && 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100',
881
- )}
879
+ // Always visible — see the duplicate button above.
880
+ className="h-8 w-8 text-muted-foreground hover:text-destructive"
882
881
  aria-label="Remove row"
883
882
  data-testid={`line-items-remove-${rowIdx}`}
884
883
  onClick={() => removeRow(rowIdx)}