@object-ui/fields 11.3.0 → 11.5.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 (50) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/dist/FieldEditWidget.d.ts +8 -0
  3. package/dist/FieldEditWidget.test.d.ts +8 -0
  4. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  5. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  6. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  7. package/dist/index.cjs +4 -4
  8. package/dist/index.d.ts +17 -2
  9. package/dist/index.js +2015 -1346
  10. package/dist/widgets/LookupField.dependsOn.test.d.ts +8 -0
  11. package/dist/widgets/PeoplePicker.d.ts +63 -0
  12. package/dist/widgets/PeoplePicker.test.d.ts +8 -0
  13. package/dist/widgets/PeoplePickerComponents.test.d.ts +8 -0
  14. package/dist/widgets/PersonRow.d.ts +23 -0
  15. package/dist/widgets/RecordPickerDialog.d.ts +9 -1
  16. package/dist/widgets/SelectionTray.d.ts +27 -0
  17. package/dist/widgets/UserField.d.ts +0 -13
  18. package/dist/widgets/UserFieldPicker.test.d.ts +8 -0
  19. package/dist/widgets/deriveLookupColumns.d.ts +12 -4
  20. package/dist/widgets/personDisplay.d.ts +37 -0
  21. package/dist/widgets/personDisplay.test.d.ts +8 -0
  22. package/dist/widgets/useRecordQuery.d.ts +87 -0
  23. package/dist/widgets/useRecordQuery.test.d.ts +8 -0
  24. package/package.json +7 -7
  25. package/src/FieldEditWidget.test.ts +61 -0
  26. package/src/FieldEditWidget.tsx +56 -1
  27. package/src/__tests__/DateCellRenderer.test.tsx +50 -0
  28. package/src/__tests__/NumberCellRenderer.test.tsx +60 -0
  29. package/src/__tests__/lookupCellReferenceKey.test.tsx +57 -0
  30. package/src/complex-widgets.test.tsx +241 -0
  31. package/src/index.tsx +61 -18
  32. package/src/widgets/GridField.test.tsx +14 -0
  33. package/src/widgets/GridField.tsx +7 -8
  34. package/src/widgets/LookupField.dependsOn.test.tsx +233 -0
  35. package/src/widgets/LookupField.tsx +365 -190
  36. package/src/widgets/NumberField.tsx +4 -2
  37. package/src/widgets/PeoplePicker.test.tsx +334 -0
  38. package/src/widgets/PeoplePicker.tsx +552 -0
  39. package/src/widgets/PeoplePickerComponents.test.tsx +104 -0
  40. package/src/widgets/PersonRow.tsx +117 -0
  41. package/src/widgets/RecordPickerDialog.tsx +58 -129
  42. package/src/widgets/SelectionTray.tsx +110 -0
  43. package/src/widgets/UserField.tsx +20 -1
  44. package/src/widgets/UserFieldPicker.test.tsx +78 -0
  45. package/src/widgets/deriveLookupColumns.test.ts +25 -0
  46. package/src/widgets/deriveLookupColumns.ts +24 -10
  47. package/src/widgets/personDisplay.test.ts +113 -0
  48. package/src/widgets/personDisplay.ts +92 -0
  49. package/src/widgets/useRecordQuery.test.ts +174 -0
  50. package/src/widgets/useRecordQuery.ts +264 -0
