@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.
package/README.md CHANGED
@@ -29,7 +29,7 @@ npm install @devcustrom/strapi-plugin-api-select
29
29
  Add the plugin to your `config/plugins.js` file:
30
30
 
31
31
  ```javascript
32
- module.exports = {
32
+ export default {
33
33
  // ...
34
34
  "api-select": {
35
35
  enabled: true,
@@ -1,5 +1,4 @@
1
- import React, { useState, useEffect } from "react";
2
- import * as PropTypes from "prop-types";
1
+ import React, { useState, useEffect, useMemo } from "react";
3
2
  import {
4
3
  Field,
5
4
  SingleSelect,
@@ -9,58 +8,101 @@ import {
9
8
  Loader,
10
9
  Box,
11
10
  Typography,
12
- Flex
11
+ Flex,
13
12
  } from "@strapi/design-system";
14
13
 
15
- // Helper functions (getNestedValue, mapResponseData) остаются без изменений
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Safely resolves a dot-separated path inside an object.
20
+ * Handles null/undefined at any level without throwing.
21
+ */
16
22
  const getNestedValue = (obj, path) => {
17
23
  if (!path) return obj;
18
- return path.split('.').reduce((current, key) => {
19
- return current && current[key] !== undefined ? current[key] : null;
24
+ return path.split(".").reduce((current, key) => {
25
+ if (current === null || current === undefined) return null;
26
+ return current[key] !== undefined ? current[key] : null;
20
27
  }, obj);
21
28
  };
22
29
 
30
+ /**
31
+ * Guarantees that a value is a plain string primitive.
32
+ * Objects are JSON-stringified; everything else is cast with String().
33
+ */
34
+ const toStringValue = (val) => {
35
+ if (val === null || val === undefined) return "";
36
+ if (typeof val === "object") return JSON.stringify(val);
37
+ return String(val);
38
+ };
39
+
40
+ /**
41
+ * Maps a raw API item to { label, value, item, ...extraKeys }.
42
+ * All returned values are guaranteed strings.
43
+ */
23
44
  const mapResponseData = (item, mapping, fallbackLabelKey, fallbackValueKey) => {
24
- let mapped = { label: '', value: '', item };
45
+ let mapped = { label: "", value: "", item };
25
46
 
26
- if (mapping && typeof mapping === 'object') {
47
+ if (mapping && typeof mapping === "object") {
27
48
  mapped.label =
28
49
  getNestedValue(item, mapping.label || mapping.text || mapping.name) ||
29
50
  getNestedValue(item, fallbackLabelKey) ||
30
- item.label || item.name || item.text || 'Unknown';
51
+ item.label ||
52
+ item.name ||
53
+ item.text ||
54
+ "Unknown";
31
55
 
32
56
  mapped.value =
33
57
  getNestedValue(item, mapping.value || mapping.id) ||
34
58
  getNestedValue(item, fallbackValueKey) ||
35
- item.value || item.id || mapped.label;
59
+ item.value ||
60
+ item.id ||
61
+ mapped.label;
36
62
 
37
- Object.keys(mapping).forEach(key => {
38
- if (key !== 'label' && key !== 'value') {
63
+ Object.keys(mapping).forEach((key) => {
64
+ if (key !== "label" && key !== "value") {
39
65
  mapped[key] = getNestedValue(item, mapping[key]);
40
66
  }
41
67
  });
42
68
  } else {
43
69
  mapped.label =
44
70
  getNestedValue(item, fallbackLabelKey) ||
45
- item.label || item.name || item.text || item.title ||
46
- item.displayName || item.description || 'Unknown Option';
71
+ item.label ||
72
+ item.name ||
73
+ item.text ||
74
+ item.title ||
75
+ item.displayName ||
76
+ item.description ||
77
+ "Unknown Option";
47
78
 
48
79
  mapped.value =
49
80
  getNestedValue(item, fallbackValueKey) ||
50
- item.value || item.id || item.identifier || item.uuid || mapped.label;
81
+ item.value ||
82
+ item.id ||
83
+ item.identifier ||
84
+ item.uuid ||
85
+ mapped.label;
51
86
  }
52
87
 
53
- if (!mapped.label || typeof mapped.label !== 'string') {
54
- mapped.label = `Option ${mapped.value || 'Unknown'}`;
88
+ if (!mapped.label || typeof mapped.label !== "string") {
89
+ mapped.label = `Option ${mapped.value || "Unknown"}`;
55
90
  }
56
91
 
57
92
  if (!mapped.value) {
58
93
  mapped.value = mapped.label || `option_${Date.now()}`;
59
94
  }
60
95
 
96
+ // FIX #4: guarantee value is always a string primitive, never an object
97
+ mapped.value = toStringValue(mapped.value);
98
+
61
99
  return mapped;
62
100
  };
63
101
 
102
+ // ---------------------------------------------------------------------------
103
+ // Component
104
+ // ---------------------------------------------------------------------------
105
+
64
106
  const ApiSelectInput = ({
65
107
  name,
66
108
  value,
@@ -80,38 +122,46 @@ const ApiSelectInput = ({
80
122
  const [loading, setLoading] = useState(false);
81
123
  const [fetchError, setFetchError] = useState(null);
82
124
 
83
- const config = attribute?.options || {};
84
- const configValues = config.options || config;
125
+ // FIX #1: memoize configValues so it doesn't recreate on every render
126
+ // and trigger an infinite useEffect loop.
127
+ const configValues = useMemo(() => {
128
+ const config = attribute?.options || {};
129
+ return config.options || config;
130
+ }, [attribute]);
85
131
 
86
132
  const {
87
133
  optionsApi,
88
- optionLabelKey = 'name',
89
- optionValueKey = 'id',
90
- selectMode = 'single',
91
- authMode = 'public',
134
+ optionLabelKey = "name",
135
+ optionValueKey = "id",
136
+ selectMode = "single",
137
+ authMode = "public",
92
138
  placeholder: configPlaceholder,
93
- httpMethod = 'GET',
94
- requestPayload = '',
95
- customHeaders = '',
96
- responseDataPath = '',
97
- responseMappingConfig = '',
139
+ httpMethod = "GET",
140
+ requestPayload = "",
141
+ customHeaders = "",
142
+ responseDataPath = "",
143
+ responseMappingConfig = "",
98
144
  } = configValues;
99
145
 
100
146
  const getAuthToken = () => {
101
147
  try {
102
148
  return (
103
- localStorage.getItem('jwtToken')?.replaceAll('"', '') ||
104
- localStorage.getItem('strapi_jwt')?.replaceAll('"', '') ||
105
- localStorage.getItem('token')?.replaceAll('"', '')
149
+ localStorage.getItem("jwtToken")?.replaceAll('"', "") ||
150
+ localStorage.getItem("strapi_jwt")?.replaceAll('"', "") ||
151
+ localStorage.getItem("token")?.replaceAll('"', "")
106
152
  );
107
- } catch (e) {
153
+ } catch {
108
154
  return null;
109
155
  }
110
156
  };
111
157
 
158
+ // FIX #2: AbortController to prevent race conditions when params change
159
+ // while a previous fetch is still in flight.
112
160
  useEffect(() => {
113
161
  if (!optionsApi) return;
114
162
 
163
+ const controller = new AbortController();
164
+
115
165
  const fetchOptions = async () => {
116
166
  setLoading(true);
117
167
  setFetchError(null);
@@ -122,35 +172,41 @@ const ApiSelectInput = ({
122
172
 
123
173
  const fetchOpts = {
124
174
  method: httpMethod,
125
- credentials: authMode === 'proxy' ? 'include' : 'omit',
175
+ signal: controller.signal,
176
+ credentials: authMode === "proxy" ? "include" : "omit",
126
177
  headers: {
127
- 'Content-Type': 'application/json',
178
+ "Content-Type": "application/json",
128
179
  ...(token && { Authorization: `Bearer ${token}` }),
129
180
  },
130
181
  };
131
182
 
183
+ // FIX #6: log invalid customHeaders JSON instead of silently swallowing
132
184
  if (customHeaders?.trim()) {
133
185
  try {
134
186
  Object.assign(fetchOpts.headers, JSON.parse(customHeaders));
135
- } catch (e) {}
187
+ } catch (e) {
188
+ console.warn("[ApiSelect] Invalid customHeaders JSON:", e.message);
189
+ }
136
190
  }
137
191
 
138
- if (httpMethod === 'POST' && requestPayload?.trim()) {
192
+ if (httpMethod === "POST" && requestPayload?.trim()) {
139
193
  fetchOpts.body = requestPayload;
140
194
  }
141
195
 
142
- if (authMode === 'proxy') {
196
+ if (authMode === "proxy") {
143
197
  const params = new URLSearchParams({
144
198
  api: optionsApi,
145
199
  labelKey: optionLabelKey,
146
200
  valueKey: optionValueKey,
147
201
  method: httpMethod,
148
202
  });
149
- if (requestPayload) params.append('payload', requestPayload);
150
- if (customHeaders) params.append('headers', customHeaders);
203
+ if (requestPayload) params.append("payload", requestPayload);
204
+ if (customHeaders) params.append("headers", customHeaders);
205
+ // FIX #8: proxy mode was not forwarding responseDataPath
206
+ if (responseDataPath) params.append("dataPath", responseDataPath);
151
207
 
152
208
  url = `${window.strapi.backendURL}/api-select/fetch?${params}`;
153
- fetchOpts.method = 'GET';
209
+ fetchOpts.method = "GET";
154
210
  delete fetchOpts.body;
155
211
  }
156
212
 
@@ -163,7 +219,8 @@ const ApiSelectInput = ({
163
219
  if (responseDataPath) {
164
220
  itemsArray = getNestedValue(data, responseDataPath);
165
221
  } else {
166
- itemsArray = data.data || data.results || data.items || data.response || data;
222
+ itemsArray =
223
+ data.data || data.results || data.items || data.response || data;
167
224
  }
168
225
 
169
226
  if (!Array.isArray(itemsArray)) {
@@ -171,28 +228,42 @@ const ApiSelectInput = ({
171
228
  return;
172
229
  }
173
230
 
231
+ // FIX #7: log invalid responseMappingConfig JSON
174
232
  let customMapping = null;
175
233
  if (responseMappingConfig?.trim()) {
176
234
  try {
177
235
  customMapping = JSON.parse(responseMappingConfig);
178
- } catch (e) {}
236
+ } catch (e) {
237
+ console.warn(
238
+ "[ApiSelect] Invalid responseMappingConfig JSON:",
239
+ e.message
240
+ );
241
+ }
179
242
  }
180
- console.log(itemsArray);
181
-
182
- const mappedOptions = itemsArray.map(item =>
243
+
244
+ // FIX #10: removed console.log(itemsArray) leftover
245
+ const mappedOptions = itemsArray.map((item) =>
183
246
  mapResponseData(item, customMapping, optionLabelKey, optionValueKey)
184
247
  );
185
248
 
186
249
  setOptions(mappedOptions);
187
250
  } catch (err) {
251
+ // FIX #2: ignore abort errors — they are intentional
252
+ if (err.name === "AbortError") return;
188
253
  setFetchError(err.message);
189
254
  setOptions([]);
190
255
  } finally {
191
- setLoading(false);
256
+ // Guard: don't update state if the effect was cleaned up
257
+ if (!controller.signal.aborted) {
258
+ setLoading(false);
259
+ }
192
260
  }
193
261
  };
194
262
 
195
263
  fetchOptions();
264
+
265
+ // FIX #2: cleanup — abort in-flight request when deps change or unmount
266
+ return () => controller.abort();
196
267
  }, [
197
268
  optionsApi,
198
269
  optionLabelKey,
@@ -205,139 +276,190 @@ const ApiSelectInput = ({
205
276
  responseMappingConfig,
206
277
  ]);
207
278
 
208
- const handleSingleChange = (value) => {
209
- onChange({ target: { name, value, type: 'json' } });
279
+ // -------------------------------------------------------------------------
280
+ // Change handlers
281
+ // -------------------------------------------------------------------------
282
+
283
+ // The custom field is registered with type: "json", so Strapi requires a
284
+ // valid JSON value — an object or array, never a bare string.
285
+ //
286
+ // Single mode → store { value: "selected-string" }
287
+ // Multi mode → store ["val1", "val2"] (array is valid JSON)
288
+ // Empty/clear → store null
289
+
290
+ const handleSingleChange = (val) => {
291
+ onChange({
292
+ target: {
293
+ name,
294
+ value: val ? { value: val } : null,
295
+ type: "json",
296
+ },
297
+ });
210
298
  };
211
299
 
212
- const handleMultiChange = (values) => {
213
- onChange({ target: { name, value: values, type: 'json' } });
300
+ const handleSingleClear = () => {
301
+ onChange({ target: { name, value: null, type: "json" } });
214
302
  };
215
303
 
216
- const displayLabel = label || intlLabel?.defaultMessage || name || 'Select Option';
304
+ const handleMultiChange = (values) => {
305
+ onChange({
306
+ target: {
307
+ name,
308
+ value: values?.length ? values : null,
309
+ type: "json",
310
+ },
311
+ });
312
+ };
217
313
 
218
- const getPlaceholder = () => {
219
- if (loading) return 'Loading...';
220
- if (fetchError) return 'Error loading options';
221
- return placeholder || configPlaceholder || (selectMode === 'single' ? 'Select option...' : 'Select options...');
314
+ const handleMultiClear = () => {
315
+ onChange({ target: { name, value: null, type: "json" } });
222
316
  };
223
317
 
224
- const renderOptionContent = (option) => {
225
- const iconUrl = false//enableIcons ? option.item[iconUrlKey] : null;
318
+ // -------------------------------------------------------------------------
319
+ // Derived display values
320
+ // -------------------------------------------------------------------------
321
+
322
+ const displayLabel =
323
+ label || intlLabel?.defaultMessage || name || "Select Option";
324
+
325
+ const getPlaceholder = () => {
326
+ if (loading) return "Loading...";
327
+ if (fetchError) return "Error loading options";
226
328
  return (
227
- <Flex gap={2} alignItems="center">
228
- {
229
- iconUrl && (
230
- <svg
231
- width={20}
232
- height={20}
233
- viewBox="0 0 24 24"
234
- focusable="false"
235
- aria-hidden="true"
236
- >
237
- <use href={iconUrl} />
238
- </svg>
239
- )}
240
- <span>{option.label}</span>
241
- </Flex>
329
+ placeholder ||
330
+ configPlaceholder ||
331
+ (selectMode === "single" ? "Select option..." : "Select options...")
242
332
  );
243
333
  };
244
334
 
245
- // Loading state
246
- if (loading) {
247
- return (
248
- <Box padding={2}>
249
- <Field.Root name={name} error={error} required={required}>
250
- <Field.Label>{displayLabel}</Field.Label>
251
- <Flex padding={2} gap={2}>
252
- <Loader small />
253
- <Typography variant="pi" textColor="neutral600">
254
- Loading options...
255
- </Typography>
256
- </Flex>
257
- {description && <Field.Hint>{description}</Field.Hint>}
258
- <Field.Error />
259
- </Field.Root>
260
- </Box>
261
- );
262
- }
335
+ // Unwrap the stored JSON structure back to a plain string for the Select.
336
+ // DB value for single: { value: "icon-name" } → "icon-name"
337
+ // DB value for multi: ["icon1", "icon2"] → ["icon1", "icon2"]
338
+ // Fallback: handle legacy bare strings gracefully.
339
+ const normalizedSingleValue = useMemo(() => {
340
+ if (!value) return "";
341
+ if (typeof value === "object" && !Array.isArray(value) && value.value != null)
342
+ return String(value.value);
343
+ // Legacy: bare string stored before this fix
344
+ if (typeof value === "string") return value;
345
+ return "";
346
+ }, [value]);
347
+
348
+ const normalizedMultiValue = useMemo(() => {
349
+ if (!value) return [];
350
+ return (Array.isArray(value) ? value : [value]).map(String);
351
+ }, [value]);
352
+
353
+ // -------------------------------------------------------------------------
354
+ // Option rendering
355
+ // -------------------------------------------------------------------------
356
+
357
+ const renderOptionContent = (option) => (
358
+ <Flex gap={2} alignItems="center">
359
+ <span>{option.label}</span>
360
+ </Flex>
361
+ );
263
362
 
264
- // Error state
265
- if (fetchError) {
266
- return (
267
- <Box padding={2}>
268
- <Field.Root name={name} error={error} required={required}>
269
- <Field.Label>{displayLabel}</Field.Label>
270
- <Box
271
- padding={2}
272
- background="danger100"
273
- borderColor="danger600"
274
- borderStyle="solid"
275
- borderWidth="1px"
276
- hasRadius
277
- >
278
- <Typography variant="pi" textColor="danger600">
279
- Failed to load options: {fetchError}
280
- </Typography>
281
- </Box>
282
- {description && <Field.Hint>{description}</Field.Hint>}
283
- <Field.Error />
284
- </Field.Root>
285
- </Box>
286
- );
287
- }
363
+ // -------------------------------------------------------------------------
364
+ // FIX #11: unified render — don't swap the entire JSX tree for loading/error
365
+ // states; keep the Select in place so Strapi doesn't lose the field's ref
366
+ // and layout position. Show inline feedback instead.
367
+ // -------------------------------------------------------------------------
288
368
 
289
- // MultiSelect mode
290
- if (selectMode === 'multiple') {
291
- return (
292
- <Box padding={2}>
293
- <Field.Root name={name} error={error} required={required}>
294
- <Field.Label>{displayLabel}</Field.Label>
295
- <MultiSelect
296
- ref={forwardedRef}
297
- name={name}
298
- value={value || []}
299
- onChange={handleMultiChange}
300
- disabled={disabled}
301
- placeholder={getPlaceholder()}
302
- withTags
303
- {...props}
304
- >
305
- {options.map((option, index) => (
306
- <MultiSelectOption key={option.value || index} value={option.value || ''}>
307
- {renderOptionContent(option)}
308
- </MultiSelectOption>
309
- ))}
310
- </MultiSelect>
311
- {description && <Field.Hint>{description}</Field.Hint>}
312
- <Field.Error />
313
- </Field.Root>
314
- </Box>
315
- );
316
- }
369
+ const renderFeedback = () => {
370
+ if (loading) {
371
+ return (
372
+ <Flex padding={2} gap={2} alignItems="center">
373
+ <Loader small />
374
+ <Typography variant="pi" textColor="neutral600">
375
+ Loading options...
376
+ </Typography>
377
+ </Flex>
378
+ );
379
+ }
380
+ if (fetchError) {
381
+ return (
382
+ <Box
383
+ padding={2}
384
+ background="danger100"
385
+ borderColor="danger600"
386
+ borderStyle="solid"
387
+ borderWidth="1px"
388
+ hasRadius
389
+ >
390
+ <Typography variant="pi" textColor="danger600">
391
+ Failed to load options: {fetchError}
392
+ </Typography>
393
+ </Box>
394
+ );
395
+ }
396
+ return null;
397
+ };
317
398
 
318
- return (
319
- <Box padding={2}>
399
+ // -------------------------------------------------------------------------
400
+ // Render
401
+ // -------------------------------------------------------------------------
402
+
403
+ if (selectMode === "multiple") {
404
+ return (
320
405
  <Field.Root name={name} error={error} required={required}>
321
406
  <Field.Label>{displayLabel}</Field.Label>
322
- <SingleSelect
407
+ {renderFeedback()}
408
+ {/* FIX #9: spread {...props} BEFORE controlled props so nothing
409
+ accidentally overrides onChange / onClear / value */}
410
+ <MultiSelect
411
+ {...props}
323
412
  ref={forwardedRef}
324
413
  name={name}
325
- value={value || ''}
326
- onChange={handleSingleChange}
327
- disabled={disabled}
414
+ value={normalizedMultiValue}
415
+ onChange={handleMultiChange}
416
+ onClear={handleMultiClear}
417
+ disabled={disabled || loading}
328
418
  placeholder={getPlaceholder()}
329
- {...props}
419
+ withTags
330
420
  >
331
421
  {options.map((option, index) => (
332
- <SingleSelectOption key={option.value || index} value={option.value || ''}>
422
+ <MultiSelectOption
423
+ key={option.value || index}
424
+ value={option.value || ""}
425
+ >
333
426
  {renderOptionContent(option)}
334
- </SingleSelectOption>
427
+ </MultiSelectOption>
335
428
  ))}
336
- </SingleSelect>
429
+ </MultiSelect>
337
430
  {description && <Field.Hint>{description}</Field.Hint>}
338
431
  <Field.Error />
339
432
  </Field.Root>
340
- </Box>
433
+ );
434
+ }
435
+
436
+ return (
437
+ <Field.Root name={name} error={error} required={required}>
438
+ <Field.Label>{displayLabel}</Field.Label>
439
+ {renderFeedback()}
440
+ {/* FIX #9: spread before controlled props */}
441
+ <SingleSelect
442
+ {...props}
443
+ ref={forwardedRef}
444
+ name={name}
445
+ value={normalizedSingleValue}
446
+ onChange={handleSingleChange}
447
+ onClear={handleSingleClear}
448
+ disabled={disabled || loading}
449
+ placeholder={getPlaceholder()}
450
+ >
451
+ {options.map((option, index) => (
452
+ <SingleSelectOption
453
+ key={option.value || index}
454
+ value={option.value || ""}
455
+ >
456
+ {renderOptionContent(option)}
457
+ </SingleSelectOption>
458
+ ))}
459
+ </SingleSelect>
460
+ {description && <Field.Hint>{description}</Field.Hint>}
461
+ <Field.Error />
462
+ </Field.Root>
341
463
  );
342
464
  };
343
465
 
@@ -354,22 +476,4 @@ ApiSelectInput.defaultProps = {
354
476
  attribute: {},
355
477
  };
356
478
 
357
- ApiSelectInput.propTypes = {
358
- name: PropTypes.string.isRequired,
359
- value: PropTypes.any,
360
- onChange: PropTypes.func.isRequired,
361
- attribute: PropTypes.object,
362
- placeholder: PropTypes.string,
363
- error: PropTypes.string,
364
- required: PropTypes.bool,
365
- disabled: PropTypes.bool,
366
- label: PropTypes.string,
367
- description: PropTypes.string,
368
- intlLabel: PropTypes.shape({
369
- id: PropTypes.string,
370
- defaultMessage: PropTypes.string,
371
- }),
372
- forwardedRef: PropTypes.any,
373
- };
374
-
375
479
  export default ApiSelectInput;