@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,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,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;
|
|
@@ -11,7 +11,14 @@ interface ObjectSchemaLike {
|
|
|
11
11
|
label?: string;
|
|
12
12
|
hidden?: boolean;
|
|
13
13
|
}>;
|
|
14
|
-
/**
|
|
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
|
|
23
|
-
*
|
|
24
|
-
* 2.
|
|
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,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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.4.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.
|
|
34
|
-
"@object-ui/core": "11.
|
|
35
|
-
"@object-ui/i18n": "11.
|
|
36
|
-
"@object-ui/providers": "11.
|
|
37
|
-
"@object-ui/react": "11.
|
|
38
|
-
"@object-ui/types": "11.
|
|
33
|
+
"@object-ui/components": "11.4.0",
|
|
34
|
+
"@object-ui/core": "11.4.0",
|
|
35
|
+
"@object-ui/i18n": "11.4.0",
|
|
36
|
+
"@object-ui/providers": "11.4.0",
|
|
37
|
+
"@object-ui/react": "11.4.0",
|
|
38
|
+
"@object-ui/types": "11.4.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
|
+
});
|
package/src/FieldEditWidget.tsx
CHANGED
|
@@ -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
|
-
|
|
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,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
|
+
});
|