@@ -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';
@@ -362,8 +362,12 @@ export function humanizeLabel(value: string): string {
362
362
 
363
363
  /**
364
364
  * Format date as relative time (e.g., "2 days ago", "Today", "Overdue 3d")
365
+ *
366
+ * `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at`
367
+ * isn't overdue, only a past due/deadline-semantic field is. Non-due-like
368
+ * past dates render as "Nd ago" instead.
365
369
  */
366
- export function formatRelativeDate(value: string | Date | number): string {
370
+ export function formatRelativeDate(value: string | Date | number, options?: { dueLike?: boolean }): string {
367
371
  if (value === null || value === undefined || value === '') return '—';
368
372
  const date = value instanceof Date ? value : new Date(value as any);
369
373
  if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
@@ -379,7 +383,7 @@ export function formatRelativeDate(value: string | Date | number): string {
379
383
  if (diffDays === -1) return 'Yesterday';
380
384
  if (diffDays < -1) {
381
385
  const absDays = Math.abs(diffDays);
382
- if (absDays <= 7) return `Overdue ${absDays}d`;
386
+ if (absDays <= 7) return options?.dueLike ? `Overdue ${absDays}d` : `${absDays}d ago`;
383
387
  return formatDate(date);
384
388
  }
385
389
  if (diffDays > 1 && diffDays <= 7) return `In ${diffDays} days`;
@@ -389,7 +393,7 @@ export function formatRelativeDate(value: string | Date | number): string {
389
393
  /**
390
394
  * Format date value
391
395
  */
392
- export function formatDate(value: string | Date | number, style?: string): string {
396
+ export function formatDate(value: string | Date | number, style?: string, options?: { dueLike?: boolean }): string {
393
397
  if (value === null || value === undefined || value === '') return '—';
394
398
  const date = value instanceof Date ? value : new Date(value as any);
395
399
  if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
@@ -403,7 +407,7 @@ export function formatDate(value: string | Date | number, style?: string): strin
403
407
  }
404
408
 
405
409
  if (style === 'relative') {
406
- return formatRelativeDate(date);
410
+ return formatRelativeDate(date, options);
407
411
  }
408
412
 
409
413
  // Default format: locale-aware human-readable. Drop the year when it
@@ -453,10 +457,21 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
453
457
 
454
458
  const safe = coerceToSafeValue(value);
455
459
  const numField = field as any;
456
- const precision = numField.precision ?? 0;
460
+ // Decimal places come from `scale` (the `s` in a `decimal(p, s)` column),
461
+ // NOT `precision` — `precision` is the TOTAL digit count (`p`), and reading
462
+ // it here padded every value out to that width (e.g. `1` from a
463
+ // decimal(10, 0) column rendered as "1.0000000000"). When `scale` is
464
+ // declared we pad to it so a fixed display is honoured (e.g. an amount with
465
+ // scale 2 → "16.00", a field with scale 3 → "3.140"); when it is absent we
466
+ // keep the minimum at 0 so trailing zeros are trimmed and only cap the
467
+ // maximum (20 = Intl max) to preserve the value's natural precision.
468
+ const scale = typeof numField.scale === 'number' ? numField.scale : undefined;
457
469
  const num = Number(safe);
458
470
  const formatted = !isNaN(num)
459
- ? new Intl.NumberFormat('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(num)
471
+ ? new Intl.NumberFormat('en-US', {
472
+ minimumFractionDigits: scale ?? 0,
473
+ maximumFractionDigits: scale ?? 20,
474
+ }).format(num)
460
475
  : String(safe);
461
476
 
462
477
  return <span className="tabular-nums">{formatted}</span>;
@@ -586,19 +601,20 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
586
601
  const safe = coerceToSafeValue(value);
587
602
  const dateField = field as any;
588
603
  const style = dateField.format || 'relative';
589
- const formatted = formatDate(safe as string | Date, style);
590
604
 
591
- // Determine if date is overdue (in the past) — but only color it red when the
592
- // field is *semantically* a due/deadline. A plain "start_date" or "created_at"
593
- // in the past should not render in red.
594
- const date = safe != null ? new Date(safe as string | number) : null;
595
- const isValidDate = date !== null && !isNaN(date.getTime());
596
- const startOfToday = new Date();
597
- startOfToday.setHours(0, 0, 0, 0);
605
+ // A date is only *semantically* a due/deadline when the field says so — a
606
+ // plain "start_date" or "created_at" in the past is neither overdue text
607
+ // nor red, even though it renders in the same relative-time style.
598
608
  const fieldName = String(dateField?.name || dateField?.accessorKey || dateField?.key || '').toLowerCase();
599
609
  const dueLike =
600
610
  dateField?.dueLike === true ||
601
611
  /(^|_)(due|deadline|expires?|expiry|expiration|expected_close|target_close|sla|return_by|renewal|next_action)(_|$)/.test(fieldName);
612
+ const formatted = formatDate(safe as string | Date, style, { dueLike });
613
+
614
+ const date = safe != null ? new Date(safe as string | number) : null;
615
+ const isValidDate = date !== null && !isNaN(date.getTime());
616
+ const startOfToday = new Date();
617
+ startOfToday.setHours(0, 0, 0, 0);
602
618
  const isOverdue = dueLike && isValidDate && date! < startOfToday;
603
619
  const isoString = isValidDate ? date!.toISOString() : String(safe);
604
620
 
@@ -1152,7 +1168,14 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
1152
1168
  * Falls back to a muted placeholder while pending and on failure.
1153
1169
  */
1154
1170
  export function LookupCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
1155
- const referenceTo = (field as { reference_to?: string }).reference_to;
1171
+ // ObjectStack object metadata uses `reference` for the lookup target while the
1172
+ // objectui types call it `reference_to`. Every other reader (LookupField,
1173
+ // UserField, DetailSection, RelatedList, …) accepts both; this read cell must
1174
+ // too, or a picked/opaque id never resolves to a name and the cell shows the
1175
+ // muted "—" placeholder forever (e.g. after inline-editing a lookup).
1176
+ const referenceTo =
1177
+ (field as { reference_to?: string }).reference_to ||
1178
+ (field as { reference?: string }).reference;
1156
1179
 
1157
1180
  // Pick the FIRST primitive id we see (for arrays, only the first one is auto-resolved
1158
1181
  // to keep the cell cheap; multi-value lookups should generally be expanded server-side).
@@ -1316,6 +1339,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
1316
1339
  className="size-8 border-2 border-white"
1317
1340
  title={name}
1318
1341
  >
1342
+ {user.image && <AvatarImage src={user.image} alt={name} />}
1319
1343
  <AvatarFallback className="bg-blue-500 text-white text-xs">
1320
1344
  {initials}
1321
1345
  </AvatarFallback>
@@ -1339,6 +1363,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
1339
1363
  return (
1340
1364
  <div className="flex items-center gap-2">
1341
1365
  <Avatar className="size-8">
1366
+ {value.image && <AvatarImage src={value.image} alt={name} />}
1342
1367
  <AvatarFallback className="bg-blue-500 text-white text-xs">
1343
1368
  {initials}
1344
1369
  </AvatarFallback>
@@ -1976,6 +2001,14 @@ const fieldWidgetMap: Record<string, () => Promise<{ default: React.ComponentTyp
1976
2001
  'qrcode': () => import('./widgets/QRCodeField').then(m => ({ default: m.QRCodeField })),
1977
2002
  };
1978
2003
 
2004
+ /**
2005
+ * Every field type the form can render (the canonical list of supported types).
2006
+ * Exported so inline editing can be checked against it — a form type must
2007
+ * either have an inline editor or be explicitly excluded, see
2008
+ * `INLINE_EXCLUDED_FIELD_TYPES` and its drift-guard test.
2009
+ */
2010
+ export const FORM_FIELD_TYPES: readonly string[] = Object.freeze(Object.keys(fieldWidgetMap));
2011
+
1979
2012
  /**
1980
2013
  * Register a specific field type lazily
1981
2014
  * @param fieldType - The field type to register (e.g., 'text', 'number')
@@ -2009,6 +2042,12 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([
2009
2042
  'slider',
2010
2043
  // Display renderer owned by `plugin-markdown:markdown`.
2011
2044
  'markdown',
2045
+ // No other package owns the bare `time`/`address` key, but `registerField`
2046
+ // wraps each call in a fresh `React.lazy(...)`, so re-registration (HMR,
2047
+ // re-import) fails the registry's identity check every time and logs the
2048
+ // same "bare-name fallback overwritten" warning at every boot regardless.
2049
+ 'time',
2050
+ 'address',
2012
2051
  ]);
2013
2052
 
2014
2053
  export function registerField(fieldType: string): void {
@@ -2065,7 +2104,10 @@ export function registerFields() {
2065
2104
  ComponentRegistry.register('select', createFieldRenderer(SelectField), { namespace: 'field', skipFallback: true });
2066
2105
  ComponentRegistry.register('date', createFieldRenderer(DateField), { namespace: 'field' });
2067
2106
  ComponentRegistry.register('datetime', createFieldRenderer(DateTimeField), { namespace: 'field' });
2068
- ComponentRegistry.register('time', createFieldRenderer(TimeField), { namespace: 'field' });
2107
+ // Namespaced-only: no other renderer legitimately owns the bare 'time' key,
2108
+ // but the fallback still triggered noisy "bare-name overwritten" warnings on
2109
+ // every re-registration (e.g. hot reload / re-init in a shared registry).
2110
+ ComponentRegistry.register('time', createFieldRenderer(TimeField), { namespace: 'field', skipFallback: true });
2069
2111
 
2070
2112
  // Contact fields - wrapped for documentation compatibility
2071
2113
  // `email` collides with the `ui:email` input variant; namespaced-only so the
@@ -2123,7 +2165,8 @@ export function registerFields() {
2123
2165
  ComponentRegistry.register('code', createFieldRenderer(CodeField), { namespace: 'field' });
2124
2166
  // `avatar` collides with the display avatar widget; namespaced-only.
2125
2167
  ComponentRegistry.register('avatar', createFieldRenderer(AvatarField), { namespace: 'field', skipFallback: true });
2126
- ComponentRegistry.register('address', createFieldRenderer(AddressField), { namespace: 'field' });
2168
+ // Namespaced-only — see the 'time' registration above for rationale.
2169
+ ComponentRegistry.register('address', createFieldRenderer(AddressField), { namespace: 'field', skipFallback: true });
2127
2170
  ComponentRegistry.register('geolocation', createFieldRenderer(GeolocationField), { namespace: 'field' });
2128
2171
  ComponentRegistry.register('signature', createFieldRenderer(SignatureField), { namespace: 'field' });
2129
2172
  ComponentRegistry.register('qrcode', createFieldRenderer(QRCodeField), { namespace: 'field' });
@@ -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)}