@object-ui/fields 11.3.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.
- package/CHANGELOG.md +139 -0
- package/dist/FieldEditWidget.d.ts +8 -0
- package/dist/FieldEditWidget.test.d.ts +8 -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 +7 -0
- package/dist/index.js +1987 -1326
- 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__/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 +32 -4
- 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';
|
|
@@ -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
|
-
|
|
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', {
|
|
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
|
-
|
|
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')
|
|
@@ -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)}
|