@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,8 @@
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
+ export {};
@@ -0,0 +1,63 @@
1
+ import { default as React } from 'react';
2
+ import { DataSource, LookupFilterDef } from '@object-ui/types';
3
+ /**
4
+ * PeoplePicker — the Tier 0, search-first user picker (issue #2112).
5
+ *
6
+ * A single-column picker: search box → recent contacts → rich candidate rows
7
+ * (avatar + name + department·email) → a live SelectionTray for multi-select.
8
+ * Composed from the reusable {@link useRecordQuery} kernel and
9
+ * {@link SelectionTray}; a future org-tree tier reuses both beside a left tree.
10
+ *
11
+ * Container is responsive: a centered Dialog on desktop, a bottom Sheet on
12
+ * mobile (<768px). Interaction: full keyboard model (↑/↓ move the cursor, ↵
13
+ * toggles/commits, Backspace on an empty search removes the last chip, Esc
14
+ * closes), matched-term highlighting in rows, skeletons on first load with no
15
+ * flash-to-empty on refetch, and friendly empty / error+retry states.
16
+ *
17
+ * Candidate hygiene (e.g. `banned != true`), the department `$expand`, and the
18
+ * avatar/subtitle field config are supplied by the caller (UserField). Pinyin /
19
+ * employee-id search is server-side and transparent — the client only sends the
20
+ * term.
21
+ */
22
+ export interface PeoplePickerProps {
23
+ open: boolean;
24
+ onOpenChange: (open: boolean) => void;
25
+ title?: string;
26
+ multiple?: boolean;
27
+ dataSource: DataSource;
28
+ /** Object to query — `sys_user` for user fields. */
29
+ objectName: string;
30
+ displayField?: string;
31
+ idField?: string;
32
+ /** Dotted field paths for the row subtitle, e.g. `['primary_business_unit_id.name','email']`. */
33
+ subtitleFields?: string[];
34
+ avatarField?: string;
35
+ /** Related entities to expand (e.g. `['primary_business_unit_id']` for the department name). */
36
+ expand?: string[];
37
+ /** Narrow the server searchable set (ADR-0061). */
38
+ searchFields?: string[];
39
+ pageSize?: number;
40
+ /** Base candidate filters (e.g. exclude banned users). */
41
+ lookupFilters?: LookupFilterDef[];
42
+ /**
43
+ * Hard filter constraints in QueryParams.$filter record form — the
44
+ * dependent (cascading) lookup chain (#2215). Always ANDed into the
45
+ * candidate query; spread after `lookupFilters` so it wins on conflicts.
46
+ */
47
+ baseFilter?: Record<string, any>;
48
+ /** Current selection (id, or id[] when `multiple`). */
49
+ value?: any;
50
+ onSelect: (value: any) => void;
51
+ onSelectRecords?: (records: any[]) => void;
52
+ /**
53
+ * Render as an inline combobox anchored to {@link trigger} — a Popover
54
+ * dropdown on desktop, a bottom Sheet on mobile — instead of a centered
55
+ * modal Dialog. In inline mode multi-select commits live on each toggle
56
+ * (the caller's field chips are the selection), so there's no staging tray
57
+ * or confirm button.
58
+ */
59
+ inline?: boolean;
60
+ /** The element the inline dropdown/sheet anchors to (the field trigger). */
61
+ trigger?: React.ReactNode;
62
+ }
63
+ export declare function PeoplePicker({ open, onOpenChange, title, multiple, dataSource, objectName, displayField, idField, subtitleFields, avatarField, expand, searchFields, pageSize, lookupFilters, baseFilter: baseFilterProp, value, onSelect, onSelectRecords, inline, trigger, }: PeoplePickerProps): React.JSX.Element;
@@ -0,0 +1,8 @@
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
+ export {};
@@ -0,0 +1,8 @@
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
+ export {};
@@ -0,0 +1,23 @@
1
+ import { default as React } from 'react';
2
+ /**
3
+ * A rich, single-line candidate row for the search-first PeoplePicker:
4
+ * avatar (image with initials fallback) + name + subtitle (department · email).
5
+ * The subtitle is what lets search stand in for an org tree — it disambiguates
6
+ * same-named people inline. The typed query is highlighted in both lines.
7
+ * Selectable/toggleable; a Check marks the selected state, and `active` reflects
8
+ * the keyboard cursor (scrolls itself into view).
9
+ */
10
+ export interface PersonRowProps {
11
+ record: any;
12
+ displayField?: string;
13
+ subtitleFields?: string[];
14
+ avatarField?: string;
15
+ selected?: boolean;
16
+ /** Keyboard cursor is on this row — highlight + scroll into view. */
17
+ active?: boolean;
18
+ /** Typed search term; matches are highlighted in name + subtitle. */
19
+ highlightQuery?: string;
20
+ onSelect?: (record: any) => void;
21
+ className?: string;
22
+ }
23
+ export declare function PersonRow({ record, displayField, subtitleFields, avatarField, selected, active, highlightQuery, onSelect, className, }: PersonRowProps): React.JSX.Element;
@@ -125,6 +125,14 @@ export interface RecordPickerDialogProps {
125
125
  * Restricts which records are selectable (e.g. only active records).
126
126
  */
127
127
  lookupFilters?: LookupFilterDef[];
128
+ /**
129
+ * Hard filter constraints applied to every query, in QueryParams.$filter
130
+ * record form. Unlike `lookupFilters`, entries here never surface in the
131
+ * filter bar and cannot be overridden by user filter input — used for the
132
+ * dependent (cascading) lookup chain, where the parent field's value MUST
133
+ * scope the candidate set (#2215).
134
+ */
135
+ baseFilter?: Record<string, any>;
128
136
  /**
129
137
  * Cell renderer resolver function.
130
138
  * When provided, columns with a `type` property will be rendered using the
@@ -177,4 +185,4 @@ export interface RecordPickerDialogProps {
177
185
  * states, and single/multi-select. Responsive: mobile-friendly width
178
186
  * via Tailwind breakpoints.
179
187
  */
180
- export declare function RecordPickerDialog({ open, onOpenChange, title, multiple, dataSource, objectName, columns: columnsProp, displayField, titleFormat, idField, pageSize, value, onSelect, onSelectRecords, lookupFilters, cellRenderer, filterColumns, renderFilterBar, renderGrid, }: RecordPickerDialogProps): React.JSX.Element;
188
+ export declare function RecordPickerDialog({ open, onOpenChange, title, multiple, dataSource, objectName, columns: columnsProp, displayField, titleFormat, idField, pageSize, value, onSelect, onSelectRecords, lookupFilters, baseFilter, cellRenderer, filterColumns, renderFilterBar, renderGrid, }: RecordPickerDialogProps): React.JSX.Element;
@@ -0,0 +1,27 @@
1
+ import { default as React } from 'react';
2
+ /**
3
+ * "Selected" transfer-box area (穿梭框式已选区): live echo of the current
4
+ * multi-selection as removable avatar chips. Reusable kernel — the future
5
+ * org-tree tier composes the same tray beside its right-hand list. Purely
6
+ * presentational: it takes the resolved records + an onRemove callback, and
7
+ * accepts already-translated `label`/`emptyText` so it stays i18n-agnostic.
8
+ */
9
+ export interface SelectionTrayProps {
10
+ /** Full record objects for the current selection. */
11
+ records: any[];
12
+ /** Remove a selected record by its id. */
13
+ onRemove: (id: any) => void;
14
+ displayField?: string;
15
+ avatarField?: string;
16
+ idField?: string;
17
+ /** Header label, e.g. "Selected (3)" — omit to hide the header. */
18
+ label?: string;
19
+ /** When provided, a "Clear all" action shows in the header while non-empty. */
20
+ onClear?: () => void;
21
+ /** Localized "Clear all" text (i18n-agnostic — caller passes it). */
22
+ clearLabel?: string;
23
+ /** Shown when the selection is empty. */
24
+ emptyText?: string;
25
+ className?: string;
26
+ }
27
+ export declare function SelectionTray({ records, onRemove, displayField, avatarField, idField, label, onClear, clearLabel, emptyText, className, }: SelectionTrayProps): React.JSX.Element;
@@ -1,16 +1,3 @@
1
1
  import { default as React } from 'react';
2
2
  import { FieldWidgetProps } from './types';
3
- /**
4
- * UserField — person picker for the `user` field type.
5
- *
6
- * `user` is a lookup specialized to the `sys_user` system object (see framework
7
- * "lookup → sys_user" specialization). Rather than re-implement candidate search,
8
- * the searchable picker, the record-picker dialog and id resolution, this widget
9
- * **delegates to the shared {@link LookupField}** with the reference fixed to
10
- * `sys_user`. The author writes `Field.user({ multiple })`; we normalise the field
11
- * metadata so the lookup machinery targets users with a sensible display field.
12
- *
13
- * Table-cell display (avatars / initials) is handled separately by
14
- * `UserCellRenderer`; this is the form/editor widget.
15
- */
16
3
  export declare function UserField(props: FieldWidgetProps<any>): React.JSX.Element;
@@ -0,0 +1,8 @@
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
+ export {};
@@ -11,7 +11,14 @@ interface ObjectSchemaLike {
11
11
  label?: string;
12
12
  hidden?: boolean;
13
13
  }>;
14
- /** Object-level "fields to display in search result cards" (spec metadata). */
14
+ /**
15
+ * ADR-0085 semantic role: the object's most important fields. The single
16
+ * source for "how to list this object" — shared with the detail-page related
17
+ * list — so a lookup picker and a related list of the same object agree on
18
+ * columns with zero per-surface config.
19
+ */
20
+ highlightFields?: unknown;
21
+ /** Object-level "fields to display in search result cards" (legacy spec metadata). */
15
22
  displayFields?: unknown;
16
23
  }
