@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.
- package/CHANGELOG.md +155 -0
- package/dist/FieldEditWidget.d.ts +8 -0
- package/dist/FieldEditWidget.test.d.ts +8 -0
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/index.cjs +4 -4
- package/dist/index.d.ts +17 -2
- package/dist/index.js +2015 -1346
- package/dist/widgets/LookupField.dependsOn.test.d.ts +8 -0
- package/dist/widgets/PeoplePicker.d.ts +63 -0
- package/dist/widgets/PeoplePicker.test.d.ts +8 -0
- package/dist/widgets/PeoplePickerComponents.test.d.ts +8 -0
- package/dist/widgets/PersonRow.d.ts +23 -0
- package/dist/widgets/RecordPickerDialog.d.ts +9 -1
- package/dist/widgets/SelectionTray.d.ts +27 -0
- package/dist/widgets/UserField.d.ts +0 -13
- package/dist/widgets/UserFieldPicker.test.d.ts +8 -0
- package/dist/widgets/deriveLookupColumns.d.ts +12 -4
- package/dist/widgets/personDisplay.d.ts +37 -0
- package/dist/widgets/personDisplay.test.d.ts +8 -0
- package/dist/widgets/useRecordQuery.d.ts +87 -0
- package/dist/widgets/useRecordQuery.test.d.ts +8 -0
- package/package.json +7 -7
- package/src/FieldEditWidget.test.ts +61 -0
- package/src/FieldEditWidget.tsx +56 -1
- package/src/__tests__/DateCellRenderer.test.tsx +50 -0
- package/src/__tests__/NumberCellRenderer.test.tsx +60 -0
- package/src/__tests__/lookupCellReferenceKey.test.tsx +57 -0
- package/src/complex-widgets.test.tsx +241 -0
- package/src/index.tsx +61 -18
- package/src/widgets/GridField.test.tsx +14 -0
- package/src/widgets/GridField.tsx +7 -8
- package/src/widgets/LookupField.dependsOn.test.tsx +233 -0
- package/src/widgets/LookupField.tsx +365 -190
- package/src/widgets/NumberField.tsx +4 -2
- package/src/widgets/PeoplePicker.test.tsx +334 -0
- package/src/widgets/PeoplePicker.tsx +552 -0
- package/src/widgets/PeoplePickerComponents.test.tsx +104 -0
- package/src/widgets/PersonRow.tsx +117 -0
- package/src/widgets/RecordPickerDialog.tsx +58 -129
- package/src/widgets/SelectionTray.tsx +110 -0
- package/src/widgets/UserField.tsx +20 -1
- package/src/widgets/UserFieldPicker.test.tsx +78 -0
- package/src/widgets/deriveLookupColumns.test.ts +25 -0
- package/src/widgets/deriveLookupColumns.ts +24 -10
- package/src/widgets/personDisplay.test.ts +113 -0
- package/src/widgets/personDisplay.ts +92 -0
- package/src/widgets/useRecordQuery.test.ts +174 -0
- 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
|
-
|
|
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', {
|
|
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
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
879
|
-
|
|
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)}
|