@object-ui/fields 11.5.0 → 13.2.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.
@@ -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
  );
@@ -0,0 +1,18 @@
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 function openNativePicker(input: HTMLInputElement): void {
12
+ if (input.disabled || input.readOnly) return;
13
+ try {
14
+ (input as HTMLInputElement & { showPicker?: () => void }).showPicker?.();
15
+ } catch {
16
+ // Ignore — browsers throw when the picker can't be shown; the icon still works.
17
+ }
18
+ }