@object-ui/fields 13.2.0 → 14.0.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,150 @@
1
+ import React from 'react';
2
+ import { Combobox, EmptyValue, cn } from '@object-ui/components';
3
+ import { SchemaRendererContext } from '@object-ui/react';
4
+ import type { FieldWidgetProps } from './types';
5
+
6
+ /**
7
+ * RecipientPickerField — dependent record picker for a polymorphic recipient
8
+ * reference (e.g. `sys_sharing_rule.recipient_id`), whose target object is
9
+ * decided by a sibling `recipient_type` select.
10
+ *
11
+ * Reached via the field `widget: 'recipient-picker'` hint (resolves as
12
+ * `field:recipient-picker`). Reads the live `recipient_type` from
13
+ * `dependentValues` (the form record), loads candidate records of the mapped
14
+ * object via `dataSource.find(...)`, and stores the value the sharing evaluator
15
+ * expects for that type:
16
+ *
17
+ * user → sys_user, store `id`
18
+ * team → sys_team, store `id`
19
+ * business_unit → sys_business_unit, store `id`
20
+ * unit_and_subordinates → sys_business_unit, store `id`
21
+ * position → sys_position, store `name` (matched against
22
+ * sys_user_position.position at evaluation time)
23
+ *
24
+ * When `recipient_type` changes after mount the stored id is reset (an id valid
25
+ * for one type is meaningless for another). Unknown types degrade to a plain
26
+ * text input so nothing breaks.
27
+ */
28
+ interface RecipientMapping {
29
+ object: string;
30
+ /** Which record field to persist into recipient_id. */
31
+ storeField: 'id' | 'name';
32
+ /** Candidate display-label fields, in preference order. */
33
+ labelFields: string[];
34
+ }
35
+
36
+ const TYPE_TO_OBJECT: Record<string, RecipientMapping> = {
37
+ user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'] },
38
+ team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'] },
39
+ business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] },
40
+ unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] },
41
+ position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'] },
42
+ };
43
+
44
+ export function RecipientPickerField({
45
+ value,
46
+ onChange,
47
+ readonly,
48
+ className,
49
+ ...props
50
+ }: FieldWidgetProps<string>) {
51
+ const ctx = React.useContext(SchemaRendererContext);
52
+ const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
53
+ const disabled = (props as any).disabled as boolean | undefined;
54
+ const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
55
+ const recipientType = String(dependentValues.recipient_type ?? '');
56
+ const mapping = TYPE_TO_OBJECT[recipientType];
57
+
58
+ const [records, setRecords] = React.useState<any[] | null>(null);
59
+
60
+ // Reset the stored recipient when the type changes AFTER mount (an id for a
61
+ // user is not a valid team/business-unit id). The ref starts null so the
62
+ // initial render of an existing rule never clears its value.
63
+ const prevType = React.useRef<string | null>(null);
64
+ React.useEffect(() => {
65
+ if (prevType.current !== null && prevType.current !== recipientType && value) {
66
+ onChange('' as any);
67
+ }
68
+ prevType.current = recipientType;
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [recipientType]);
71
+
72
+ React.useEffect(() => {
73
+ setRecords(null);
74
+ if (!dataSource || !mapping || typeof dataSource.find !== 'function') return;
75
+ let cancelled = false;
76
+ (async () => {
77
+ try {
78
+ const res = await dataSource.find(mapping.object, { $top: 500, $orderby: 'name asc' });
79
+ const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []);
80
+ if (!cancelled) setRecords(Array.isArray(list) ? list : []);
81
+ } catch {
82
+ if (!cancelled) setRecords([]);
83
+ }
84
+ })();
85
+ return () => {
86
+ cancelled = true;
87
+ };
88
+ }, [dataSource, mapping?.object]);
89
+
90
+ const labelOf = (r: any): string => {
91
+ for (const f of mapping?.labelFields ?? ['name']) if (r?.[f]) return String(r[f]);
92
+ return String(r?.id ?? '');
93
+ };
94
+ const valueOf = (r: any): string => String(r?.[mapping?.storeField ?? 'id'] ?? '');
95
+
96
+ const options = React.useMemo(() => {
97
+ const opts = (records ?? [])
98
+ .map((r) => ({ value: valueOf(r), label: labelOf(r) }))
99
+ .filter((o) => o.value);
100
+ if (value && !opts.some((o) => o.value === value)) opts.unshift({ value, label: value });
101
+ return opts;
102
+ // eslint-disable-next-line react-hooks/exhaustive-deps
103
+ }, [records, value, mapping]);
104
+
105
+ if (!recipientType) {
106
+ return (
107
+ <p className={cn('text-sm text-muted-foreground', className)}>
108
+ Select a recipient type first.
109
+ </p>
110
+ );
111
+ }
112
+
113
+ if (!mapping) {
114
+ // Unknown / unsupported recipient type — keep a plain text input so the
115
+ // field is never un-editable.
116
+ return (
117
+ <input
118
+ className={cn(
119
+ 'w-full rounded border bg-background px-2 py-1 text-sm',
120
+ className,
121
+ )}
122
+ value={value ?? ''}
123
+ disabled={disabled || readonly}
124
+ onChange={(e) => onChange(e.target.value as any)}
125
+ />
126
+ );
127
+ }
128
+
129
+ if (readonly) {
130
+ if (!value) return <EmptyValue />;
131
+ return <span className={className}>{options.find((o) => o.value === value)?.label ?? value}</span>;
132
+ }
133
+
134
+ return (
135
+ <Combobox
136
+ options={options}
137
+ value={value ?? ''}
138
+ onValueChange={(v) => onChange(v as any)}
139
+ placeholder={
140
+ records === null ? 'Loading…' : `Select a ${recipientType.replace(/_/g, ' ')}`
141
+ }
142
+ searchPlaceholder="Search…"
143
+ emptyText={records === null ? 'Loading…' : 'No matches'}
144
+ disabled={disabled}
145
+ className={className}
146
+ />
147
+ );
148
+ }
149
+
150
+ export default RecipientPickerField;