@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.
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
- * Renders options from field metadata with support for placeholder and readonly display
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": "11.4.0",
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": "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"
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.0",
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
- // Determine if date is overdue (in the past) — but only color it red when the
603
- // field is *semantically* a due/deadline. A plain "start_date" or "created_at"
604
- // in the past should not render in red.
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
- return <span className="font-mono text-xs text-gray-600 truncate" title={text}>{text}</span>;
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
- : `${field.label || field.name} is required`;
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 || `Minimum length is ${field.min_length} characters`,
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 || `Maximum length is ${field.max_length} characters`,
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 || `Minimum value is ${field.min}`,
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 || `Maximum value is ${field.max}`,
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 || 'Invalid format',
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: 'Please enter a valid email address',
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: 'Please enter a valid URL',
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
- ComponentRegistry.register('time', createFieldRenderer(TimeField), { namespace: 'field' });
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
- ComponentRegistry.register('address', createFieldRenderer(AddressField), { namespace: 'field' });
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
- * Renders options from field metadata with support for placeholder and readonly display
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({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
19
- const config = (field || (props as any).schema) as SelectFieldMetadata;
20
- const options = config?.options || [];
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 = options.find((o) => o.value === value);
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 (the field metadata has
36
- // no `options`). Surface a legible empty state instead, without needing to
37
- // open the popover. Mirrors the inline form renderer's behaviour
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
- No options available
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
  );