17
24
  /**
@@ -19,9 +26,10 @@ interface ObjectSchemaLike {
19
26
  * a lookup field declares no explicit `lookup_columns`.
20
27
  *
21
28
  * Priority:
22
- * 1. The referenced object's own `displayFields` ("fields to display in
23
- * search result cards") — the author's declared search-card shape.
24
- * 2. Otherwise the display field plus the next few business fields in
29
+ * 1. The referenced object's ADR-0085 `highlightFields` — the canonical
30
+ * "how to list this object" set, shared with the detail-page related list.
31
+ * 2. Else the object's legacy `displayFields` search-card shape.
32
+ * 3. Otherwise the display field plus the next few business fields in
25
33
  * declaration order, skipping system/audit fields and heavy non-tabular
26
34
  * types.
27
35
  *
@@ -0,0 +1,37 @@
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
+ * Pure display helpers for person (sys_user) records, shared by the
10
+ * search-first PeoplePicker's rich rows, the SelectionTray chips, and the
11
+ * read-only user cell renderer. Kept framework-free so they're trivially
12
+ * unit-testable and reusable by a future org-tree tier.
13
+ */
14
+ /** Resolve a possibly-dotted path (e.g. `primary_business_unit_id.name`) against a record. */
15
+ export declare function resolvePath(record: any, path: string): any;
16
+ /** Best display name for a person record, tolerating expanded / raw shapes. */
17
+ export declare function getPersonName(record: any, nameField?: string): string;
18
+ /**
19
+ * Up-to-2-char initials for the avatar fallback.
20
+ * - "John Doe" → "JD"; "John" → "JO"
21
+ * - CJK single token → trailing 1–2 chars (given name): "张三" → "张三", "王小明" → "小明"
22
+ */
23
+ export declare function getPersonInitials(name: string): string;
24
+ /** Secondary line: resolve each field path, drop empties, join with " · ". */
25
+ export declare function getPersonSubtitle(record: any, fields?: string[]): string;
26
+ /** Avatar image URL (defaults to `sys_user.image`); undefined when absent. */
27
+ export declare function getPersonAvatarUrl(record: any, avatarField?: string): string | undefined;
28
+ /** Record id, tolerating `id` / `_id` / a custom id field. */
29
+ export declare function getPersonId(record: any, idField?: string): any;
30
+ /**
31
+ * Case-insensitive literal-substring match ranges of `query` in `text`, as
32
+ * `[start, end)` index pairs. Used to highlight the typed term in candidate
33
+ * rows — a strong disambiguation aid for same-named people. Pinyin matches are
34
+ * resolved server-side and won't have client ranges; that's fine, the row still
35
+ * shows, just without a highlight.
36
+ */
37
+ export declare function matchRanges(text: string, query: string): Array<[number, number]>;
@@ -0,0 +1,8 @@
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
+ export {};
@@ -0,0 +1,87 @@
1
+ import { DataSource } from '@object-ui/types';
2
+ /**
3
+ * useRecordQuery — the shared record-query kernel behind record pickers.
4
+ *
5
+ * Encapsulates the query/pagination/search/sort loop that was previously
6
+ * duplicated (and independently tuned) inside `RecordPickerDialog.fetchRecords`
7
+ * and `LookupField.fetchLookupData`:
8
+ *
9
+ * - builds the `QueryParams` (`$top`/`$skip`/`$search`/`$searchFields`/
10
+ * `$orderby`/`$filter`/`$expand`),
11
+ * - issues `dataSource.find(objectName, params)` and normalises the result
12
+ * (`{ data, total }`, tolerating a bare array),
13
+ * - owns `records`/`loading`/`error`/`total` plus the `page`/`search`/`sort`
14
+ * controls, with a debounced search path,
15
+ * - clears itself when `enabled` goes false (e.g. a dialog closing).
16
+ *
17
+ * The caller keeps ownership of *selection* state and of record→option mapping;
18
+ * this hook only answers "what records match the current query". It is the
19
+ * reusable core the search-first PeoplePicker (and a future org-tree tier)
20
+ * compose on top of.
21
+ *
22
+ * Effect shape intentionally mirrors the original components to preserve
23
+ * behaviour: the fetch effect keys on page/sort/filter/expand (NOT `search`,
24
+ * which drives its own debounced fetch), and state resets live in a separate
25
+ * `enabled`-keyed effect so they never cascade into a fetch (React #185).
26
+ */
27
+ export interface UseRecordQueryOptions {
28
+ /** Backing data source. When absent/invalid the hook stays idle. */
29
+ dataSource?: DataSource | null;
30
+ /** Object/resource name to query (e.g. `sys_user`). */
31
+ objectName?: string | null;
32
+ /**
33
+ * Gate fetching. When false no request is issued and query state is cleared.
34
+ * Typically wired to a dialog's `open`, plus any "dependencies satisfied"
35
+ * guard. Default `true`.
36
+ */
37
+ enabled?: boolean;
38
+ /** `$top` — page size. Default 50. */
39
+ pageSize?: number;
40
+ /**
41
+ * When true, use page-based pagination (`$skip = (page - 1) * pageSize`).
42
+ * When false (default) a single page of `pageSize` records is fetched.
43
+ */
44
+ paginate?: boolean;
45
+ /**
46
+ * `$filter` — already merged by the caller (base `lookup_filters`, dependent
47
+ * lookup chain, candidate hygiene like `banned != true`, …). Compared by
48
+ * value, so a referentially-new-but-equal object each render will not loop.
49
+ */
50
+ filter?: Record<string, any>;
51
+ /** `$expand` — related entities to include (e.g. `['primary_business_unit_id']`). */
52
+ expand?: string[];
53
+ /** `$searchFields` — narrow the server searchable set (ADR-0061). */
54
+ searchFields?: string[];
55
+ /** Debounce applied to {@link UseRecordQueryResult.setSearch}, in ms. Default 300. */
56
+ debounceMs?: number;
57
+ }
58
+ export interface RecordQuerySort {
59
+ field: string;
60
+ direction: 'asc' | 'desc';
61
+ }
62
+ export interface UseRecordQueryResult {
63
+ /** Records returned by the current query. */
64
+ records: any[];
65
+ loading: boolean;
66
+ error: string | null;
67
+ /** Total matching records (server-reported, else the current page length). */
68
+ total: number;
69
+ /** `ceil(total / pageSize)`, at least 1. */
70
+ totalPages: number;
71
+ page: number;
72
+ search: string;
73
+ sort: RecordQuerySort | null;
74
+ /** Jump to a page (1-indexed). No-op unless `paginate` is set. */
75
+ setPage: (page: number) => void;
76
+ /** Set the search term; debounced, and resets to page 1. */
77
+ setSearch: (query: string) => void;
78
+ /** Toggle sort on a field (new field → asc, same field → flip); resets to page 1. */
79
+ toggleSort: (field: string) => void;
80
+ /** Set the sort directly (or clear with `null`); resets to page 1. */
81
+ setSort: (sort: RecordQuerySort | null) => void;
82
+ /** Clear query state (search/page/sort/records/error). */
83
+ reset: () => void;
84
+ /** Imperatively refetch with the current params. */
85
+ refetch: () => void;
86
+ }
87
+ export declare function useRecordQuery(options: UseRecordQueryOptions): UseRecordQueryResult;
@@ -0,0 +1,8 @@
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
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/fields",
3
- "version": "11.3.0",
3
+ "version": "11.5.0",
4
4
  "description": "Field renderers and registry for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,12 +30,12 @@
