-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathrecord-picker.tsx
More file actions
190 lines (176 loc) · 6.5 KB
/
Copy pathrecord-picker.tsx
File metadata and controls
190 lines (176 loc) · 6.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* element:record_picker — an interactive element that lets the user pick one
* record of an object and writes the selection into a page variable.
*
* Data binding follows the spec's ElementDataSource (`schema.dataSource`):
* { object, filter?, sort?, limit? }
* with `properties.object` accepted as a fallback. Display config is read off
* `schema.properties`:
* { labelField='name', valueField='id', label?, placeholder?, emptyText? }
*
* The selection is written through `usePageVariableBinding(schema.id)`: the
* page variable whose `source` equals this picker's id receives the selected
* record's `valueField` (default the record id). With no bound variable the
* picker is uncontrolled (still usable, just inert) so it never throws outside
* a Page. The written value drives any predicate referencing `page.<var>`
* (e.g. another component's `visible` / `visibility`).
*/
import * as React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { useAdapter, usePageVariableBinding } from '@object-ui/react';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '../../ui';
import { cn } from '../../lib/utils';
function readProps<T extends Record<string, any>>(schema: any): T {
// Per spec, element components carry their config in `schema.properties`.
// Tolerate `schema.props` (legacy alias) so JSON written either way works.
const fromProperties = (schema?.properties ?? {}) as T;
const fromProps = (schema?.props ?? {}) as T;
return { ...fromProps, ...fromProperties };
}
function toText(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
if (typeof v === 'object') {
const o = v as Record<string, any>;
return String(o.label ?? o.name ?? o.title ?? o.en ?? '');
}
return String(v);
}
function ElementRecordPickerRenderer({ schema }: { schema: any }) {
const props = readProps<{
object?: string;
labelField?: string;
valueField?: string;
label?: unknown;
placeholder?: string;
emptyText?: string;
filter?: unknown;
sort?: any;
limit?: number;
}>(schema);
// Per-element data binding (ElementDataSourceSchema) takes precedence over the
// flat `properties.object` shorthand.
const ds = (schema?.dataSource ?? {}) as {
object?: string;
filter?: unknown;
sort?: unknown;
limit?: number;
};
const object = ds.object ?? props.object;
const filter = ds.filter ?? props.filter;
const sort = ds.sort ?? props.sort;
const limit = ds.limit ?? props.limit ?? 50;
const labelField = props.labelField ?? 'name';
const valueField = props.valueField ?? 'id';
const adapter = useAdapter() as any;
const binding = usePageVariableBinding(schema?.id);
const [rows, setRows] = React.useState<any[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const filterKey = React.useMemo(() => (filter ? JSON.stringify(filter) : ''), [filter]);
React.useEffect(() => {
let cancelled = false;
if (!adapter || !object || typeof adapter.find !== 'function') {
setLoading(false);
return;
}
setLoading(true);
setError(null);
(async () => {
try {
const query: any = {};
if (filter) query.$filter = filter;
if (sort) query.$orderby = sort;
if (limit) query.$top = limit;
const res = await adapter.find(object, query);
const data: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []);
if (!cancelled) setRows(data);
} catch (e: any) {
if (!cancelled) setError(e?.message ?? 'Failed to load');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adapter, object, filterKey, limit]);
// Reflect the bound variable's value back into the control. When a variable
// targets this picker we stay controlled for its whole lifetime (empty string
// = no selection) so React never warns about an uncontrolled->controlled switch
// once the first value lands. With no binding the picker is uncontrolled and
// Radix manages its own state. shadcn Select keys on exact string values, so
// coerce the id to a string.
const current = binding?.value;
const value = binding ? String(current ?? '') : undefined;
const handleChange = React.useCallback(
(next: string) => {
binding?.setValue(next);
},
[binding],
);
const label = toText(props.label);
const placeholder = props.placeholder ?? 'Select a record…';
return (
<div
className={cn('space-y-1.5', schema?.className)}
data-testid="record-picker"
data-picker-id={schema?.id}
>
{label && <label className="text-sm font-medium text-foreground">{label}</label>}
<Select
value={value}
onValueChange={handleChange}
disabled={loading || !!error || !object}
>
<SelectTrigger className="w-full max-w-xs" data-testid="record-picker-trigger">
<SelectValue
placeholder={loading ? 'Loading…' : error ? 'Failed to load' : placeholder}
/>
</SelectTrigger>
<SelectContent>
{rows.map((row, i) => {
const v = row?.[valueField];
const key = v == null ? String(i) : String(v);
return (
<SelectItem key={key} value={key}>
{toText(row?.[labelField]) || key}
</SelectItem>
);
})}
</SelectContent>
</Select>
{!loading && !error && rows.length === 0 && (
<p className="text-xs text-muted-foreground">{props.emptyText ?? 'No records'}</p>
)}
</div>
);
}
ComponentRegistry.register('record_picker', ElementRecordPickerRenderer, {
namespace: 'element',
skipFallback: true,
label: 'Record Picker',
category: 'input',
inputs: [
{ name: 'object', type: 'string', label: 'Object' },
{ name: 'labelField', type: 'string', label: 'Label Field' },
{ name: 'valueField', type: 'string', label: 'Value Field' },
{ name: 'placeholder', type: 'string', label: 'Placeholder' },
{ name: 'label', type: 'string', label: 'Label' },
],
});
export { ElementRecordPickerRenderer };