@devcustrom/strapi-plugin-api-select 1.1.2 → 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.
@@ -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;