@devcustrom/strapi-plugin-api-select 1.2.0 → 1.3.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/README.md +1 -1
- package/admin/src/plugin/ApiSelectInput.jsx +275 -171
- package/admin/src/plugin/test.jsx +817 -0
- package/package.json +1 -1
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Field,
|
|
4
|
+
Loader,
|
|
5
|
+
Box,
|
|
6
|
+
Typography,
|
|
7
|
+
Flex,
|
|
8
|
+
TextInput,
|
|
9
|
+
Tag,
|
|
10
|
+
IconButton,
|
|
11
|
+
} from "@strapi/design-system";
|
|
12
|
+
import { Cross, Search, CaretDown, CheckCircle } from "@strapi/icons";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const getNestedValue = (obj, path) => {
|
|
19
|
+
if (!path) return obj;
|
|
20
|
+
return path.split(".").reduce((current, key) => {
|
|
21
|
+
if (current === null || current === undefined) return null;
|
|
22
|
+
return current[key] !== undefined ? current[key] : null;
|
|
23
|
+
}, obj);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const toStringValue = (val) => {
|
|
27
|
+
if (val === null || val === undefined) return "";
|
|
28
|
+
if (typeof val === "object") return JSON.stringify(val);
|
|
29
|
+
return String(val);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const mapResponseData = (item, mapping, fallbackLabelKey, fallbackValueKey) => {
|
|
33
|
+
let mapped = { label: "", value: "", iconUrl: null, item };
|
|
34
|
+
|
|
35
|
+
if (mapping && typeof mapping === "object") {
|
|
36
|
+
mapped.label =
|
|
37
|
+
getNestedValue(item, mapping.label || mapping.text || mapping.name) ||
|
|
38
|
+
getNestedValue(item, fallbackLabelKey) ||
|
|
39
|
+
item.label || item.name || item.text || "Unknown";
|
|
40
|
+
|
|
41
|
+
mapped.value =
|
|
42
|
+
getNestedValue(item, mapping.value || mapping.id) ||
|
|
43
|
+
getNestedValue(item, fallbackValueKey) ||
|
|
44
|
+
item.value || item.id || mapped.label;
|
|
45
|
+
|
|
46
|
+
Object.keys(mapping).forEach((key) => {
|
|
47
|
+
if (key !== "label" && key !== "value") {
|
|
48
|
+
mapped[key] = getNestedValue(item, mapping[key]);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
} else {
|
|
52
|
+
mapped.label =
|
|
53
|
+
getNestedValue(item, fallbackLabelKey) ||
|
|
54
|
+
item.label || item.name || item.text || item.title ||
|
|
55
|
+
item.displayName || item.description || "Unknown Option";
|
|
56
|
+
|
|
57
|
+
mapped.value =
|
|
58
|
+
getNestedValue(item, fallbackValueKey) ||
|
|
59
|
+
item.value || item.id || item.identifier || item.uuid || mapped.label;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Always pull iconUrl from the raw item if present
|
|
63
|
+
mapped.iconUrl = item.iconUrl || item.icon_url || item.imageUrl || null;
|
|
64
|
+
|
|
65
|
+
if (!mapped.label || typeof mapped.label !== "string") {
|
|
66
|
+
mapped.label = `Option ${mapped.value || "Unknown"}`;
|
|
67
|
+
}
|
|
68
|
+
if (!mapped.value) {
|
|
69
|
+
mapped.value = mapped.label || `option_${Date.now()}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
mapped.value = toStringValue(mapped.value);
|
|
73
|
+
return mapped;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// SVG Sprite Icon
|
|
78
|
+
// Handles URLs in format: "https://host/sprite.svg#iconname"
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const SpriteIcon = ({ iconUrl, size = 18 }) => {
|
|
82
|
+
if (!iconUrl) return null;
|
|
83
|
+
const hashIdx = iconUrl.lastIndexOf("#");
|
|
84
|
+
const spriteFile = hashIdx !== -1 ? iconUrl.slice(0, hashIdx) : iconUrl;
|
|
85
|
+
const iconId = hashIdx !== -1 ? iconUrl.slice(hashIdx) : "";
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<svg
|
|
89
|
+
width={size}
|
|
90
|
+
height={size}
|
|
91
|
+
viewBox="0 0 24 24"
|
|
92
|
+
focusable="false"
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
style={{ flexShrink: 0, display: "block" }}
|
|
95
|
+
>
|
|
96
|
+
<use href={`${spriteFile}${iconId}`} />
|
|
97
|
+
</svg>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Inline styles
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
const styles = {
|
|
106
|
+
wrapper: {
|
|
107
|
+
position: "relative",
|
|
108
|
+
},
|
|
109
|
+
trigger: (disabled, open) => ({
|
|
110
|
+
display: "flex",
|
|
111
|
+
alignItems: "center",
|
|
112
|
+
justifyContent: "space-between",
|
|
113
|
+
gap: 8,
|
|
114
|
+
minHeight: 40,
|
|
115
|
+
padding: "0 12px",
|
|
116
|
+
border: `1px solid ${open ? "var(--strapi-colors-primary600)" : "var(--strapi-colors-neutral200)"}`,
|
|
117
|
+
borderRadius: "var(--strapi-radii-sm, 4px)",
|
|
118
|
+
background: disabled
|
|
119
|
+
? "var(--strapi-colors-neutral150)"
|
|
120
|
+
: "var(--strapi-colors-neutral0)",
|
|
121
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
122
|
+
outline: "none",
|
|
123
|
+
width: "100%",
|
|
124
|
+
boxSizing: "border-box",
|
|
125
|
+
boxShadow: open ? "0 0 0 2px var(--strapi-colors-primary200)" : "none",
|
|
126
|
+
transition: "border-color 0.15s, box-shadow 0.15s",
|
|
127
|
+
}),
|
|
128
|
+
dropdown: {
|
|
129
|
+
position: "absolute",
|
|
130
|
+
top: "calc(100% + 4px)",
|
|
131
|
+
left: 0,
|
|
132
|
+
right: 0,
|
|
133
|
+
zIndex: 999,
|
|
134
|
+
background: "var(--strapi-colors-neutral0)",
|
|
135
|
+
border: "1px solid var(--strapi-colors-neutral200)",
|
|
136
|
+
borderRadius: "var(--strapi-radii-sm, 4px)",
|
|
137
|
+
boxShadow: "0 4px 16px rgba(0,0,0,0.12)",
|
|
138
|
+
overflow: "hidden",
|
|
139
|
+
},
|
|
140
|
+
searchBox: {
|
|
141
|
+
padding: "8px",
|
|
142
|
+
borderBottom: "1px solid var(--strapi-colors-neutral150)",
|
|
143
|
+
},
|
|
144
|
+
optionsList: {
|
|
145
|
+
maxHeight: 260,
|
|
146
|
+
overflowY: "auto",
|
|
147
|
+
},
|
|
148
|
+
option: (isSelected, isFocused) => ({
|
|
149
|
+
display: "flex",
|
|
150
|
+
alignItems: "center",
|
|
151
|
+
gap: 8,
|
|
152
|
+
padding: "8px 12px",
|
|
153
|
+
cursor: "pointer",
|
|
154
|
+
background: isSelected
|
|
155
|
+
? "var(--strapi-colors-primary100)"
|
|
156
|
+
: isFocused
|
|
157
|
+
? "var(--strapi-colors-neutral100)"
|
|
158
|
+
: "transparent",
|
|
159
|
+
borderLeft: isSelected
|
|
160
|
+
? "2px solid var(--strapi-colors-primary600)"
|
|
161
|
+
: "2px solid transparent",
|
|
162
|
+
transition: "background 0.1s",
|
|
163
|
+
}),
|
|
164
|
+
optionLabel: (isSelected) => ({
|
|
165
|
+
flex: 1,
|
|
166
|
+
fontSize: 14,
|
|
167
|
+
color: isSelected
|
|
168
|
+
? "var(--strapi-colors-primary700)"
|
|
169
|
+
: "var(--strapi-colors-neutral800)",
|
|
170
|
+
fontWeight: isSelected ? 600 : 400,
|
|
171
|
+
}),
|
|
172
|
+
placeholder: {
|
|
173
|
+
color: "var(--strapi-colors-neutral500)",
|
|
174
|
+
fontSize: 14,
|
|
175
|
+
},
|
|
176
|
+
selectedLabel: {
|
|
177
|
+
fontSize: 14,
|
|
178
|
+
color: "var(--strapi-colors-neutral800)",
|
|
179
|
+
flex: 1,
|
|
180
|
+
textAlign: "left",
|
|
181
|
+
overflow: "hidden",
|
|
182
|
+
textOverflow: "ellipsis",
|
|
183
|
+
whiteSpace: "nowrap",
|
|
184
|
+
},
|
|
185
|
+
tagsWrap: {
|
|
186
|
+
display: "flex",
|
|
187
|
+
flexWrap: "wrap",
|
|
188
|
+
gap: 4,
|
|
189
|
+
padding: "4px 0",
|
|
190
|
+
flex: 1,
|
|
191
|
+
minWidth: 0,
|
|
192
|
+
},
|
|
193
|
+
controls: {
|
|
194
|
+
display: "flex",
|
|
195
|
+
alignItems: "center",
|
|
196
|
+
gap: 2,
|
|
197
|
+
flexShrink: 0,
|
|
198
|
+
},
|
|
199
|
+
checkMark: {
|
|
200
|
+
color: "var(--strapi-colors-primary600)",
|
|
201
|
+
flexShrink: 0,
|
|
202
|
+
display: "flex",
|
|
203
|
+
alignItems: "center",
|
|
204
|
+
},
|
|
205
|
+
emptyMsg: {
|
|
206
|
+
padding: "16px 12px",
|
|
207
|
+
textAlign: "center",
|
|
208
|
+
color: "var(--strapi-colors-neutral500)",
|
|
209
|
+
fontSize: 13,
|
|
210
|
+
},
|
|
211
|
+
caretIcon: (open) => ({
|
|
212
|
+
color: "var(--strapi-colors-neutral500)",
|
|
213
|
+
width: 16,
|
|
214
|
+
height: 16,
|
|
215
|
+
transform: open ? "rotate(180deg)" : "none",
|
|
216
|
+
transition: "transform 0.2s",
|
|
217
|
+
display: "block",
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// IconSelect — custom combobox with search + SVG sprite icons
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
const IconSelect = ({
|
|
226
|
+
options,
|
|
227
|
+
value, // string (single) | string[] (multi)
|
|
228
|
+
onChange,
|
|
229
|
+
onClear,
|
|
230
|
+
placeholder,
|
|
231
|
+
disabled,
|
|
232
|
+
isMulti,
|
|
233
|
+
forwardedRef,
|
|
234
|
+
}) => {
|
|
235
|
+
const [open, setOpen] = useState(false);
|
|
236
|
+
const [search, setSearch] = useState("");
|
|
237
|
+
const [focusedIdx, setFocusedIdx] = useState(-1);
|
|
238
|
+
const wrapperRef = useRef(null);
|
|
239
|
+
const searchRef = useRef(null);
|
|
240
|
+
const listRef = useRef(null);
|
|
241
|
+
|
|
242
|
+
// Close on outside click
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
const handler = (e) => {
|
|
245
|
+
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
|
246
|
+
setOpen(false);
|
|
247
|
+
setSearch("");
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
document.addEventListener("mousedown", handler);
|
|
251
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
252
|
+
}, []);
|
|
253
|
+
|
|
254
|
+
// Focus search input when dropdown opens
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (open) {
|
|
257
|
+
setTimeout(() => searchRef.current?.focus(), 50);
|
|
258
|
+
setFocusedIdx(-1);
|
|
259
|
+
}
|
|
260
|
+
}, [open]);
|
|
261
|
+
|
|
262
|
+
const filtered = useMemo(() => {
|
|
263
|
+
if (!search.trim()) return options;
|
|
264
|
+
const q = search.toLowerCase();
|
|
265
|
+
return options.filter((o) => o.label.toLowerCase().includes(q));
|
|
266
|
+
}, [options, search]);
|
|
267
|
+
|
|
268
|
+
const isSelected = useCallback(
|
|
269
|
+
(optVal) => {
|
|
270
|
+
if (isMulti) return Array.isArray(value) && value.includes(optVal);
|
|
271
|
+
return value === optVal;
|
|
272
|
+
},
|
|
273
|
+
[value, isMulti]
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const handleToggle = (optVal) => {
|
|
277
|
+
if (isMulti) {
|
|
278
|
+
const current = Array.isArray(value) ? value : [];
|
|
279
|
+
const next = current.includes(optVal)
|
|
280
|
+
? current.filter((v) => v !== optVal)
|
|
281
|
+
: [...current, optVal];
|
|
282
|
+
onChange(next);
|
|
283
|
+
// Keep dropdown open for multi-select
|
|
284
|
+
} else {
|
|
285
|
+
onChange(optVal);
|
|
286
|
+
setOpen(false);
|
|
287
|
+
setSearch("");
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const handleRemoveTag = (optVal, e) => {
|
|
292
|
+
e.stopPropagation();
|
|
293
|
+
const next = (Array.isArray(value) ? value : []).filter((v) => v !== optVal);
|
|
294
|
+
onChange(next);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const handleKeyDown = (e) => {
|
|
298
|
+
if (!open) {
|
|
299
|
+
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
|
300
|
+
e.preventDefault();
|
|
301
|
+
setOpen(true);
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (e.key === "Escape") {
|
|
306
|
+
setOpen(false);
|
|
307
|
+
setSearch("");
|
|
308
|
+
} else if (e.key === "ArrowDown") {
|
|
309
|
+
e.preventDefault();
|
|
310
|
+
setFocusedIdx((i) => Math.min(i + 1, filtered.length - 1));
|
|
311
|
+
} else if (e.key === "ArrowUp") {
|
|
312
|
+
e.preventDefault();
|
|
313
|
+
setFocusedIdx((i) => Math.max(i - 1, 0));
|
|
314
|
+
} else if (e.key === "Enter" && focusedIdx >= 0) {
|
|
315
|
+
e.preventDefault();
|
|
316
|
+
handleToggle(filtered[focusedIdx].value);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// Scroll focused item into view
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
if (focusedIdx >= 0 && listRef.current) {
|
|
323
|
+
const el = listRef.current.children[focusedIdx];
|
|
324
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
325
|
+
}
|
|
326
|
+
}, [focusedIdx]);
|
|
327
|
+
|
|
328
|
+
const optionByValue = useCallback(
|
|
329
|
+
(val) => options.find((o) => o.value === val),
|
|
330
|
+
[options]
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const hasValue = isMulti
|
|
334
|
+
? Array.isArray(value) && value.length > 0
|
|
335
|
+
: Boolean(value);
|
|
336
|
+
|
|
337
|
+
// ---- Trigger content ----
|
|
338
|
+
const renderTriggerContent = () => {
|
|
339
|
+
if (isMulti) {
|
|
340
|
+
const selected = Array.isArray(value) ? value : [];
|
|
341
|
+
if (selected.length === 0) {
|
|
342
|
+
return <span style={styles.placeholder}>{placeholder}</span>;
|
|
343
|
+
}
|
|
344
|
+
return (
|
|
345
|
+
<div style={styles.tagsWrap}>
|
|
346
|
+
{selected.map((val) => {
|
|
347
|
+
const opt = optionByValue(val);
|
|
348
|
+
return (
|
|
349
|
+
<Tag
|
|
350
|
+
key={val}
|
|
351
|
+
icon={
|
|
352
|
+
opt?.iconUrl
|
|
353
|
+
? <SpriteIcon iconUrl={opt.iconUrl} size={14} />
|
|
354
|
+
: undefined
|
|
355
|
+
}
|
|
356
|
+
onClick={(e) => handleRemoveTag(val, e)}
|
|
357
|
+
>
|
|
358
|
+
{opt?.label || val}
|
|
359
|
+
</Tag>
|
|
360
|
+
);
|
|
361
|
+
})}
|
|
362
|
+
</div>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const opt = optionByValue(value);
|
|
367
|
+
if (!opt && !value) {
|
|
368
|
+
return <span style={styles.placeholder}>{placeholder}</span>;
|
|
369
|
+
}
|
|
370
|
+
return (
|
|
371
|
+
<Flex gap={2} alignItems="center" style={{ flex: 1, minWidth: 0 }}>
|
|
372
|
+
{opt?.iconUrl && <SpriteIcon iconUrl={opt.iconUrl} size={18} />}
|
|
373
|
+
<span style={styles.selectedLabel}>{opt?.label || value}</span>
|
|
374
|
+
</Flex>
|
|
375
|
+
);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
return (
|
|
379
|
+
<div ref={wrapperRef} style={styles.wrapper}>
|
|
380
|
+
{/* Trigger button */}
|
|
381
|
+
<div
|
|
382
|
+
ref={forwardedRef}
|
|
383
|
+
role="combobox"
|
|
384
|
+
aria-expanded={open}
|
|
385
|
+
aria-haspopup="listbox"
|
|
386
|
+
tabIndex={disabled ? -1 : 0}
|
|
387
|
+
style={styles.trigger(disabled, open)}
|
|
388
|
+
onClick={() => !disabled && setOpen((v) => !v)}
|
|
389
|
+
onKeyDown={handleKeyDown}
|
|
390
|
+
>
|
|
391
|
+
{renderTriggerContent()}
|
|
392
|
+
|
|
393
|
+
<div style={styles.controls}>
|
|
394
|
+
{hasValue && onClear && (
|
|
395
|
+
<IconButton
|
|
396
|
+
label="Clear"
|
|
397
|
+
size="S"
|
|
398
|
+
variant="ghost"
|
|
399
|
+
onClick={(e) => {
|
|
400
|
+
e.stopPropagation();
|
|
401
|
+
onClear();
|
|
402
|
+
}}
|
|
403
|
+
>
|
|
404
|
+
<Cross />
|
|
405
|
+
</IconButton>
|
|
406
|
+
)}
|
|
407
|
+
<CaretDown style={styles.caretIcon(open)} />
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
{/* Dropdown */}
|
|
412
|
+
{open && (
|
|
413
|
+
<div style={styles.dropdown} role="listbox" aria-multiselectable={isMulti}>
|
|
414
|
+
{/* Search input */}
|
|
415
|
+
<div style={styles.searchBox}>
|
|
416
|
+
<TextInput
|
|
417
|
+
ref={searchRef}
|
|
418
|
+
placeholder="Search..."
|
|
419
|
+
value={search}
|
|
420
|
+
onChange={(e) => {
|
|
421
|
+
setSearch(e.target.value);
|
|
422
|
+
setFocusedIdx(-1);
|
|
423
|
+
}}
|
|
424
|
+
onKeyDown={handleKeyDown}
|
|
425
|
+
startAction={
|
|
426
|
+
<Search
|
|
427
|
+
style={{
|
|
428
|
+
color: "var(--strapi-colors-neutral400)",
|
|
429
|
+
width: 16,
|
|
430
|
+
height: 16,
|
|
431
|
+
}}
|
|
432
|
+
/>
|
|
433
|
+
}
|
|
434
|
+
size="S"
|
|
435
|
+
/>
|
|
436
|
+
</div>
|
|
437
|
+
|
|
438
|
+
{/* Options list */}
|
|
439
|
+
<div style={styles.optionsList} ref={listRef}>
|
|
440
|
+
{filtered.length === 0 ? (
|
|
441
|
+
<div style={styles.emptyMsg}>No options found</div>
|
|
442
|
+
) : (
|
|
443
|
+
filtered.map((option, idx) => {
|
|
444
|
+
const selected = isSelected(option.value);
|
|
445
|
+
const focused = idx === focusedIdx;
|
|
446
|
+
return (
|
|
447
|
+
<div
|
|
448
|
+
key={option.value || idx}
|
|
449
|
+
role="option"
|
|
450
|
+
aria-selected={selected}
|
|
451
|
+
style={styles.option(selected, focused)}
|
|
452
|
+
onMouseEnter={() => setFocusedIdx(idx)}
|
|
453
|
+
onMouseDown={(e) => {
|
|
454
|
+
// preventDefault keeps focus on the search input
|
|
455
|
+
e.preventDefault();
|
|
456
|
+
handleToggle(option.value);
|
|
457
|
+
}}
|
|
458
|
+
>
|
|
459
|
+
{option.iconUrl && (
|
|
460
|
+
<SpriteIcon iconUrl={option.iconUrl} size={20} />
|
|
461
|
+
)}
|
|
462
|
+
<span style={styles.optionLabel(selected)}>
|
|
463
|
+
{option.label}
|
|
464
|
+
</span>
|
|
465
|
+
{selected && (
|
|
466
|
+
<span style={styles.checkMark}>
|
|
467
|
+
<CheckCircle style={{ width: 16, height: 16 }} />
|
|
468
|
+
</span>
|
|
469
|
+
)}
|
|
470
|
+
</div>
|
|
471
|
+
);
|
|
472
|
+
})
|
|
473
|
+
)}
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
)}
|
|
477
|
+
</div>
|
|
478
|
+
);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
// Main component
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
const ApiSelectInput = ({
|
|
486
|
+
name,
|
|
487
|
+
value,
|
|
488
|
+
onChange,
|
|
489
|
+
attribute,
|
|
490
|
+
placeholder,
|
|
491
|
+
error,
|
|
492
|
+
required,
|
|
493
|
+
disabled,
|
|
494
|
+
label,
|
|
495
|
+
description,
|
|
496
|
+
intlLabel,
|
|
497
|
+
forwardedRef,
|
|
498
|
+
...props
|
|
499
|
+
}) => {
|
|
500
|
+
const [options, setOptions] = useState([]);
|
|
501
|
+
const [loading, setLoading] = useState(false);
|
|
502
|
+
const [fetchError, setFetchError] = useState(null);
|
|
503
|
+
|
|
504
|
+
const configValues = useMemo(() => {
|
|
505
|
+
const config = attribute?.options || {};
|
|
506
|
+
return config.options || config;
|
|
507
|
+
}, [attribute]);
|
|
508
|
+
|
|
509
|
+
const {
|
|
510
|
+
optionsApi,
|
|
511
|
+
optionLabelKey = "name",
|
|
512
|
+
optionValueKey = "id",
|
|
513
|
+
selectMode = "single",
|
|
514
|
+
authMode = "public",
|
|
515
|
+
placeholder: configPlaceholder,
|
|
516
|
+
httpMethod = "GET",
|
|
517
|
+
requestPayload = "",
|
|
518
|
+
customHeaders = "",
|
|
519
|
+
responseDataPath = "",
|
|
520
|
+
responseMappingConfig = "",
|
|
521
|
+
} = configValues;
|
|
522
|
+
|
|
523
|
+
const getAuthToken = () => {
|
|
524
|
+
try {
|
|
525
|
+
return (
|
|
526
|
+
localStorage.getItem("jwtToken")?.replaceAll('"', "") ||
|
|
527
|
+
localStorage.getItem("strapi_jwt")?.replaceAll('"', "") ||
|
|
528
|
+
localStorage.getItem("token")?.replaceAll('"', "")
|
|
529
|
+
);
|
|
530
|
+
} catch {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
useEffect(() => {
|
|
536
|
+
if (!optionsApi) return;
|
|
537
|
+
const controller = new AbortController();
|
|
538
|
+
|
|
539
|
+
const fetchOptions = async () => {
|
|
540
|
+
setLoading(true);
|
|
541
|
+
setFetchError(null);
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
let url = optionsApi;
|
|
545
|
+
const token = getAuthToken();
|
|
546
|
+
|
|
547
|
+
const fetchOpts = {
|
|
548
|
+
method: httpMethod,
|
|
549
|
+
signal: controller.signal,
|
|
550
|
+
credentials: authMode === "proxy" ? "include" : "omit",
|
|
551
|
+
headers: {
|
|
552
|
+
"Content-Type": "application/json",
|
|
553
|
+
...(token && { Authorization: `Bearer ${token}` }),
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
if (customHeaders?.trim()) {
|
|
558
|
+
try {
|
|
559
|
+
Object.assign(fetchOpts.headers, JSON.parse(customHeaders));
|
|
560
|
+
} catch (e) {
|
|
561
|
+
console.warn("[ApiSelect] Invalid customHeaders JSON:", e.message);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (httpMethod === "POST" && requestPayload?.trim()) {
|
|
566
|
+
fetchOpts.body = requestPayload;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (authMode === "proxy") {
|
|
570
|
+
const params = new URLSearchParams({
|
|
571
|
+
api: optionsApi,
|
|
572
|
+
labelKey: optionLabelKey,
|
|
573
|
+
valueKey: optionValueKey,
|
|
574
|
+
method: httpMethod,
|
|
575
|
+
});
|
|
576
|
+
if (requestPayload) params.append("payload", requestPayload);
|
|
577
|
+
if (customHeaders) params.append("headers", customHeaders);
|
|
578
|
+
if (responseDataPath) params.append("dataPath", responseDataPath);
|
|
579
|
+
|
|
580
|
+
url = `${window.strapi.backendURL}/api-select/fetch?${params}`;
|
|
581
|
+
fetchOpts.method = "GET";
|
|
582
|
+
delete fetchOpts.body;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const response = await fetch(url, fetchOpts);
|
|
586
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
587
|
+
|
|
588
|
+
const data = await response.json();
|
|
589
|
+
|
|
590
|
+
let itemsArray = data;
|
|
591
|
+
if (responseDataPath) {
|
|
592
|
+
itemsArray = getNestedValue(data, responseDataPath);
|
|
593
|
+
} else {
|
|
594
|
+
itemsArray =
|
|
595
|
+
data.data || data.results || data.items || data.response || data;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (!Array.isArray(itemsArray)) {
|
|
599
|
+
setOptions([]);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
let customMapping = null;
|
|
604
|
+
if (responseMappingConfig?.trim()) {
|
|
605
|
+
try {
|
|
606
|
+
customMapping = JSON.parse(responseMappingConfig);
|
|
607
|
+
} catch (e) {
|
|
608
|
+
console.warn(
|
|
609
|
+
"[ApiSelect] Invalid responseMappingConfig JSON:",
|
|
610
|
+
e.message
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const mappedOptions = itemsArray.map((item) =>
|
|
616
|
+
mapResponseData(item, customMapping, optionLabelKey, optionValueKey)
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
setOptions(mappedOptions);
|
|
620
|
+
} catch (err) {
|
|
621
|
+
if (err.name === "AbortError") return;
|
|
622
|
+
setFetchError(err.message);
|
|
623
|
+
setOptions([]);
|
|
624
|
+
} finally {
|
|
625
|
+
if (!controller.signal.aborted) {
|
|
626
|
+
setLoading(false);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
fetchOptions();
|
|
632
|
+
return () => controller.abort();
|
|
633
|
+
}, [
|
|
634
|
+
optionsApi,
|
|
635
|
+
optionLabelKey,
|
|
636
|
+
optionValueKey,
|
|
637
|
+
authMode,
|
|
638
|
+
httpMethod,
|
|
639
|
+
requestPayload,
|
|
640
|
+
customHeaders,
|
|
641
|
+
responseDataPath,
|
|
642
|
+
responseMappingConfig,
|
|
643
|
+
]);
|
|
644
|
+
|
|
645
|
+
// -------------------------------------------------------------------------
|
|
646
|
+
// Value storage
|
|
647
|
+
//
|
|
648
|
+
// Field type is "json" → must store object/array, never a bare string.
|
|
649
|
+
//
|
|
650
|
+
// Single → { value: "sprite:address", label: "address", iconUrl: "https://..." }
|
|
651
|
+
// Multi → [{ value, label, iconUrl }, ...]
|
|
652
|
+
// Empty → null
|
|
653
|
+
//
|
|
654
|
+
// Storing full option objects means consumers can render icons immediately
|
|
655
|
+
// without re-fetching the API.
|
|
656
|
+
// -------------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
const optionByValue = useCallback(
|
|
659
|
+
(val) => options.find((o) => o.value === val),
|
|
660
|
+
[options]
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
const handleSingleChange = (val) => {
|
|
664
|
+
if (!val) {
|
|
665
|
+
onChange({ target: { name, value: null, type: "json" } });
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const opt = optionByValue(val);
|
|
669
|
+
const stored = opt
|
|
670
|
+
? { value: opt.value, label: opt.label, iconUrl: opt.iconUrl }
|
|
671
|
+
: { value: val };
|
|
672
|
+
onChange({ target: { name, value: stored, type: "json" } });
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
const handleSingleClear = () => {
|
|
676
|
+
onChange({ target: { name, value: null, type: "json" } });
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const handleMultiChange = (vals) => {
|
|
680
|
+
if (!vals?.length) {
|
|
681
|
+
onChange({ target: { name, value: null, type: "json" } });
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const stored = vals.map((val) => {
|
|
685
|
+
const opt = optionByValue(val);
|
|
686
|
+
return opt
|
|
687
|
+
? { value: opt.value, label: opt.label, iconUrl: opt.iconUrl }
|
|
688
|
+
: { value: val };
|
|
689
|
+
});
|
|
690
|
+
onChange({ target: { name, value: stored, type: "json" } });
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const handleMultiClear = () => {
|
|
694
|
+
onChange({ target: { name, value: null, type: "json" } });
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
// -------------------------------------------------------------------------
|
|
698
|
+
// Normalize stored value → plain string(s) for IconSelect widget
|
|
699
|
+
//
|
|
700
|
+
// DB single: { value, label, iconUrl } → "value-string"
|
|
701
|
+
// DB multi: [{ value, ... }, ...] → ["val1", "val2"]
|
|
702
|
+
// Legacy bare string → pass through
|
|
703
|
+
// -------------------------------------------------------------------------
|
|
704
|
+
|
|
705
|
+
const normalizedSingleValue = useMemo(() => {
|
|
706
|
+
if (!value) return "";
|
|
707
|
+
if (typeof value === "string") return value; // legacy bare string
|
|
708
|
+
if (typeof value === "object" && !Array.isArray(value) && value.value != null)
|
|
709
|
+
return String(value.value);
|
|
710
|
+
return "";
|
|
711
|
+
}, [value]);
|
|
712
|
+
|
|
713
|
+
const normalizedMultiValue = useMemo(() => {
|
|
714
|
+
if (!value) return [];
|
|
715
|
+
if (Array.isArray(value)) {
|
|
716
|
+
return value.map((v) =>
|
|
717
|
+
typeof v === "object" && v?.value != null ? String(v.value) : String(v)
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
return [];
|
|
721
|
+
}, [value]);
|
|
722
|
+
|
|
723
|
+
// -------------------------------------------------------------------------
|
|
724
|
+
// Display helpers
|
|
725
|
+
// -------------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
const displayLabel =
|
|
728
|
+
label || intlLabel?.defaultMessage || name || "Select Option";
|
|
729
|
+
|
|
730
|
+
const getPlaceholder = () => {
|
|
731
|
+
if (loading) return "Loading...";
|
|
732
|
+
if (fetchError) return "Error loading options";
|
|
733
|
+
return (
|
|
734
|
+
placeholder ||
|
|
735
|
+
configPlaceholder ||
|
|
736
|
+
(selectMode === "single" ? "Select option..." : "Select options...")
|
|
737
|
+
);
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const renderFeedback = () => {
|
|
741
|
+
if (loading) {
|
|
742
|
+
return (
|
|
743
|
+
<Flex padding={2} gap={2} alignItems="center">
|
|
744
|
+
<Loader small />
|
|
745
|
+
<Typography variant="pi" textColor="neutral600">
|
|
746
|
+
Loading options...
|
|
747
|
+
</Typography>
|
|
748
|
+
</Flex>
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
if (fetchError) {
|
|
752
|
+
return (
|
|
753
|
+
<Box
|
|
754
|
+
padding={2}
|
|
755
|
+
background="danger100"
|
|
756
|
+
borderColor="danger600"
|
|
757
|
+
borderStyle="solid"
|
|
758
|
+
borderWidth="1px"
|
|
759
|
+
hasRadius
|
|
760
|
+
>
|
|
761
|
+
<Typography variant="pi" textColor="danger600">
|
|
762
|
+
Failed to load options: {fetchError}
|
|
763
|
+
</Typography>
|
|
764
|
+
</Box>
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
return null;
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
// -------------------------------------------------------------------------
|
|
771
|
+
// Render
|
|
772
|
+
// -------------------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
return (
|
|
775
|
+
<Box padding={2}>
|
|
776
|
+
<Field.Root name={name} error={error} required={required}>
|
|
777
|
+
<Field.Label>{displayLabel}</Field.Label>
|
|
778
|
+
{renderFeedback()}
|
|
779
|
+
<IconSelect
|
|
780
|
+
forwardedRef={forwardedRef}
|
|
781
|
+
options={options}
|
|
782
|
+
value={
|
|
783
|
+
selectMode === "multiple"
|
|
784
|
+
? normalizedMultiValue
|
|
785
|
+
: normalizedSingleValue
|
|
786
|
+
}
|
|
787
|
+
onChange={
|
|
788
|
+
selectMode === "multiple" ? handleMultiChange : handleSingleChange
|
|
789
|
+
}
|
|
790
|
+
onClear={
|
|
791
|
+
selectMode === "multiple" ? handleMultiClear : handleSingleClear
|
|
792
|
+
}
|
|
793
|
+
placeholder={getPlaceholder()}
|
|
794
|
+
disabled={disabled || loading}
|
|
795
|
+
isMulti={selectMode === "multiple"}
|
|
796
|
+
/>
|
|
797
|
+
{description && <Field.Hint>{description}</Field.Hint>}
|
|
798
|
+
<Field.Error />
|
|
799
|
+
</Field.Root>
|
|
800
|
+
</Box>
|
|
801
|
+
);
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
ApiSelectInput.defaultProps = {
|
|
805
|
+
value: null,
|
|
806
|
+
placeholder: null,
|
|
807
|
+
error: null,
|
|
808
|
+
required: false,
|
|
809
|
+
disabled: false,
|
|
810
|
+
label: null,
|
|
811
|
+
description: null,
|
|
812
|
+
intlLabel: null,
|
|
813
|
+
forwardedRef: null,
|
|
814
|
+
attribute: {},
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
export default ApiSelectInput;
|