30
30
  "rehype-sanitize": "^6.0.0",
31
31
  "remark-gfm": "^4.0.1",
32
32
  "tailwind-merge": "^3.6.0",
33
- "@object-ui/components": "11.3.0",
34
- "@object-ui/core": "11.3.0",
35
- "@object-ui/i18n": "11.3.0",
36
- "@object-ui/providers": "11.3.0",
37
- "@object-ui/react": "11.3.0",
38
- "@object-ui/types": "11.3.0"
33
+ "@object-ui/components": "11.5.0",
34
+ "@object-ui/core": "11.5.0",
35
+ "@object-ui/i18n": "11.5.0",
36
+ "@object-ui/providers": "11.5.0",
37
+ "@object-ui/react": "11.5.0",
38
+ "@object-ui/types": "11.5.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": "^18.0.0 || ^19.0.0",
@@ -0,0 +1,61 @@
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 {
11
+ FORM_FIELD_TYPES,
12
+ INLINE_EXCLUDED_FIELD_TYPES,
13
+ hasFieldEditWidget,
14
+ } from './index';
15
+
16
+ /**
17
+ * Drift-guard: the inline cell editor reuses the form's field widgets, but the
18
+ * two lists were hand-maintained separately and drifted — `lookup` (and other
19
+ * relational types) had a perfectly good form widget yet fell back to a plain
20
+ * text box inline, because nobody wired it up. This pins the contract: every
21
+ * type the FORM can render must have an explicit inline decision — an editor or
22
+ * a documented exclusion — so a new form widget can never again silently become
23
+ * an editable text box (or a missing one) in the grid.
24
+ */
25
+ describe('inline editor ↔ form widget parity', () => {
26
+ it('every form field type either has an inline editor or is explicitly excluded', () => {
27
+ const undecided = FORM_FIELD_TYPES.filter(
28
+ (t) => !hasFieldEditWidget(t) && !INLINE_EXCLUDED_FIELD_TYPES.has(t),
29
+ );
30
+ // If this fails: a form widget type has no inline decision. Either add it to
31
+ // EDIT_WIDGETS (inline-editable) or to INLINE_EXCLUDED_FIELD_TYPES (with a
32
+ // reason) in FieldEditWidget.tsx.
33
+ expect(undecided).toEqual([]);
34
+ });
35
+
36
+ it('the exclusion set lists only real form types (no stale entries)', () => {
37
+ const known = new Set(FORM_FIELD_TYPES);
38
+ const stale = [...INLINE_EXCLUDED_FIELD_TYPES].filter((t) => !known.has(t));
39
+ expect(stale).toEqual([]);
40
+ });
41
+
42
+ it('relational fields use the standard picker inline (regression: lookup was a text box)', () => {
43
+ for (const t of ['lookup', 'master_detail', 'user', 'owner']) {
44
+ expect(hasFieldEditWidget(t)).toBe(true);
45
+ }
46
+ });
47
+
48
+ it('computed / binary form types are NOT inline-editable (excluded)', () => {
49
+ for (const t of ['formula', 'summary', 'auto_number', 'file', 'image']) {
50
+ expect(hasFieldEditWidget(t)).toBe(false);
51
+ expect(INLINE_EXCLUDED_FIELD_TYPES.has(t)).toBe(true);
52
+ }
53
+ });
54
+
55
+ it('structured-value types edit inline with their form widget (color/address/location/code/…)', () => {
56
+ for (const t of ['color', 'address', 'location', 'geolocation', 'code', 'qrcode']) {
57
+ expect(hasFieldEditWidget(t)).toBe(true);
58
+ expect(INLINE_EXCLUDED_FIELD_TYPES.has(t)).toBe(false);
59
+ }
60
+ });
61
+ });
@@ -31,6 +31,19 @@ import { TimeField } from './widgets/TimeField';
31
31
  import { EmailField } from './widgets/EmailField';
