@devcustrom/strapi-plugin-api-select 1.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,64 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.1] - 2025-08-21
9
+
10
+ ### Fixed
11
+
12
+ - πŸ› Fixed plugin name inconsistency causing custom field registration error
13
+ - πŸ”§ Updated field name from "custom-strapi-select" to "api-select" to match package.json
14
+ - πŸ“ Updated all i18n keys to use consistent "api-select" prefix
15
+
16
+ ## [1.0.0] - 2025-08-21
17
+
18
+ ### Added
19
+
20
+ - πŸŽ‰ Initial release of Strapi Plugin API Select
21
+ - ✨ Custom field for API-driven select dropdowns
22
+ - πŸ”— Support for external API integration
23
+ - πŸ“‘ HTTP GET and POST request methods
24
+ - πŸ” Custom headers and authentication support
25
+ - πŸ“‹ Request payload configuration for POST requests
26
+ - πŸ—ΊοΈ Flexible response data path mapping
27
+ - πŸŽ›οΈ Custom response field mapping configuration
28
+ - 🌐 Proxy mode for private APIs with CORS bypass
29
+ - πŸ”’ Basic SSRF protection for API URLs
30
+ - 🌍 Multi-language content support
31
+ - πŸ“± Single and multiple selection modes
32
+ - 🎨 Strapi v5 design system integration
33
+ - πŸ”„ Real-time option loading
34
+ - πŸ“ Field descriptions and help text
35
+ - ⚑ Performance optimized with caching
36
+ - πŸ› Comprehensive error handling
37
+ - πŸ“Š Debug logging for troubleshooting
38
+
39
+ ### Features
40
+
41
+ - **Dynamic Select Fields**: Create select fields that fetch options from any REST API
42
+ - **Flexible HTTP Methods**: Support for both GET and POST requests with custom payloads
43
+ - **Authentication Support**: Add custom headers for API authentication
44
+ - **Response Mapping**: Handle diverse API response structures with custom field mapping
45
+ - **Proxy Support**: Built-in proxy for private APIs and CORS handling
46
+ - **Multilingual Ready**: Works with Strapi's internationalization features
47
+ - **Production Ready**: Includes error handling, validation, and performance optimizations
48
+
49
+ ### Technical
50
+
51
+ - Built for Strapi v5
52
+ - ES6/CommonJS module compatibility
53
+ - React-based admin UI
54
+ - TypeScript support
55
+ - Node.js 18+ compatibility
56
+ - Modern build system integration
57
+
58
+ ### Documentation
59
+
60
+ - Comprehensive README with examples
61
+ - API response format examples
62
+ - Configuration guide
63
+ - Troubleshooting section
64
+ - MIT License
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,266 @@
1
+ # Strapi Plugin API Select
2
+
3
+ [![npm version](https://badge.fury.io/js/@devcustrom/strapi-plugin-api-select.svg)](https://badge.fury.io/js/@devcustrom/strapi-plugin-api-select)
4
+ [![Downloads](https://img.shields.io/npm/dm/@devcustrom/strapi-plugin-api-select)](https://npmjs.org/package/@devcustrom/strapi-plugin-api-select)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ A powerful **Strapi v5 plugin** that provides API-driven select fields with support for GET/POST requests, custom headers, flexible response mapping, and multilingual content.
8
+
9
+ ## ✨ Features
10
+
11
+ - πŸ”— **External API Integration** - Fetch select options from any REST API
12
+ - πŸ“‘ **HTTP Methods** - Support for both GET and POST requests
13
+ - πŸ” **Authentication** - Custom headers and API token support
14
+ - πŸ“‹ **Request Payloads** - Configure JSON payloads for POST requests
15
+ - πŸ—ΊοΈ **Flexible Mapping** - Handle diverse API response structures
16
+ - 🌐 **Proxy Mode** - Built-in proxy for private APIs and CORS bypass
17
+ - 🌍 **Multilingual** - Works with Strapi's i18n features
18
+ - πŸ“± **Selection Modes** - Single or multiple selection
19
+ - ⚑ **Performance** - Optimized with error handling and caching
20
+
21
+ ## πŸš€ Installation
22
+
23
+ ```bash
24
+ npm install @devcustrom/strapi-plugin-api-select
25
+ ```
26
+
27
+ ## βš™οΈ Configuration
28
+
29
+ Add the plugin to your `config/plugins.js` file:
30
+
31
+ ```javascript
32
+ module.exports = {
33
+ // ...
34
+ "api-select": {
35
+ enabled: true,
36
+ },
37
+ // ...
38
+ };
39
+ ```
40
+
41
+ ## πŸ“– Usage
42
+
43
+ ### 1. Add Field to Content Type
44
+
45
+ 1. Go to **Content-Type Builder** in your Strapi admin
46
+ 2. Select your content type (e.g., Article, Product)
47
+ 3. Click **Add another field**
48
+ 4. Choose **Custom** β†’ **API Select**
49
+ 5. Configure your field options
50
+
51
+ ### 2. Field Configuration
52
+
53
+ #### Basic Settings
54
+
55
+ - **Options API URL**: The endpoint that returns your options
56
+ - **Label Key**: Field to use for display text (default: `name`)
57
+ - **Value Key**: Field to use for stored value (default: `id`)
58
+ - **Select Mode**: Single or Multiple selection
59
+ - **Auth Mode**: Public API or Proxy for private APIs
60
+
61
+ #### Advanced Settings
62
+
63
+ - **HTTP Method**: GET or POST (default: `GET`)
64
+ - **Request Payload**: JSON payload for POST requests
65
+ - **Custom Headers**: Additional headers as JSON object
66
+ - **Response Data Path**: Path to array in response (e.g., `data`, `results`)
67
+ - **Response Mapping**: Custom field mapping for complex responses
68
+
69
+ ## πŸ“‹ Examples
70
+
71
+ ### Basic GET Request
72
+
73
+ ```javascript
74
+ // API URL: https://api.example.com/categories
75
+ // Response:
76
+ [
77
+ { id: 1, name: "Technology" },
78
+ { id: 2, name: "Science" },
79
+ ];
80
+
81
+ // Configuration:
82
+ // - Label Key: name
83
+ // - Value Key: id
84
+ ```
85
+
86
+ ### POST Request with Payload
87
+
88
+ ```javascript
89
+ // Configuration:
90
+ // - HTTP Method: POST
91
+ // - Request Payload:
92
+ {
93
+ "filters": {"status": "active"},
94
+ "limit": 100
95
+ }
96
+
97
+ // - Custom Headers:
98
+ {
99
+ "Authorization": "Bearer your-token",
100
+ "X-API-Key": "your-api-key"
101
+ }
102
+ ```
103
+
104
+ ### Complex Response Mapping
105
+
106
+ ```javascript
107
+ // API Response:
108
+ {
109
+ "success": true,
110
+ "data": {
111
+ "users": [
112
+ {
113
+ "userId": 1,
114
+ "profile": {"displayName": "John Doe"},
115
+ "department": {"name": "Engineering"}
116
+ }
117
+ ]
118
+ }
119
+ }
120
+
121
+ // Configuration:
122
+ // - Response Data Path: data.users
123
+ // - Response Mapping:
124
+ {
125
+ "value": "userId",
126
+ "label": "profile.displayName",
127
+ "group": "department.name"
128
+ }
129
+ ```
130
+
131
+ ### Different Response Formats
132
+
133
+ The plugin automatically handles various API response structures:
134
+
135
+ #### Direct Array
136
+
137
+ ```json
138
+ [
139
+ { "id": 1, "name": "Option 1" },
140
+ { "id": 2, "name": "Option 2" }
141
+ ]
142
+ ```
143
+
144
+ #### Nested Data
145
+
146
+ ```json
147
+ {
148
+ "data": [
149
+ { "id": 1, "title": "Option 1" },
150
+ { "id": 2, "title": "Option 2" }
151
+ ]
152
+ }
153
+ ```
154
+
155
+ #### Complex Structure
156
+
157
+ ```json
158
+ {
159
+ "response": {
160
+ "items": [{ "uuid": "abc-123", "label": "Custom Option" }]
161
+ }
162
+ }
163
+ ```
164
+
165
+ ## πŸ”’ Security Features
166
+
167
+ - **SSRF Protection**: Validates API URLs to prevent server-side request forgery
168
+ - **Proxy Mode**: Routes requests through Strapi backend for private APIs
169
+ - **Environment Variables**: Support for API tokens via environment variables
170
+
171
+ Set your API token:
172
+
173
+ ```bash
174
+ CUSTOM_STRAPI_SELECT_TOKEN=your_api_token_here
175
+ ```
176
+
177
+ ## 🌐 Proxy Mode
178
+
179
+ For private APIs or to bypass CORS issues, use proxy mode:
180
+
181
+ 1. Set **Auth Mode** to "Proxy"
182
+ 2. Requests will be routed through your Strapi backend
183
+ 3. Configure authentication via environment variables
184
+
185
+ ## πŸŽ›οΈ Field Types Supported
186
+
187
+ ### Single Selection
188
+
189
+ Returns a single value:
190
+
191
+ ```javascript
192
+ "selected_option_id";
193
+ ```
194
+
195
+ ### Multiple Selection
196
+
197
+ Returns an array of values:
198
+
199
+ ```javascript
200
+ ["option_1", "option_2", "option_3"];
201
+ ```
202
+
203
+ ## πŸ”§ Development
204
+
205
+ ### Local Development
206
+
207
+ 1. Clone the repository
208
+ 2. Install dependencies: `npm install`
209
+ 3. Link locally: `npm link`
210
+ 4. In your Strapi project: `npm link @devcustrom/strapi-plugin-api-select`
211
+ 5. Restart Strapi
212
+
213
+ ### Testing
214
+
215
+ ```bash
216
+ npm test
217
+ ```
218
+
219
+ ## πŸ“„ API Reference
220
+
221
+ ### Server Routes
222
+
223
+ - `GET /api/api-select/fetch` - Proxy endpoint for fetching external API data
224
+
225
+ ### Query Parameters
226
+
227
+ - `api` - External API URL
228
+ - `labelKey` - Field name for option labels
229
+ - `valueKey` - Field name for option values
230
+ - `method` - HTTP method (GET/POST)
231
+ - `payload` - JSON payload for POST requests
232
+ - `headers` - Custom headers as JSON string
233
+
234
+ ## 🀝 Contributing
235
+
236
+ We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
237
+
238
+ 1. Fork the repository
239
+ 2. Create your feature branch: `git checkout -b feature/amazing-feature`
240
+ 3. Commit your changes: `git commit -m 'Add amazing feature'`
241
+ 4. Push to the branch: `git push origin feature/amazing-feature`
242
+ 5. Open a Pull Request
243
+
244
+ ## πŸ“ Changelog
245
+
246
+ See [CHANGELOG.md](CHANGELOG.md) for a list of changes.
247
+
248
+ ## πŸ“„ License
249
+
250
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
251
+
252
+ ## πŸ†˜ Support
253
+
254
+ - πŸ“– [Documentation](https://github.com/devcustrom/strapi-plugin-api-select#readme)
255
+ - πŸ› [Issue Tracker](https://github.com/devcustrom/strapi-plugin-api-select/issues)
256
+ - πŸ’¬ [Strapi Discord](https://discord.strapi.io) - Ask in #plugins channel
257
+
258
+ ## πŸ™ Acknowledgments
259
+
260
+ - Built for [Strapi v5](https://strapi.io)
261
+ - Inspired by the need for dynamic content in headless CMS
262
+ - Thanks to the Strapi community for feedback and contributions
263
+
264
+ ---
265
+
266
+ Made with ❀️ for the Strapi community
@@ -0,0 +1,242 @@
1
+ export default {
2
+ register(app) {
3
+ app.customFields.register({
4
+ name: "api-select",
5
+ pluginId: "api-select",
6
+ type: "json",
7
+ intlLabel: {
8
+ id: "api-select.label",
9
+ defaultMessage: "API Select",
10
+ },
11
+ intlDescription: {
12
+ id: "api-select.desc",
13
+ defaultMessage: "Select options from a remote API",
14
+ },
15
+ icon: () => "πŸ”—",
16
+ components: {
17
+ Input: async () =>
18
+ import("./plugin/ApiSelectInput.jsx").then((m) => ({
19
+ default: m.default,
20
+ })),
21
+ },
22
+ options: {
23
+ base: [
24
+ {
25
+ name: "options.optionsApi",
26
+ type: "text",
27
+ intlLabel: {
28
+ id: "api-select.optionsApi.label",
29
+ defaultMessage: "Options API URL",
30
+ },
31
+ },
32
+ {
33
+ name: "options.optionLabelKey",
34
+ type: "text",
35
+ defaultValue: "name",
36
+ intlLabel: {
37
+ id: "api-select.optionLabelKey.label",
38
+ defaultMessage: "Label key",
39
+ },
40
+ },
41
+ {
42
+ name: "options.optionValueKey",
43
+ type: "text",
44
+ defaultValue: "id",
45
+ intlLabel: {
46
+ id: "api-select.optionValueKey.label",
47
+ defaultMessage: "Value key",
48
+ },
49
+ },
50
+ {
51
+ name: "options.selectMode",
52
+ type: "select",
53
+ defaultValue: "multiple",
54
+ options: [
55
+ {
56
+ key: "single",
57
+ value: "single",
58
+ metadatas: {
59
+ intlLabel: {
60
+ id: "api-select.selectMode.single",
61
+ defaultMessage: "Single",
62
+ },
63
+ },
64
+ },
65
+ {
66
+ key: "multiple",
67
+ value: "multiple",
68
+ metadatas: {
69
+ intlLabel: {
70
+ id: "api-select.selectMode.multiple",
71
+ defaultMessage: "Multiple",
72
+ },
73
+ },
74
+ },
75
+ ],
76
+ intlLabel: {
77
+ id: "api-select.selectMode.label",
78
+ defaultMessage: "Select mode",
79
+ },
80
+ },
81
+ {
82
+ name: "options.authMode",
83
+ type: "select",
84
+ defaultValue: "public",
85
+ options: [
86
+ {
87
+ key: "public",
88
+ value: "public",
89
+ metadatas: {
90
+ intlLabel: {
91
+ id: "api-select.authMode.public",
92
+ defaultMessage: "Public",
93
+ },
94
+ },
95
+ },
96
+ {
97
+ key: "proxy",
98
+ value: "proxy",
99
+ metadatas: {
100
+ intlLabel: {
101
+ id: "api-select.authMode.proxy",
102
+ defaultMessage: "Proxy",
103
+ },
104
+ },
105
+ },
106
+ ],
107
+ intlLabel: {
108
+ id: "api-select.authMode.label",
109
+ defaultMessage: "Auth mode",
110
+ },
111
+ },
112
+ {
113
+ name: "options.placeholder",
114
+ type: "text",
115
+ intlLabel: {
116
+ id: "api-select.placeholder.label",
117
+ defaultMessage: "Placeholder",
118
+ },
119
+ },
120
+ {
121
+ name: "options.httpMethod",
122
+ type: "select",
123
+ defaultValue: "GET",
124
+ options: [
125
+ {
126
+ key: "GET",
127
+ value: "GET",
128
+ metadatas: {
129
+ intlLabel: {
130
+ id: "api-select.httpMethod.get",
131
+ defaultMessage: "GET",
132
+ },
133
+ },
134
+ },
135
+ {
136
+ key: "POST",
137
+ value: "POST",
138
+ metadatas: {
139
+ intlLabel: {
140
+ id: "api-select.httpMethod.post",
141
+ defaultMessage: "POST",
142
+ },
143
+ },
144
+ },
145
+ ],
146
+ intlLabel: {
147
+ id: "api-select.httpMethod.label",
148
+ defaultMessage: "HTTP Method",
149
+ },
150
+ },
151
+ {
152
+ name: "options.requestPayload",
153
+ type: "textarea",
154
+ intlLabel: {
155
+ id: "api-select.requestPayload.label",
156
+ defaultMessage: "Request Payload (JSON)",
157
+ },
158
+ description: {
159
+ id: "api-select.requestPayload.description",
160
+ defaultMessage:
161
+ "JSON payload for POST requests. Leave empty for GET requests.",
162
+ },
163
+ },
164
+ {
165
+ name: "options.customHeaders",
166
+ type: "textarea",
167
+ intlLabel: {
168
+ id: "api-select.customHeaders.label",
169
+ defaultMessage: "Custom Headers (JSON)",
170
+ },
171
+ description: {
172
+ id: "api-select.customHeaders.description",
173
+ defaultMessage:
174
+ 'Additional headers as JSON object. Example: {"Authorization": "Bearer token"}',
175
+ },
176
+ },
177
+ {
178
+ name: "options.responseDataPath",
179
+ type: "text",
180
+ intlLabel: {
181
+ id: "api-select.responseDataPath.label",
182
+ defaultMessage: "Response Data Path",
183
+ },
184
+ description: {
185
+ id: "api-select.responseDataPath.description",
186
+ defaultMessage:
187
+ "Path to array in response. Examples: 'data', 'results', 'items', 'data.users'. Leave empty if response is direct array.",
188
+ },
189
+ },
190
+ {
191
+ name: "options.responseMappingConfig",
192
+ type: "textarea",
193
+ intlLabel: {
194
+ id: "api-select.responseMappingConfig.label",
195
+ defaultMessage: "Response Mapping (JSON)",
196
+ },
197
+ description: {
198
+ id: "api-select.responseMappingConfig.description",
199
+ defaultMessage:
200
+ 'Custom mapping for complex responses. Example: {"value": "id", "label": "name", "group": "category.name"}',
201
+ },
202
+ },
203
+ // {
204
+ // sectionTitle: {
205
+ // id: "api-select.icons.section",
206
+ // defaultMessage: "Icon Preview",
207
+ // },
208
+ // items: [
209
+ // {
210
+ // name: "options.enableIcons",
211
+ // type: "checkbox",
212
+ // defaultValue: false,
213
+ // intlLabel: {
214
+ // id: "api-select.enableIcons.label",
215
+ // defaultMessage: "Show icons",
216
+ // },
217
+ // description: {
218
+ // id: "api-select.enableIcons.description",
219
+ // defaultMessage: "Display icons next to options",
220
+ // },
221
+ // },
222
+ // {
223
+ // name: "options.iconUrlKey",
224
+ // type: "text",
225
+ // defaultValue: "iconUrl",
226
+ // intlLabel: {
227
+ // id: "api-select.iconUrlKey.label",
228
+ // defaultMessage: "Icon URL field",
229
+ // },
230
+ // description: {
231
+ // id: "api-select.iconUrlKey.description",
232
+ // defaultMessage:
233
+ // "Field in API response that contains the full icon URL. Example: 'icon', 'image', 'svg'",
234
+ // },
235
+ // },
236
+ // ],
237
+ // },
238
+ ],
239
+ },
240
+ });
241
+ },
242
+ };
@@ -0,0 +1,380 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import PropTypes from "prop-types";
3
+ import {
4
+ Field,
5
+ SingleSelect,
6
+ SingleSelectOption,
7
+ MultiSelect,
8
+ MultiSelectOption,
9
+ Loader,
10
+ Box,
11
+ Typography,
12
+ Flex
13
+ } from "@strapi/design-system";
14
+
15
+ // Helper functions (getNestedValue, mapResponseData) ΠΎΡΡ‚Π°ΡŽΡ‚ΡΡ Π±Π΅Π· ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ
16
+ const getNestedValue = (obj, path) => {
17
+ if (!path) return obj;
18
+ return path.split('.').reduce((current, key) => {
19
+ return current && current[key] !== undefined ? current[key] : null;
20
+ }, obj);
21
+ };
22
+
23
+ const mapResponseData = (item, mapping, fallbackLabelKey, fallbackValueKey) => {
24
+ let mapped = { label: '', value: '', item };
25
+
26
+ if (mapping && typeof mapping === 'object') {
27
+ mapped.label =
28
+ getNestedValue(item, mapping.label || mapping.text || mapping.name) ||
29
+ getNestedValue(item, fallbackLabelKey) ||
30
+ item.label || item.name || item.text || 'Unknown';
31
+
32
+ mapped.value =
33
+ getNestedValue(item, mapping.value || mapping.id) ||
34
+ getNestedValue(item, fallbackValueKey) ||
35
+ item.value || item.id || mapped.label;
36
+
37
+ Object.keys(mapping).forEach(key => {
38
+ if (key !== 'label' && key !== 'value') {
39
+ mapped[key] = getNestedValue(item, mapping[key]);
40
+ }
41
+ });
42
+ } else {
43
+ mapped.label =
44
+ getNestedValue(item, fallbackLabelKey) ||
45
+ item.label || item.name || item.text || item.title ||
46
+ item.displayName || item.description || 'Unknown Option';
47
+
48
+ mapped.value =
49
+ getNestedValue(item, fallbackValueKey) ||
50
+ item.value || item.id || item.identifier || item.uuid || mapped.label;
51
+ }
52
+
53
+ if (!mapped.label || typeof mapped.label !== 'string') {
54
+ mapped.label = `Option ${mapped.value || 'Unknown'}`;
55
+ }
56
+
57
+ if (!mapped.value) {
58
+ mapped.value = mapped.label || `option_${Date.now()}`;
59
+ }
60
+
61
+ return mapped;
62
+ };
63
+
64
+ const ApiSelectInput = ({
65
+ name,
66
+ value,
67
+ onChange,
68
+ attribute,
69
+ placeholder,
70
+ error,
71
+ required,
72
+ disabled,
73
+ label,
74
+ description,
75
+ intlLabel,
76
+ forwardedRef,
77
+ ...props
78
+ }) => {
79
+ const [options, setOptions] = useState([]);
80
+ const [loading, setLoading] = useState(false);
81
+ const [fetchError, setFetchError] = useState(null);
82
+
83
+ const config = attribute?.options || {};
84
+ const configValues = config.options || config;
85
+
86
+ const {
87
+ optionsApi,
88
+ optionLabelKey = 'name',
89
+ optionValueKey = 'id',
90
+ selectMode = 'single',
91
+ authMode = 'public',
92
+ placeholder: configPlaceholder,
93
+ httpMethod = 'GET',
94
+ requestPayload = '',
95
+ customHeaders = '',
96
+ responseDataPath = '',
97
+ responseMappingConfig = '',
98
+ // πŸ”₯ НовыС поля для ΠΈΠΊΠΎΠ½ΠΎΠΊ
99
+ enableIcons = false,
100
+ iconUrlKey = 'iconUrl',
101
+ } = configValues;
102
+
103
+ const getAuthToken = () => {
104
+ try {
105
+ return (
106
+ localStorage.getItem('jwtToken')?.replaceAll('"', '') ||
107
+ localStorage.getItem('strapi_jwt')?.replaceAll('"', '') ||
108
+ localStorage.getItem('token')?.replaceAll('"', '')
109
+ );
110
+ } catch (e) {
111
+ return null;
112
+ }
113
+ };
114
+
115
+ useEffect(() => {
116
+ if (!optionsApi) return;
117
+
118
+ const fetchOptions = async () => {
119
+ setLoading(true);
120
+ setFetchError(null);
121
+
122
+ try {
123
+ let url = optionsApi;
124
+ const token = getAuthToken();
125
+
126
+ const fetchOpts = {
127
+ method: httpMethod,
128
+ credentials: authMode === 'proxy' ? 'include' : 'omit',
129
+ headers: {
130
+ 'Content-Type': 'application/json',
131
+ ...(token && { Authorization: `Bearer ${token}` }),
132
+ },
133
+ };
134
+
135
+ if (customHeaders?.trim()) {
136
+ try {
137
+ Object.assign(fetchOpts.headers, JSON.parse(customHeaders));
138
+ } catch (e) {}
139
+ }
140
+
141
+ if (httpMethod === 'POST' && requestPayload?.trim()) {
142
+ fetchOpts.body = requestPayload;
143
+ }
144
+
145
+ if (authMode === 'proxy') {
146
+ const params = new URLSearchParams({
147
+ api: optionsApi,
148
+ labelKey: optionLabelKey,
149
+ valueKey: optionValueKey,
150
+ method: httpMethod,
151
+ });
152
+ if (requestPayload) params.append('payload', requestPayload);
153
+ if (customHeaders) params.append('headers', customHeaders);
154
+
155
+ url = `${window.strapi.backendURL}/api-select/fetch?${params}`;
156
+ fetchOpts.method = 'GET';
157
+ delete fetchOpts.body;
158
+ }
159
+
160
+ const response = await fetch(url, fetchOpts);
161
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
162
+
163
+ const data = await response.json();
164
+
165
+ let itemsArray = data;
166
+ if (responseDataPath) {
167
+ itemsArray = getNestedValue(data, responseDataPath);
168
+ } else {
169
+ itemsArray = data.data || data.results || data.items || data.response || data;
170
+ }
171
+
172
+ if (!Array.isArray(itemsArray)) {
173
+ setOptions([]);
174
+ return;
175
+ }
176
+
177
+ let customMapping = null;
178
+ if (responseMappingConfig?.trim()) {
179
+ try {
180
+ customMapping = JSON.parse(responseMappingConfig);
181
+ } catch (e) {}
182
+ }
183
+ console.log(itemsArray);
184
+
185
+ const mappedOptions = itemsArray.map(item =>
186
+ mapResponseData(item, customMapping, optionLabelKey, optionValueKey)
187
+ );
188
+
189
+ setOptions(mappedOptions);
190
+ } catch (err) {
191
+ setFetchError(err.message);
192
+ setOptions([]);
193
+ } finally {
194
+ setLoading(false);
195
+ }
196
+ };
197
+
198
+ fetchOptions();
199
+ }, [
200
+ optionsApi,
201
+ optionLabelKey,
202
+ optionValueKey,
203
+ authMode,
204
+ httpMethod,
205
+ requestPayload,
206
+ customHeaders,
207
+ responseDataPath,
208
+ responseMappingConfig,
209
+ ]);
210
+
211
+ const handleSingleChange = (value) => {
212
+ onChange({ target: { name, value, type: 'json' } });
213
+ };
214
+
215
+ const handleMultiChange = (values) => {
216
+ onChange({ target: { name, value: values, type: 'json' } });
217
+ };
218
+
219
+ const displayLabel = label || intlLabel?.defaultMessage || name || 'Select Option';
220
+
221
+ const getPlaceholder = () => {
222
+ if (loading) return 'Loading...';
223
+ if (fetchError) return 'Error loading options';
224
+ return placeholder || configPlaceholder || (selectMode === 'single' ? 'Select option...' : 'Select options...');
225
+ };
226
+
227
+ // πŸ”₯ Ѐункция для Ρ€Π΅Π½Π΄Π΅Ρ€Π° ΠΎΠΏΡ†ΠΈΠΈ с ΠΈΠΊΠΎΠ½ΠΊΠΎΠΉ
228
+ const renderOptionContent = (option) => {
229
+ const iconUrl = enableIcons ? option.item[iconUrlKey] : null;
230
+ return (
231
+ <Flex gap={2} alignItems="center">
232
+ {
233
+ iconUrl && (
234
+ <svg
235
+ width={20}
236
+ height={20}
237
+ viewBox="0 0 24 24"
238
+ focusable="false"
239
+ aria-hidden="true"
240
+ >
241
+ <use href={iconUrl} />
242
+ </svg>
243
+ )}
244
+ <span>{option.label}</span>
245
+ </Flex>
246
+ );
247
+ };
248
+
249
+ // Loading state
250
+ if (loading) {
251
+ return (
252
+ <Box padding={2}>
253
+ <Field.Root name={name} error={error} required={required}>
254
+ <Field.Label>{displayLabel}</Field.Label>
255
+ <Flex padding={2} gap={2}>
256
+ <Loader small />
257
+ <Typography variant="pi" textColor="neutral600">
258
+ Loading options...
259
+ </Typography>
260
+ </Flex>
261
+ {description && <Field.Hint>{description}</Field.Hint>}
262
+ <Field.Error />
263
+ </Field.Root>
264
+ </Box>
265
+ );
266
+ }
267
+
268
+ // Error state
269
+ if (fetchError) {
270
+ return (
271
+ <Box padding={2}>
272
+ <Field.Root name={name} error={error} required={required}>
273
+ <Field.Label>{displayLabel}</Field.Label>
274
+ <Box
275
+ padding={2}
276
+ background="danger100"
277
+ borderColor="danger600"
278
+ borderStyle="solid"
279
+ borderWidth="1px"
280
+ hasRadius
281
+ >
282
+ <Typography variant="pi" textColor="danger600">
283
+ Failed to load options: {fetchError}
284
+ </Typography>
285
+ </Box>
286
+ {description && <Field.Hint>{description}</Field.Hint>}
287
+ <Field.Error />
288
+ </Field.Root>
289
+ </Box>
290
+ );
291
+ }
292
+
293
+ // MultiSelect mode
294
+ if (selectMode === 'multiple') {
295
+ return (
296
+ <Box padding={2}>
297
+ <Field.Root name={name} error={error} required={required}>
298
+ <Field.Label>{displayLabel}</Field.Label>
299
+ <MultiSelect
300
+ ref={forwardedRef}
301
+ name={name}
302
+ value={value || []}
303
+ onChange={handleMultiChange}
304
+ disabled={disabled}
305
+ placeholder={getPlaceholder()}
306
+ withTags
307
+ {...props}
308
+ >
309
+ {options.map((option, index) => (
310
+ <MultiSelectOption key={option.value || index} value={option.value || ''}>
311
+ {renderOptionContent(option)}
312
+ </MultiSelectOption>
313
+ ))}
314
+ </MultiSelect>
315
+ {description && <Field.Hint>{description}</Field.Hint>}
316
+ <Field.Error />
317
+ </Field.Root>
318
+ </Box>
319
+ );
320
+ }
321
+
322
+ // Single Select mode
323
+ return (
324
+ <Box padding={2}>
325
+ <Field.Root name={name} error={error} required={required}>
326
+ <Field.Label>{displayLabel}</Field.Label>
327
+ <SingleSelect
328
+ ref={forwardedRef}
329
+ name={name}
330
+ value={value || ''}
331
+ onChange={handleSingleChange}
332
+ disabled={disabled}
333
+ placeholder={getPlaceholder()}
334
+ {...props}
335
+ >
336
+ {options.map((option, index) => (
337
+ <SingleSelectOption key={option.value || index} value={option.value || ''}>
338
+ {renderOptionContent(option)}
339
+ </SingleSelectOption>
340
+ ))}
341
+ </SingleSelect>
342
+ {description && <Field.Hint>{description}</Field.Hint>}
343
+ <Field.Error />
344
+ </Field.Root>
345
+ </Box>
346
+ );
347
+ };
348
+
349
+ ApiSelectInput.defaultProps = {
350
+ value: null,
351
+ placeholder: null,
352
+ error: null,
353
+ required: false,
354
+ disabled: false,
355
+ label: null,
356
+ description: null,
357
+ intlLabel: null,
358
+ forwardedRef: null,
359
+ attribute: {},
360
+ };
361
+
362
+ ApiSelectInput.propTypes = {
363
+ name: PropTypes.string.isRequired,
364
+ value: PropTypes.any,
365
+ onChange: PropTypes.func.isRequired,
366
+ attribute: PropTypes.object,
367
+ placeholder: PropTypes.string,
368
+ error: PropTypes.string,
369
+ required: PropTypes.bool,
370
+ disabled: PropTypes.bool,
371
+ label: PropTypes.string,
372
+ description: PropTypes.string,
373
+ intlLabel: PropTypes.shape({
374
+ id: PropTypes.string,
375
+ defaultMessage: PropTypes.string,
376
+ }),
377
+ forwardedRef: PropTypes.any,
378
+ };
379
+
380
+ export default ApiSelectInput;
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@devcustrom/strapi-plugin-api-select",
3
+ "version": "1.1.0",
4
+ "description": "A powerful Strapi v5 plugin that provides API-driven select fields with support for GET/POST requests, custom headers, flexible response mapping, and multilingual content.",
5
+ "license": "MIT",
6
+ "main": "strapi-server.js",
7
+ "author": {
8
+ "name": "devcustrom",
9
+ "url": "https://github.com/devcustrom"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/devcustrom/strapi-plugin-api-select.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/devcustrom/strapi-plugin-api-select/issues"
17
+ },
18
+ "homepage": "https://github.com/devcustrom/strapi-plugin-api-select#readme",
19
+ "strapi": {
20
+ "name": "api-select",
21
+ "displayName": "API Select",
22
+ "description": "Dynamic select fields powered by external APIs",
23
+ "kind": "plugin",
24
+ "type": "custom-field"
25
+ },
26
+ "peerDependencies": {
27
+ "@strapi/strapi": "^5.0.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=18.0.0",
31
+ "npm": ">=8.0.0"
32
+ },
33
+ "files": [
34
+ "admin",
35
+ "server",
36
+ "strapi-admin.js",
37
+ "strapi-server.js",
38
+ "README.md",
39
+ "LICENSE",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "keywords": [
43
+ "strapi",
44
+ "strapi-plugin",
45
+ "custom-field",
46
+ "select",
47
+ "dropdown",
48
+ "api",
49
+ "dynamic",
50
+ "remote-data",
51
+ "external-api",
52
+ "v5",
53
+ "cms",
54
+ "headless"
55
+ ],
56
+ "scripts": {
57
+ "test": "echo \"No tests yet\" && exit 0"
58
+ }
59
+ }
@@ -0,0 +1,5 @@
1
+ const proxy = require("./proxy");
2
+
3
+ module.exports = {
4
+ proxy,
5
+ };
@@ -0,0 +1,87 @@
1
+ const { URL } = require("url");
2
+
3
+ module.exports = {
4
+ async fetch(ctx) {
5
+ try {
6
+ const {
7
+ api,
8
+ labelKey = "name",
9
+ valueKey = "id",
10
+ method = "GET",
11
+ payload,
12
+ headers: customHeadersStr,
13
+ } = ctx.query;
14
+
15
+ if (!api) {
16
+ return ctx.badRequest("API URL is required");
17
+ }
18
+
19
+ // Basic SSRF protection
20
+ const url = new URL(api);
21
+ if (!["http:", "https:"].includes(url.protocol)) {
22
+ return ctx.badRequest("Only HTTP/HTTPS URLs allowed");
23
+ }
24
+
25
+ // Prepare fetch options
26
+ const fetchOptions = {
27
+ method: method.toUpperCase(),
28
+ headers: {
29
+ "Content-Type": "application/json",
30
+ },
31
+ };
32
+
33
+ // Add environment token if available
34
+ if (process.env.CUSTOM_STRAPI_SELECT_TOKEN) {
35
+ fetchOptions.headers.Authorization = `Bearer ${process.env.CUSTOM_STRAPI_SELECT_TOKEN}`;
36
+ }
37
+
38
+ // Parse and add custom headers
39
+ if (customHeadersStr) {
40
+ try {
41
+ const customHeaders = JSON.parse(customHeadersStr);
42
+ fetchOptions.headers = { ...fetchOptions.headers, ...customHeaders };
43
+ strapi.log.info("Added custom headers to proxy request");
44
+ } catch (error) {
45
+ strapi.log.warn("Invalid custom headers JSON:", error.message);
46
+ }
47
+ }
48
+
49
+ // Add request body for POST requests
50
+ if (method.toUpperCase() === "POST" && payload) {
51
+ try {
52
+ // Validate that payload is valid JSON
53
+ JSON.parse(payload);
54
+ fetchOptions.body = payload;
55
+ strapi.log.info("Added POST payload to proxy request");
56
+ } catch (error) {
57
+ return ctx.badRequest("Invalid JSON payload");
58
+ }
59
+ }
60
+
61
+ strapi.log.info(`Proxying ${method.toUpperCase()} request to: ${api}`);
62
+
63
+ // Fetch data
64
+ const response = await fetch(api, fetchOptions);
65
+
66
+ if (!response.ok) {
67
+ throw new Error(`API responded with ${response.status}`);
68
+ }
69
+
70
+ let data = await response.json();
71
+
72
+ // Handle both array and {data: array} formats
73
+ if (data.data && Array.isArray(data.data)) {
74
+ data = data.data;
75
+ }
76
+
77
+ if (!Array.isArray(data)) {
78
+ return ctx.badRequest("API must return an array or {data: array}");
79
+ }
80
+
81
+ ctx.body = data;
82
+ } catch (error) {
83
+ strapi.log.error("Proxy fetch error:", error.message);
84
+ ctx.internalServerError("Failed to fetch options");
85
+ }
86
+ },
87
+ };
@@ -0,0 +1,9 @@
1
+ const register = require("./register");
2
+ const routes = require("./routes");
3
+ const controllers = require("./controllers");
4
+
5
+ module.exports = {
6
+ register,
7
+ routes,
8
+ controllers,
9
+ };
@@ -0,0 +1,8 @@
1
+ module.exports = ({ strapi }) => {
2
+ strapi.customFields.register({
3
+ name: "api-select",
4
+ plugin: "api-select",
5
+ type: "json",
6
+ inputSize: { default: 8, isResizable: true },
7
+ });
8
+ };
@@ -0,0 +1,7 @@
1
+ module.exports = [
2
+ {
3
+ method: "GET",
4
+ path: "/fetch",
5
+ handler: "proxy.fetch",
6
+ },
7
+ ];
@@ -0,0 +1 @@
1
+ export { default } from "./admin/src";
@@ -0,0 +1 @@
1
+ module.exports = require("./server");