@object-ui/fields 11.4.0 → 13.1.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 +115 -0
- package/README.md +11 -0
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/index.cjs +4 -4
- package/dist/index.d.ts +15 -2
- package/dist/index.js +1555 -1497
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/SelectField.d.ts +10 -3
- package/dist/widgets/openNativePicker.d.ts +11 -0
- package/package.json +8 -8
- package/src/__tests__/DateCellRenderer.test.tsx +50 -0
- package/src/index.tsx +64 -27
- package/src/widgets/DateField.tsx +5 -0
- package/src/widgets/DateTimeField.tsx +5 -0
- package/src/widgets/SelectField.cascade.test.tsx +138 -0
- package/src/widgets/SelectField.tsx +74 -14
- package/src/widgets/TimeField.tsx +5 -0
- package/src/widgets/openNativePicker.ts +18 -0
|
File without changes
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { default as React } from 'react';
|
|
2
2
|
import { FieldWidgetProps } from './types';
|
|
3
3
|
/**
|
|
4
|
-
* SelectField - Dropdown selection widget with configurable options
|
|
5
|
-
*
|
|
4
|
+
* SelectField - Dropdown selection widget with configurable options.
|
|
5
|
+
*
|
|
6
|
+
* Supports cascading / role-gated options (#2284): each option may carry a
|
|
7
|
+
* `visibleWhen` CEL predicate, evaluated against the live form record +
|
|
8
|
+
* `current_user`, so the offered set narrows as a controlling field changes
|
|
9
|
+
* (`record.country == 'cn'`) or by role (`'admin' in current_user.roles`). A
|
|
10
|
+
* field declares which sibling fields drive its list via `dependsOn`; while any
|
|
11
|
+
* of those is empty the control is gated with a "select the parent first" hint,
|
|
12
|
+
* mirroring the dependent-lookup UX.
|
|
6
13
|
*/
|
|
7
|
-
export declare function SelectField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>): React.JSX.Element;
|
|
14
|
+
export declare function SelectField({ value, onChange, field, readonly, schema, dependentValues, dependsOn: dependsOnProp, emptyHint: _emptyHint, dataSource: _dataSource, ...props }: FieldWidgetProps<string>): React.JSX.Element;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open the browser's native picker (date / datetime-local / time) for an input.
|
|
3
|
+
*
|
|
4
|
+
* Native date/time inputs only open their picker when the user clicks the small
|
|
5
|
+
* calendar/clock icon. Calling `showPicker()` on click makes the whole field
|
|
6
|
+
* behave like other widgets — clicking anywhere in the box opens the picker.
|
|
7
|
+
*
|
|
8
|
+
* `showPicker()` can throw (unsupported browser, not user-activated, or the input
|
|
9
|
+
* is disabled/hidden), so failures are swallowed and the native fallback stands.
|
|
10
|
+
*/
|
|
11
|
+
export declare function openNativePicker(input: HTMLInputElement): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "13.1.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": "
|
|
34
|
-
"@object-ui/core": "
|
|
35
|
-
"@object-ui/i18n": "
|
|
36
|
-
"@object-ui/providers": "
|
|
37
|
-
"@object-ui/react": "
|
|
38
|
-
"@object-ui/types": "
|
|
33
|
+
"@object-ui/components": "13.1.0",
|
|
34
|
+
"@object-ui/core": "13.1.0",
|
|
35
|
+
"@object-ui/i18n": "13.1.0",
|
|
36
|
+
"@object-ui/providers": "13.1.0",
|
|
37
|
+
"@object-ui/react": "13.1.0",
|
|
38
|
+
"@object-ui/types": "13.1.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@types/react-dom": "19.2.3",
|
|
47
47
|
"@vitejs/plugin-react": "^6.0.3",
|
|
48
48
|
"typescript": "^6.0.3",
|
|
49
|
-
"vite": "^8.1.
|
|
49
|
+
"vite": "^8.1.3",
|
|
50
50
|
"vite-plugin-dts": "^5.0.3"
|
|
51
51
|
},
|
|
52
52
|
"publishConfig": {
|
|
@@ -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
|
+
});
|
package/src/index.tsx
CHANGED
|
@@ -27,7 +27,7 @@ const lookupNameCache: Map<string, LookupCacheEntry> = new Map();
|
|
|
27
27
|
* Pick the most reasonable display name from an arbitrary record object.
|
|
28
28
|
* Tries common name-like keys in priority order, then falls back to undefined.
|
|
29
29
|
*/
|
|
30
|
-
function pickRecordDisplayName(
|
|
30
|
+
export function pickRecordDisplayName(
|
|
31
31
|
record: Record<string, unknown> | null | undefined,
|
|
32
32
|
preferredField?: string,
|
|
33
33
|
): string | undefined {
|
|
@@ -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
|
|
@@ -597,19 +601,20 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
597
601
|
const safe = coerceToSafeValue(value);
|
|
598
602
|
const dateField = field as any;
|
|
599
603
|
const style = dateField.format || 'relative';
|
|
600
|
-
const formatted = formatDate(safe as string | Date, style);
|
|
601
604
|
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
const date = safe != null ? new Date(safe as string | number) : null;
|
|
606
|
-
const isValidDate = date !== null && !isNaN(date.getTime());
|
|
607
|
-
const startOfToday = new Date();
|
|
608
|
-
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.
|
|
609
608
|
const fieldName = String(dateField?.name || dateField?.accessorKey || dateField?.key || '').toLowerCase();
|
|
610
609
|
const dueLike =
|
|
611
610
|
dateField?.dueLike === true ||
|
|
612
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);
|
|
613
618
|
const isOverdue = dueLike && isValidDate && date! < startOfToday;
|
|
614
619
|
const isoString = isValidDate ? date!.toISOString() : String(safe);
|
|
615
620
|
|
|
@@ -1445,7 +1450,13 @@ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactEleme
|
|
|
1445
1450
|
} else {
|
|
1446
1451
|
text = String(value);
|
|
1447
1452
|
}
|
|
1448
|
-
|
|
1453
|
+
// inline-block + max-w-full so `truncate` (overflow-hidden/ellipsis/nowrap)
|
|
1454
|
+
// actually clamps to the cell width. On a bare inline <span> truncate never
|
|
1455
|
+
// clips — there is no width box — and its `white-space:nowrap` also defeats
|
|
1456
|
+
// the parent cell's `break-words`, so a long name-keyed map / address JSON
|
|
1457
|
+
// spills into the neighbouring column (objectui#2578). The title keeps the
|
|
1458
|
+
// full value on hover.
|
|
1459
|
+
return <span className="block max-w-full font-mono text-xs text-gray-600 truncate" title={text}>{text}</span>;
|
|
1449
1460
|
}
|
|
1450
1461
|
|
|
1451
1462
|
/**
|
|
@@ -1755,25 +1766,36 @@ export function formatFileSize(bytes: number): string {
|
|
|
1755
1766
|
export function buildValidationRules(field: any): any {
|
|
1756
1767
|
const rules: any = {};
|
|
1757
1768
|
|
|
1758
|
-
// Required validation
|
|
1769
|
+
// Required validation. Emit a bare `true` for the auto-generated case rather
|
|
1770
|
+
// than baking an English message here: the form renderer localizes it via
|
|
1771
|
+
// `t('validation.required', { field })`. A field-authored `required_message`
|
|
1772
|
+
// still wins and is passed through verbatim.
|
|
1759
1773
|
if (field.required) {
|
|
1760
|
-
rules.required = typeof field.required_message === 'string'
|
|
1761
|
-
? field.required_message
|
|
1762
|
-
:
|
|
1774
|
+
rules.required = typeof field.required_message === 'string'
|
|
1775
|
+
? field.required_message
|
|
1776
|
+
: true;
|
|
1763
1777
|
}
|
|
1764
1778
|
|
|
1779
|
+
// Standard validation messages are localized by the form renderer, which has
|
|
1780
|
+
// an i18n `t` in scope. We therefore emit `message: undefined` for the
|
|
1781
|
+
// auto-generated case and tag each rule with a `messageKey` (+ any interp
|
|
1782
|
+
// vars); a field-authored `*_message` is a string and passes through as-is,
|
|
1783
|
+
// still winning over the localized default. See form.tsx `localizeRule`.
|
|
1784
|
+
|
|
1765
1785
|
// Length validation for text fields
|
|
1766
1786
|
if (field.min_length) {
|
|
1767
1787
|
rules.minLength = {
|
|
1768
1788
|
value: field.min_length,
|
|
1769
|
-
message: field.min_length_message
|
|
1789
|
+
message: typeof field.min_length_message === 'string' ? field.min_length_message : undefined,
|
|
1790
|
+
messageKey: 'validation.minLength',
|
|
1770
1791
|
};
|
|
1771
1792
|
}
|
|
1772
1793
|
|
|
1773
1794
|
if (field.max_length) {
|
|
1774
1795
|
rules.maxLength = {
|
|
1775
1796
|
value: field.max_length,
|
|
1776
|
-
message: field.max_length_message
|
|
1797
|
+
message: typeof field.max_length_message === 'string' ? field.max_length_message : undefined,
|
|
1798
|
+
messageKey: 'validation.maxLength',
|
|
1777
1799
|
};
|
|
1778
1800
|
}
|
|
1779
1801
|
|
|
@@ -1781,14 +1803,16 @@ export function buildValidationRules(field: any): any {
|
|
|
1781
1803
|
if (field.min !== undefined) {
|
|
1782
1804
|
rules.min = {
|
|
1783
1805
|
value: field.min,
|
|
1784
|
-
message: field.min_message
|
|
1806
|
+
message: typeof field.min_message === 'string' ? field.min_message : undefined,
|
|
1807
|
+
messageKey: 'validation.min',
|
|
1785
1808
|
};
|
|
1786
1809
|
}
|
|
1787
1810
|
|
|
1788
1811
|
if (field.max !== undefined) {
|
|
1789
1812
|
rules.max = {
|
|
1790
1813
|
value: field.max,
|
|
1791
|
-
message: field.max_message
|
|
1814
|
+
message: typeof field.max_message === 'string' ? field.max_message : undefined,
|
|
1815
|
+
messageKey: 'validation.max',
|
|
1792
1816
|
};
|
|
1793
1817
|
}
|
|
1794
1818
|
|
|
@@ -1796,7 +1820,8 @@ export function buildValidationRules(field: any): any {
|
|
|
1796
1820
|
if (field.pattern) {
|
|
1797
1821
|
rules.pattern = {
|
|
1798
1822
|
value: typeof field.pattern === 'string' ? new RegExp(field.pattern) : field.pattern,
|
|
1799
|
-
message: field.pattern_message
|
|
1823
|
+
message: typeof field.pattern_message === 'string' ? field.pattern_message : undefined,
|
|
1824
|
+
messageKey: 'validation.pattern',
|
|
1800
1825
|
};
|
|
1801
1826
|
}
|
|
1802
1827
|
|
|
@@ -1804,7 +1829,8 @@ export function buildValidationRules(field: any): any {
|
|
|
1804
1829
|
if (field.type === 'email') {
|
|
1805
1830
|
rules.pattern = {
|
|
1806
1831
|
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
1807
|
-
message:
|
|
1832
|
+
message: undefined,
|
|
1833
|
+
messageKey: 'validation.email',
|
|
1808
1834
|
};
|
|
1809
1835
|
}
|
|
1810
1836
|
|
|
@@ -1812,7 +1838,8 @@ export function buildValidationRules(field: any): any {
|
|
|
1812
1838
|
if (field.type === 'url') {
|
|
1813
1839
|
rules.pattern = {
|
|
1814
1840
|
value: /^https?:\/\/.+/,
|
|
1815
|
-
message:
|
|
1841
|
+
message: undefined,
|
|
1842
|
+
messageKey: 'validation.url',
|
|
1816
1843
|
};
|
|
1817
1844
|
}
|
|
1818
1845
|
|
|
@@ -2037,6 +2064,12 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([
|
|
|
2037
2064
|
'slider',
|
|
2038
2065
|
// Display renderer owned by `plugin-markdown:markdown`.
|
|
2039
2066
|
'markdown',
|
|
2067
|
+
// No other package owns the bare `time`/`address` key, but `registerField`
|
|
2068
|
+
// wraps each call in a fresh `React.lazy(...)`, so re-registration (HMR,
|
|
2069
|
+
// re-import) fails the registry's identity check every time and logs the
|
|
2070
|
+
// same "bare-name fallback overwritten" warning at every boot regardless.
|
|
2071
|
+
'time',
|
|
2072
|
+
'address',
|
|
2040
2073
|
]);
|
|
2041
2074
|
|
|
2042
2075
|
export function registerField(fieldType: string): void {
|
|
@@ -2093,7 +2126,10 @@ export function registerFields() {
|
|
|
2093
2126
|
ComponentRegistry.register('select', createFieldRenderer(SelectField), { namespace: 'field', skipFallback: true });
|
|
2094
2127
|
ComponentRegistry.register('date', createFieldRenderer(DateField), { namespace: 'field' });
|
|
2095
2128
|
ComponentRegistry.register('datetime', createFieldRenderer(DateTimeField), { namespace: 'field' });
|
|
2096
|
-
|
|
2129
|
+
// Namespaced-only: no other renderer legitimately owns the bare 'time' key,
|
|
2130
|
+
// but the fallback still triggered noisy "bare-name overwritten" warnings on
|
|
2131
|
+
// every re-registration (e.g. hot reload / re-init in a shared registry).
|
|
2132
|
+
ComponentRegistry.register('time', createFieldRenderer(TimeField), { namespace: 'field', skipFallback: true });
|
|
2097
2133
|
|
|
2098
2134
|
// Contact fields - wrapped for documentation compatibility
|
|
2099
2135
|
// `email` collides with the `ui:email` input variant; namespaced-only so the
|
|
@@ -2151,7 +2187,8 @@ export function registerFields() {
|
|
|
2151
2187
|
ComponentRegistry.register('code', createFieldRenderer(CodeField), { namespace: 'field' });
|
|
2152
2188
|
// `avatar` collides with the display avatar widget; namespaced-only.
|
|
2153
2189
|
ComponentRegistry.register('avatar', createFieldRenderer(AvatarField), { namespace: 'field', skipFallback: true });
|
|
2154
|
-
|
|
2190
|
+
// Namespaced-only — see the 'time' registration above for rationale.
|
|
2191
|
+
ComponentRegistry.register('address', createFieldRenderer(AddressField), { namespace: 'field', skipFallback: true });
|
|
2155
2192
|
ComponentRegistry.register('geolocation', createFieldRenderer(GeolocationField), { namespace: 'field' });
|
|
2156
2193
|
ComponentRegistry.register('signature', createFieldRenderer(SignatureField), { namespace: 'field' });
|
|
2157
2194
|
ComponentRegistry.register('qrcode', createFieldRenderer(QRCodeField), { namespace: 'field' });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
|
+
import { openNativePicker } from './openNativePicker';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* DateField - Date picker input widget
|
|
@@ -20,6 +21,10 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
20
21
|
type="date"
|
|
21
22
|
value={value || ''}
|
|
22
23
|
onChange={(e) => onChange(e.target.value)}
|
|
24
|
+
onClick={(e) => {
|
|
25
|
+
openNativePicker(e.currentTarget);
|
|
26
|
+
domProps.onClick?.(e);
|
|
27
|
+
}}
|
|
23
28
|
disabled={readonly || domProps.disabled}
|
|
24
29
|
/>
|
|
25
30
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
|
+
import { openNativePicker } from './openNativePicker';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* DateTimeField - Combined date and time picker widget
|
|
@@ -26,6 +27,10 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
|
|
|
26
27
|
type="datetime-local"
|
|
27
28
|
value={value || ''}
|
|
28
29
|
onChange={(e) => onChange(e.target.value)}
|
|
30
|
+
onClick={(e) => {
|
|
31
|
+
openNativePicker(e.currentTarget);
|
|
32
|
+
domProps.onClick?.(e);
|
|
33
|
+
}}
|
|
29
34
|
disabled={readonly || domProps.disabled}
|
|
30
35
|
/>
|
|
31
36
|
);
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Cascading / role-gated `select` options (#2284).
|
|
11
|
+
*
|
|
12
|
+
* Exercises the observable, non-Radix behaviour of {@link SelectField}: the
|
|
13
|
+
* dependency gate (a "select the parent first" empty-state) and the cascade
|
|
14
|
+
* clear (a value dropped when the offered set no longer includes it). The
|
|
15
|
+
* per-option filtering itself is unit-tested in `@object-ui/core`
|
|
16
|
+
* (`optionRules.test.ts`); here we prove the widget wires `dependentValues` +
|
|
17
|
+
* predicate scope into that resolver.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
20
|
+
import React from 'react';
|
|
21
|
+
import { render, screen } from '@testing-library/react';
|
|
22
|
+
import '@testing-library/jest-dom';
|
|
23
|
+
import { PredicateScopeProvider } from '@object-ui/react';
|
|
24
|
+
import { SelectField } from './SelectField';
|
|
25
|
+
|
|
26
|
+
const provinceField = {
|
|
27
|
+
name: 'province',
|
|
28
|
+
type: 'select',
|
|
29
|
+
dependsOn: 'country',
|
|
30
|
+
options: [
|
|
31
|
+
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
|
|
32
|
+
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
|
|
33
|
+
],
|
|
34
|
+
} as any;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('SelectField — dependency gating (#2284)', () => {
|
|
41
|
+
it('gates with a "select parent first" hint while the controlling field is empty', () => {
|
|
42
|
+
render(
|
|
43
|
+
<SelectField
|
|
44
|
+
value={undefined}
|
|
45
|
+
onChange={vi.fn()}
|
|
46
|
+
field={provinceField}
|
|
47
|
+
{...({ name: 'province', dependentValues: {} } as any)}
|
|
48
|
+
/>,
|
|
49
|
+
);
|
|
50
|
+
const gate = screen.getByTestId('select-empty-province');
|
|
51
|
+
expect(gate).toHaveTextContent(/select country first/i);
|
|
52
|
+
// Gated → no live combobox is offered.
|
|
53
|
+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('unlocks the combobox once the controlling field is set', () => {
|
|
57
|
+
render(
|
|
58
|
+
<SelectField
|
|
59
|
+
value={undefined}
|
|
60
|
+
onChange={vi.fn()}
|
|
61
|
+
field={provinceField}
|
|
62
|
+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
63
|
+
/>,
|
|
64
|
+
);
|
|
65
|
+
expect(screen.queryByTestId('select-empty-province')).not.toBeInTheDocument();
|
|
66
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('SelectField — cascade clear (#2284)', () => {
|
|
71
|
+
it('clears a value the parent change no longer offers', () => {
|
|
72
|
+
const onChange = vi.fn();
|
|
73
|
+
render(
|
|
74
|
+
<SelectField
|
|
75
|
+
value={'ca'}
|
|
76
|
+
onChange={onChange}
|
|
77
|
+
field={provinceField}
|
|
78
|
+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
79
|
+
/>,
|
|
80
|
+
);
|
|
81
|
+
// 'ca' is a US province — under country=cn it is not offered, so it is dropped.
|
|
82
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('keeps a value that is still offered', () => {
|
|
86
|
+
const onChange = vi.fn();
|
|
87
|
+
render(
|
|
88
|
+
<SelectField
|
|
89
|
+
value={'zj'}
|
|
90
|
+
onChange={onChange}
|
|
91
|
+
field={provinceField}
|
|
92
|
+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
93
|
+
/>,
|
|
94
|
+
);
|
|
95
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('SelectField — role / context gating (#2284)', () => {
|
|
100
|
+
const tierField = {
|
|
101
|
+
name: 'tier',
|
|
102
|
+
type: 'select',
|
|
103
|
+
options: [
|
|
104
|
+
{ label: 'Standard', value: 'standard' },
|
|
105
|
+
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.roles" },
|
|
106
|
+
],
|
|
107
|
+
} as any;
|
|
108
|
+
|
|
109
|
+
it('clears an admin-only value for a non-admin (offered set excludes it)', () => {
|
|
110
|
+
const onChange = vi.fn();
|
|
111
|
+
render(
|
|
112
|
+
<PredicateScopeProvider scope={{ current_user: { roles: ['sales'] } }}>
|
|
113
|
+
<SelectField
|
|
114
|
+
value={'admin_only'}
|
|
115
|
+
onChange={onChange}
|
|
116
|
+
field={tierField}
|
|
117
|
+
{...({ name: 'tier', dependentValues: {} } as any)}
|
|
118
|
+
/>
|
|
119
|
+
</PredicateScopeProvider>,
|
|
120
|
+
);
|
|
121
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('keeps an admin-only value for an admin', () => {
|
|
125
|
+
const onChange = vi.fn();
|
|
126
|
+
render(
|
|
127
|
+
<PredicateScopeProvider scope={{ current_user: { roles: ['admin'] } }}>
|
|
128
|
+
<SelectField
|
|
129
|
+
value={'admin_only'}
|
|
130
|
+
onChange={onChange}
|
|
131
|
+
field={tierField}
|
|
132
|
+
{...({ name: 'tier', dependentValues: {} } as any)}
|
|
133
|
+
/>
|
|
134
|
+
</PredicateScopeProvider>,
|
|
135
|
+
);
|
|
136
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useContext, useEffect, useMemo } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
Select,
|
|
4
4
|
SelectContent,
|
|
@@ -7,17 +7,42 @@ import {
|
|
|
7
7
|
SelectValue,
|
|
8
8
|
EmptyValue,
|
|
9
9
|
} from '@object-ui/components';
|
|
10
|
+
import {
|
|
11
|
+
resolveVisibleOptions,
|
|
12
|
+
isOptionGroupGated,
|
|
13
|
+
resolveDependsOnFields,
|
|
14
|
+
isValueStillOffered,
|
|
15
|
+
} from '@object-ui/core';
|
|
16
|
+
import { SchemaRendererContext, usePredicateScope } from '@object-ui/react';
|
|
10
17
|
import { SelectFieldMetadata } from '@object-ui/types';
|
|
11
18
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
12
19
|
import { FieldWidgetProps } from './types';
|
|
13
20
|
|
|
14
21
|
/**
|
|
15
|
-
* SelectField - Dropdown selection widget with configurable options
|
|
16
|
-
*
|
|
22
|
+
* SelectField - Dropdown selection widget with configurable options.
|
|
23
|
+
*
|
|
24
|
+
* Supports cascading / role-gated options (#2284): each option may carry a
|
|
25
|
+
* `visibleWhen` CEL predicate, evaluated against the live form record +
|
|
26
|
+
* `current_user`, so the offered set narrows as a controlling field changes
|
|
27
|
+
* (`record.country == 'cn'`) or by role (`'admin' in current_user.roles`). A
|
|
28
|
+
* field declares which sibling fields drive its list via `dependsOn`; while any
|
|
29
|
+
* of those is empty the control is gated with a "select the parent first" hint,
|
|
30
|
+
* mirroring the dependent-lookup UX.
|
|
17
31
|
*/
|
|
18
|
-
export function SelectField({
|
|
19
|
-
|
|
20
|
-
|
|
32
|
+
export function SelectField({
|
|
33
|
+
value,
|
|
34
|
+
onChange,
|
|
35
|
+
field,
|
|
36
|
+
readonly,
|
|
37
|
+
schema,
|
|
38
|
+
dependentValues,
|
|
39
|
+
dependsOn: dependsOnProp,
|
|
40
|
+
emptyHint: _emptyHint,
|
|
41
|
+
dataSource: _dataSource,
|
|
42
|
+
...props
|
|
43
|
+
}: FieldWidgetProps<string>) {
|
|
44
|
+
const config = (field || schema) as SelectFieldMetadata;
|
|
45
|
+
const rawOptions = config?.options || [];
|
|
21
46
|
const { t } = useFieldTranslation();
|
|
22
47
|
// Stable hook for automation/e2e — react-hook-form + Radix Select cannot be
|
|
23
48
|
// driven by synthetic DOM events, so e2e must target the trigger/options by a
|
|
@@ -25,32 +50,67 @@ export function SelectField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
25
50
|
// react-hook-form field name spread in by the form renderer (FormField).
|
|
26
51
|
const fieldName = (props as any).name || (config as any)?.name || props.id || '';
|
|
27
52
|
|
|
53
|
+
// Live form values for cascading options — injected by the form renderer as
|
|
54
|
+
// `dependentValues` (same channel dependent lookups use), falling back to the
|
|
55
|
+
// record on SchemaRendererContext. `current_user` etc. come from the global
|
|
56
|
+
// predicate scope so role/context predicates resolve too.
|
|
57
|
+
const ctx = useContext(SchemaRendererContext) as any;
|
|
58
|
+
const record = useMemo<Record<string, unknown>>(() => {
|
|
59
|
+
return (dependentValues ?? ctx?.formValues ?? ctx?.data ?? {}) as Record<string, unknown>;
|
|
60
|
+
}, [dependentValues, ctx?.formValues, ctx?.data]);
|
|
61
|
+
const predicateScope = usePredicateScope();
|
|
62
|
+
|
|
63
|
+
const dependsOn = (config as any)?.dependsOn ?? dependsOnProp;
|
|
64
|
+
const dependsOnFields = useMemo(() => resolveDependsOnFields(dependsOn), [dependsOn]);
|
|
65
|
+
const gated = useMemo(
|
|
66
|
+
() => dependsOnFields.length > 0 && isOptionGroupGated(dependsOn, record),
|
|
67
|
+
[dependsOnFields, dependsOn, record],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Effective (offered) options after per-option `visibleWhen` filtering. Empty
|
|
71
|
+
// while gated so we never present an unfiltered set before the parent is set.
|
|
72
|
+
const options = useMemo(
|
|
73
|
+
() => (gated ? [] : resolveVisibleOptions(rawOptions, record, predicateScope)),
|
|
74
|
+
[gated, rawOptions, record, predicateScope],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Cascade clear: once the offered set no longer includes the current value
|
|
78
|
+
// (parent changed / predicate flipped), drop it so no stale pair persists.
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (readonly) return;
|
|
81
|
+
if (value === undefined || value === null || (value as unknown) === '') return;
|
|
82
|
+
if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
|
|
83
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
84
|
+
}, [options, gated]);
|
|
85
|
+
|
|
28
86
|
if (readonly) {
|
|
29
|
-
const option =
|
|
87
|
+
const option = rawOptions.find((o) => o.value === value);
|
|
30
88
|
const display = option?.label || value;
|
|
31
89
|
return display ? <span className="text-sm">{display}</span> : <EmptyValue />;
|
|
32
90
|
}
|
|
33
91
|
|
|
34
92
|
// A select with no options is unfillable — a silently-empty Radix dropdown
|
|
35
|
-
// reads as "broken widget" and hides the real cause
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// (see plugin form `renderFieldComponent`'s `case 'select'`).
|
|
93
|
+
// reads as "broken widget" and hides the real cause. Surface a legible state:
|
|
94
|
+
// a dependency-gated list prompts for its controlling field; an unconfigured
|
|
95
|
+
// list says so. Mirrors the inline form renderer's behaviour.
|
|
39
96
|
if (options.length === 0) {
|
|
97
|
+
const hint = gated
|
|
98
|
+
? `Select ${dependsOnFields.join(' / ')} first`
|
|
99
|
+
: 'No options available';
|
|
40
100
|
return (
|
|
41
101
|
<div
|
|
42
102
|
data-testid={fieldName ? `select-empty-${fieldName}` : undefined}
|
|
43
103
|
className="flex h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
|
|
44
104
|
>
|
|
45
|
-
|
|
105
|
+
{hint}
|
|
46
106
|
</div>
|
|
47
107
|
);
|
|
48
108
|
}
|
|
49
109
|
|
|
50
110
|
return (
|
|
51
|
-
<Select
|
|
111
|
+
<Select
|
|
52
112
|
{...props}
|
|
53
|
-
value={value}
|
|
113
|
+
value={value}
|
|
54
114
|
onValueChange={onChange}
|
|
55
115
|
disabled={readonly || props.disabled}
|
|
56
116
|
>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
|
+
import { openNativePicker } from './openNativePicker';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* TimeField - Time picker input widget
|
|
@@ -20,6 +21,10 @@ export function TimeField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
20
21
|
type="time"
|
|
21
22
|
value={value || ''}
|
|
22
23
|
onChange={(e) => onChange(e.target.value)}
|
|
24
|
+
onClick={(e) => {
|
|
25
|
+
openNativePicker(e.currentTarget);
|
|
26
|
+
domProps.onClick?.(e);
|
|
27
|
+
}}
|
|
23
28
|
disabled={readonly || domProps.disabled}
|
|
24
29
|
/>
|
|
25
30
|
);
|