32
32
  import { PhoneField } from './widgets/PhoneField';
33
33
  import { UrlField } from './widgets/UrlField';
34
+ // Relational pickers — the SAME standard widgets the form uses. They read the
35
+ // related-object dataSource from SchemaRendererContext (which the grid already
36
+ // provides), so they drop straight into an inline cell.
37
+ import { LookupField } from './widgets/LookupField';
38
+ import { UserField } from './widgets/UserField';
39
+ // Structured-value editors — lightweight (no map/code-editor deps), same
40
+ // widgets the form uses. Drop into a cell like the rest.
41
+ import { ColorField } from './widgets/ColorField';
42
+ import { AddressField } from './widgets/AddressField';
43
+ import { LocationField } from './widgets/LocationField';
44
+ import { GeolocationField } from './widgets/GeolocationField';
45
+ import { CodeField } from './widgets/CodeField';
46
+ import { QRCodeField } from './widgets/QRCodeField';
34
47
 
35
48
  /**
36
49
  * Field types that edit in place with a dedicated widget. Keyed by the raw
@@ -61,8 +74,40 @@ const EDIT_WIDGETS: Record<string, React.ComponentType<FieldWidgetProps<any>>> =
61
74
  email: EmailField,
62
75
  phone: PhoneField,
63
76
  url: UrlField,
77
+ // Relational — the record/user pickers, same as the form (the form maps
78
+ // master_detail to the single-value LookupField too, see fieldWidgetMap).
79
+ lookup: LookupField,
80
+ master_detail: LookupField,
81
+ user: UserField,
82
+ owner: UserField,
83
+ // Structured-value editors — same widgets the form uses.
84
+ color: ColorField,
85
+ address: AddressField,
86
+ location: LocationField,
87
+ geolocation: GeolocationField,
88
+ code: CodeField,
89
+ qrcode: QRCodeField,
64
90
  };
65
91
 
92
+ /**
93
+ * Form field types that are deliberately NOT given an inline editor — every
94
+ * other form widget type must be in {@link EDIT_WIDGETS}. This pairing is
95
+ * enforced by a test against the form's widget map (`FORM_FIELD_TYPES`) so the
96
+ * two can't silently drift again (which is how `lookup` was missed). To add a
97
+ * type to inline editing, move it from here into EDIT_WIDGETS.
98
+ */
99
+ export const INLINE_EXCLUDED_FIELD_TYPES = new Set<string>([
100
+ // Computed / read-only — the grid keeps these non-editable (no value to author).
101
+ 'formula', 'summary', 'auto_number',
102
+ // Binary / attachment — edited from the record form, shown read-only in the grid.
103
+ 'file', 'image', 'avatar', 'signature',
104
+ // Heavy / full editors — better in the record form than a cell.
105
+ 'markdown', 'html', 'richtext', 'password',
106
+ // Containers / non-authorable — a sub-form / sub-grid / embedding vector
107
+ // doesn't belong in a single cell.
108
+ 'object', 'grid', 'vector',
109
+ ]);
110
+
66
111
  /** Field types whose value is chosen in one discrete gesture (no free typing). */
67
112
  export const DISCRETE_EDIT_TYPES = new Set<string>([
68
113
  'boolean', 'toggle', 'select', 'status', 'radio', 'rating',
@@ -73,6 +118,15 @@ export function hasFieldEditWidget(type: string | undefined): boolean {
73
118
  return !!type && type in EDIT_WIDGETS;
74
119
  }
75
120
 
121
+ /**
122
+ * Relational picker widgets (lookup / master_detail / user / owner) render best
123
+ * in a grid cell as a single-line, borderless trigger — so the selected
124
+ * record's NAME shows inside the trigger instead of a chip stacked above a
125
+ * separate "Select…" button (which double-stacks and wastes the row height).
126
+ * This mirrors how the line-item grid (`GridField`) renders its lookup cells.
127
+ */
128
+ const COMPACT_EDIT_TYPES = new Set<string>(['lookup', 'master_detail', 'user', 'owner']);
129
+
76
130
  /**
77
131
  * Render the dedicated edit widget for a field's type — the SAME control the
78
132
  * form uses — as a controlled `{ value, onChange, field }` input. Returns
@@ -87,5 +141,6 @@ export function FieldEditWidget({
87
141
  }: FieldWidgetProps<any>): React.ReactElement | null {
88
142
  const Widget = field?.type ? EDIT_WIDGETS[field.type] : undefined;
89
143
  if (!Widget) return null;
90
- return <Widget field={field} value={value} onChange={onChange} readonly={readonly} />;
144
+ const compactProps = field?.type && COMPACT_EDIT_TYPES.has(field.type) ? { compact: true } : {};
145
+ return <Widget field={field} value={value} onChange={onChange} readonly={readonly} {...(compactProps as any)} />;
91
146
  }
@@ -0,0 +1,50 @@
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 { DateCellRenderer, formatRelativeDate } from '../index';
14
+
15
+ function daysAgo(n: number): string {
16
+ const d = new Date();
17
+ d.setDate(d.getDate() - n);
18
+ return d.toISOString();
19
+ }
20
+
21
+ describe('formatRelativeDate', () => {
22
+ it('renders "Overdue Nd" only when dueLike is set', () => {
23
+ expect(formatRelativeDate(daysAgo(3), { dueLike: true })).toBe('Overdue 3d');
24
+ });
25
+
26
+ it('renders neutral "Nd ago" for a past date that is not due-like', () => {
27
+ expect(formatRelativeDate(daysAgo(3))).toBe('3d ago');
28
+ expect(formatRelativeDate(daysAgo(3), { dueLike: false })).toBe('3d ago');
29
+ });
30
+ });
31
+
32
+ describe('DateCellRenderer', () => {
33
+ it('does not label a past start_date as "Overdue" (regression: field-role-agnostic formatter)', () => {
34
+ render(<DateCellRenderer value={daysAgo(6)} field={{ type: 'date', name: 'start_date' } as any} />);
35
+ expect(screen.getByText('6d ago')).toBeInTheDocument();
36
+ expect(screen.queryByText(/Overdue/)).not.toBeInTheDocument();
37
+ });
38
+
39
+ it('labels a past due_date as "Overdue Nd" and colors it red', () => {
40
+ render(<DateCellRenderer value={daysAgo(6)} field={{ type: 'date', name: 'due_date' } as any} />);
41
+ const el = screen.getByText('Overdue 6d');
42
+ expect(el).toBeInTheDocument();
43
+ expect(el.className).toMatch(/text-red-600/);
44
+ });
45
+
46
+ it('respects an explicit dueLike:true override regardless of field name', () => {
47
+ render(<DateCellRenderer value={daysAgo(2)} field={{ type: 'date', name: 'end_date', dueLike: true } as any} />);
48
+ expect(screen.getByText('Overdue 2d')).toBeInTheDocument();
49
+ });
50
+ });
@@ -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
+ });