{
67 | try {
68 | const config = { ...selectConfiguration.fetch };
69 | config.url = (config.url || '').replace('{q}', searchModel);
70 |
71 | const res = await fetch(window.location.origin + '/remote-select/options-proxy', {
72 | method: 'POST',
73 | body: JSON.stringify({
74 | fetch: {
75 | ...selectConfiguration.fetch,
76 | url: selectConfiguration.fetch.url.replace('{q}', searchModel),
77 | body:
78 | selectConfiguration.fetch.body &&
79 | selectConfiguration.fetch.body.replace('{q}', searchModel),
80 | },
81 | mapping: selectConfiguration.mapping,
82 | }),
83 | headers: {
84 | 'Content-Type': 'application/json',
85 | },
86 | });
87 |
88 | if (res.status === 200) {
89 | setOptions(await res.json());
90 | } else {
91 | setLoadingError(res.statusText + ', code: ' + res.status);
92 | }
93 | } catch (err) {
94 | setLoadingError((err as any)?.message || err?.toString());
95 | } finally {
96 | setIsLoading(false);
97 | }
98 | }
99 |
100 | function handleChange(stringValueProjection?: string) {
101 | if (!stringValueProjection) {
102 | if (!isMulti) {
103 | writeSingleModel(undefined);
104 | }
105 | return;
106 | }
107 |
108 | try {
109 | const value: SearchableRemoteSelectValue = JSON.parse(stringValueProjection);
110 | if (isMulti) {
111 | if (!isInModel(value)) {
112 | writeMultiModel([...(valueParsed as SearchableRemoteSelectValue[]), value]);
113 | } else {
114 | removeFromModel(value);
115 | }
116 | handleTextValueChange('');
117 | } else {
118 | writeSingleModel(value);
119 | }
120 | } catch (err) {}
121 | }
122 |
123 | function handleTextValueChange(val: string): void {
124 | setSearchModel(val);
125 | if (valueParsed && isSingleParsed(valueParsed) && valueParsed.label !== val) {
126 | handleChange(undefined);
127 | }
128 |
129 | loadOptionsDebounced(val);
130 | }
131 |
132 | function handleOpenChange() {
133 | if (isMulti) {
134 | setSearchModel('');
135 | }
136 | }
137 |
138 | function isSingleParsed(
139 | val: SearchableRemoteSelectValue | SearchableRemoteSelectValue[]
140 | ): val is SearchableRemoteSelectValue {
141 | return !isMulti;
142 | }
143 |
144 | function isMultiParsed(
145 | val: SearchableRemoteSelectValue | SearchableRemoteSelectValue[]
146 | ): val is SearchableRemoteSelectValue[] {
147 | return isMulti;
148 | }
149 |
150 | function isInModel(option: SearchableRemoteSelectValue): boolean {
151 | return (
152 | !!valueParsed &&
153 | isMultiParsed(valueParsed) &&
154 | valueParsed.some((o) => o.value === option.value)
155 | );
156 | }
157 |
158 | function removeFromModel(option: SearchableRemoteSelectValue): void {
159 | if (!!valueParsed && isMultiParsed(valueParsed)) {
160 | writeMultiModel(valueParsed.filter((o) => o.value !== option.value));
161 | }
162 | }
163 |
164 | function writeMultiModel(value?: SearchableRemoteSelectValue[]): void {
165 | onChange({
166 | target: {
167 | name,
168 | type: attribute.type,
169 | value:
170 | value && value.length ? JSON.stringify(value) : required ? undefined : JSON.stringify([]),
171 | },
172 | });
173 | }
174 |
175 | function writeSingleModel(value?: SearchableRemoteSelectValue): void {
176 | onChange({
177 | target: {
178 | name,
179 | type: attribute.type,
180 | value: value ? JSON.stringify(value) : required ? undefined : JSON.stringify({}),
181 | },
182 | });
183 | }
184 |
185 | function clear(event: PointerEvent): void {
186 | event.stopPropagation();
187 | event.preventDefault();
188 | if (!isMulti) {
189 | writeSingleModel(undefined);
190 | }
191 | }
192 |
193 | const optionsList = options.map((opt) => {
194 | const optionString = JSON.stringify(opt);
195 |
196 | return (
197 |
198 |
199 | {isMulti ? : undefined}
200 | {opt.label}
201 |
202 |
203 | );
204 | });
205 |
206 | const selectedValuesTags =
207 | valueParsed && isMultiParsed(valueParsed) ? (
208 |
209 |
210 | {valueParsed.map((option) => (
211 | }
215 | onClick={() => removeFromModel(option)}
216 | >
217 | {option.label}
218 |
219 | ))}
220 |
221 |
222 | ) : undefined;
223 |
224 | return (
225 |
226 | {label}
227 |
245 | formatMessage({
246 | id: 'remote-select.searchable-select.no-results',
247 | defaultMessage: 'No results for your query',
248 | })
249 | }
250 | onTextValueChange={handleTextValueChange}
251 | onOpenChange={handleOpenChange}
252 | textValue={searchModel}
253 | onClear={isMulti ? undefined : clear}
254 | >
255 | {loadingError &&
256 | `Options loading error: ${loadingError}. Please check the field configuration.`}
257 | {optionsList}
258 |
259 |
260 |
261 | {selectedValuesTags}
262 |
263 | );
264 | }
265 |
--------------------------------------------------------------------------------
/admin/src/components/SearchableRemoteSelect/registerSearchableRemoteSelect.ts:
--------------------------------------------------------------------------------
1 | import type { StrapiApp } from '@strapi/strapi/admin';
2 | import pluginId from '../../pluginId';
3 | import { getRemoteSelectRegisterOptions } from '../../utils/getRemoteSelectRegisterOptions';
4 | import getTrad from '../../utils/getTrad';
5 | import SearchableRemoteSelectInputIcon from '../SearchableRemoteSelectInputIcon';
6 |
7 | export function registerSearchableRemoteSelect(app: StrapiApp): void {
8 | app.customFields.register({
9 | name: 'searchable-remote-select',
10 | pluginId: pluginId,
11 | type: 'json',
12 | intlLabel: {
13 | id: getTrad('searchable-remote-select.label'),
14 | defaultMessage: 'Searchable remote select',
15 | },
16 | intlDescription: {
17 | id: getTrad('remote-select.description'),
18 | defaultMessage: 'Select options from the remote source with search support',
19 | },
20 | icon: SearchableRemoteSelectInputIcon,
21 | components: {
22 | Input: async () => import(/* webpackChunkName: "RemoteSelect" */ './SearchableRemoteSelect'),
23 | },
24 | options: getRemoteSelectRegisterOptions('searchable'),
25 | });
26 | }
27 |
--------------------------------------------------------------------------------
/admin/src/components/SearchableRemoteSelectInputIcon/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * PluginIcon
4 | *
5 | */
6 |
7 | const SearchableRemoteSelectIcon = () => (
8 |
20 | );
21 |
22 | export default SearchableRemoteSelectIcon;
23 |
--------------------------------------------------------------------------------
/admin/src/index.ts:
--------------------------------------------------------------------------------
1 | import { StrapiApp } from '@strapi/strapi/admin';
2 | import { Initializer } from './components/Initializer';
3 | import { registerRemoteSelect } from './components/RemoteSelect/registerRemoteSelect';
4 | import { registerSearchableRemoteSelect } from './components/SearchableRemoteSelect/registerSearchableRemoteSelect';
5 | import { PLUGIN_ID } from './pluginId';
6 | import { getTranslation } from './utils/getTranslation';
7 |
8 | export default {
9 | register(app: StrapiApp) {
10 | app.registerPlugin({
11 | id: PLUGIN_ID,
12 | initializer: Initializer,
13 | isReady: false,
14 | name: PLUGIN_ID,
15 | });
16 |
17 | registerRemoteSelect(app);
18 | registerSearchableRemoteSelect(app);
19 | },
20 |
21 | async registerTrads(app: any) {
22 | const { locales } = app;
23 |
24 | const importedTranslations = await Promise.all(
25 | (locales as string[]).map((locale) => {
26 | return import(`./translations/${locale}.json`)
27 | .then(({ default: data }) => {
28 | return {
29 | data: getTranslation(data),
30 | locale,
31 | };
32 | })
33 | .catch(() => {
34 | return {
35 | data: {},
36 | locale,
37 | };
38 | });
39 | })
40 | );
41 |
42 | return importedTranslations;
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/admin/src/pages/App.tsx:
--------------------------------------------------------------------------------
1 | import { Page } from '@strapi/strapi/admin';
2 | import { Route, Routes } from 'react-router-dom';
3 |
4 | import { HomePage } from './HomePage';
5 |
6 | const App = () => {
7 | return (
8 |
9 | } />
10 | } />
11 |
12 | );
13 | };
14 |
15 | export { App };
16 |
--------------------------------------------------------------------------------
/admin/src/pages/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import { Main } from '@strapi/design-system';
2 | import { useIntl } from 'react-intl';
3 |
4 | import { getTranslation } from '../utils/getTranslation';
5 |
6 | const HomePage = () => {
7 | const { formatMessage } = useIntl();
8 |
9 | return (
10 |
11 | Welcome to {formatMessage({ id: getTranslation('plugin.name') })}
12 |
13 | );
14 | };
15 |
16 | export { HomePage };
17 |
--------------------------------------------------------------------------------
/admin/src/pluginId.ts:
--------------------------------------------------------------------------------
1 | import pluginPkg from '../../package.json';
2 |
3 | export const PLUGIN_ID = pluginPkg.name.replace(/^(@[^-,.][\w,-]+\/|strapi-)plugin-/i, '');
4 | export default PLUGIN_ID;
5 |
--------------------------------------------------------------------------------
/admin/src/translations/en.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/admin/src/utils/getRemoteSelectRegisterOptions.ts:
--------------------------------------------------------------------------------
1 | import { MessageDescriptor } from '@formatjs/intl/src/types';
2 | import * as yup from 'yup';
3 | import getTrad from './getTrad';
4 | import { CustomFieldOptions } from './types';
5 |
6 | type SelectType = 'base' | 'searchable';
7 |
8 | const translationsOptions: Record> = {
9 | sourceUrlDescription: {
10 | base: {
11 | id: getTrad('basic.source-url-note'),
12 | defaultMessage: 'The response should be a valid JSON',
13 | },
14 | searchable: {
15 | id: getTrad('basic.searchable-source-url-note'),
16 | defaultMessage: 'The string "{q}" in the url will be replaced with the search phrase',
17 | },
18 | },
19 | fetchBodyDescription: {
20 | base: {
21 | id: getTrad('basic.fetch-body-note'),
22 | defaultMessage: 'Fetch options request body.',
23 | },
24 | searchable: {
25 | id: getTrad('basic.searchable-fetch-body-note'),
26 | defaultMessage:
27 | 'Fetch options request body. The string "{q}" in the url will be replaced with the search phrase',
28 | },
29 | },
30 | multiModeDescription: {
31 | base: {
32 | id: getTrad('advanced.multi-note'),
33 | defaultMessage: 'Multi mode will have a JSON array of strings in the model',
34 | },
35 | searchable: {
36 | id: getTrad('advanced.searchable-multi-note'),
37 | defaultMessage: 'Multi mode will have a JSON array of objects in the model',
38 | },
39 | },
40 | };
41 |
42 | function getTranslationBySelectType(
43 | translationConfig: Record,
44 | type: SelectType
45 | ): MessageDescriptor {
46 | return translationConfig[type];
47 | }
48 |
49 | export function getRemoteSelectRegisterOptions(type: SelectType): CustomFieldOptions {
50 | return {
51 | base: [
52 | {
53 | sectionTitle: {
54 | // Add a "Format" settings section
55 | id: getTrad('basic.section-label-source'),
56 | defaultMessage: 'Options fetching configuration',
57 | },
58 | items: [
59 | {
60 | name: 'options.fetch.url' as any,
61 | type: 'string' as any,
62 | intlLabel: {
63 | id: getTrad('basic.source-url-label'),
64 | defaultMessage: 'Fetch options url',
65 | },
66 | description: getTranslationBySelectType(translationsOptions.sourceUrlDescription, type),
67 | },
68 | {
69 | name: 'options.fetch.method' as any,
70 | type: 'select',
71 | defaultValue: 'GET',
72 | intlLabel: {
73 | id: getTrad('basic.fetch-method-label'),
74 | defaultMessage: 'Fetch options method',
75 | },
76 | description: {},
77 | options: [
78 | {
79 | key: 'GET',
80 | defaultValue: 'GET',
81 | value: 'GET',
82 | metadatas: {
83 | intlLabel: {
84 | id: getTrad('basic.fetch-method-option-get'),
85 | defaultMessage: 'GET',
86 | },
87 | },
88 | },
89 | {
90 | key: 'POST',
91 | value: 'POST',
92 | metadatas: {
93 | intlLabel: {
94 | id: getTrad('basic.fetch-method-option-post'),
95 | defaultMessage: 'POST',
96 | },
97 | },
98 | },
99 | {
100 | key: 'PUT',
101 | value: 'PUT',
102 | metadatas: {
103 | intlLabel: {
104 | id: getTrad('basic.fetch-method-option-put'),
105 | defaultMessage: 'PUT',
106 | },
107 | },
108 | },
109 | ],
110 | } as any,
111 | {
112 | name: 'options.fetch.body' as any,
113 | type: 'textarea' as any,
114 | intlLabel: {
115 | id: getTrad('basic.fetch-body-label'),
116 | defaultMessage: 'Fetch options request body',
117 | },
118 | description: getTranslationBySelectType(translationsOptions.fetchBodyDescription, type),
119 | },
120 | {
121 | name: 'options.fetch.headers' as any,
122 | type: 'textarea' as any,
123 | intlLabel: {
124 | id: getTrad('basic.fetch-headers-label'),
125 | defaultMessage: 'Fetch options request custom headers',
126 | },
127 | description: {
128 | id: getTrad('basic.fetch-headers-note'),
129 | defaultMessage:
130 | 'Custom fetch options request headers in raw format, one header per line. For example:\nContent-type: application/json',
131 | },
132 | },
133 | ],
134 | },
135 | {
136 | sectionTitle: {
137 | // Add a "Format" settings section
138 | id: getTrad('basic.section-label-source-mapping'),
139 | defaultMessage: 'Options mapping',
140 | },
141 | items: [
142 | {
143 | name: 'options.mapping.sourceJsonPath' as any,
144 | type: 'string' as any,
145 | defaultValue: '$',
146 | intlLabel: {
147 | id: getTrad('basic.source-url-label'),
148 | defaultMessage: 'JSON path to options array',
149 | },
150 | description: {
151 | id: getTrad('basic.source-url-note'),
152 | defaultMessage:
153 | '"$" here is the options response. By default, it will try to use root as an array of options',
154 | },
155 | },
156 | {
157 | name: 'options.mapping.labelJsonPath' as any,
158 | type: 'string',
159 | defaultValue: '$',
160 | intlLabel: {
161 | id: getTrad('basic.labelJsonPath'),
162 | defaultMessage: 'JSON path to label for each item object',
163 | },
164 | description: {
165 | id: getTrad('basic.labelJsonPath-note'),
166 | defaultMessage:
167 | 'JSON path to label for each item object. "$"- here it is the each options item selected from "JSON path to options array"',
168 | },
169 | },
170 | {
171 | name: 'options.mapping.valueJsonPath' as any,
172 | type: 'string',
173 | defaultValue: '$',
174 | intlLabel: {
175 | id: getTrad('basic.valueJsonPath'),
176 | defaultMessage: 'JSON path to value for each item object',
177 | },
178 | description: {
179 | id: getTrad('basic.valueJsonPath-note'),
180 | defaultMessage:
181 | 'JSON path to value for each item object. "$"- here it is the each options item selected from "JSON path to options array"',
182 | },
183 | },
184 | ],
185 | },
186 | ],
187 | advanced: [
188 | {
189 | name: 'default',
190 | type: 'text',
191 | intlLabel: {
192 | id: getTrad('select.default-label'),
193 | defaultMessage: 'Default value',
194 | },
195 | description: {},
196 | },
197 | {
198 | sectionTitle: {
199 | id: getTrad('select.settings-section-label'),
200 | defaultMessage: 'Settings',
201 | },
202 | items: [
203 | {
204 | name: 'options.select.multi' as any,
205 | type: 'checkbox',
206 | intlLabel: {
207 | id: getTrad('select.multi-label'),
208 | defaultMessage: 'Multi mode',
209 | },
210 | description: getTranslationBySelectType(translationsOptions.multiModeDescription, type),
211 | },
212 | {
213 | name: 'required',
214 | type: 'checkbox',
215 | intlLabel: {
216 | id: 'form.attribute.item.requiredField',
217 | defaultMessage: 'Required field',
218 | },
219 | description: {
220 | id: 'form.attribute.item.requiredField.description',
221 | defaultMessage: "You won't be able to create an entry if this field is empty",
222 | },
223 | },
224 | {
225 | name: 'private',
226 | type: 'checkbox',
227 | intlLabel: {
228 | id: 'form.attribute.item.private',
229 | defaultMessage: 'Private field',
230 | },
231 | description: {
232 | id: 'form.attribute.item.private.description',
233 | defaultMessage: 'This field will not show up in the API response',
234 | },
235 | },
236 | ],
237 | },
238 | ],
239 | validator() {
240 | return {
241 | fetch: yup.object().shape({
242 | url: yup.string().required(),
243 | method: yup.string().oneOf(['GET', 'POST', 'PUT']).required(),
244 | body: yup.string().optional(),
245 | headers: yup.string().optional(),
246 | }),
247 | mapping: yup
248 | .object()
249 | .optional()
250 | .shape({
251 | sourceJsonPath: yup.string().optional(),
252 | labelJsonPath: yup.string().optional(),
253 | valueJsonPath: yup.string().optional(),
254 | })
255 | .nullable(),
256 | };
257 | },
258 | };
259 | }
260 |
--------------------------------------------------------------------------------
/admin/src/utils/getTrad.ts:
--------------------------------------------------------------------------------
1 | import pluginId from '../pluginId';
2 |
3 | const getTrad = (id: string) => `${pluginId}.${id}`;
4 |
5 | export default getTrad;
6 |
--------------------------------------------------------------------------------
/admin/src/utils/getTranslation.ts:
--------------------------------------------------------------------------------
1 | import { PLUGIN_ID } from '../pluginId';
2 |
3 | const getTranslation = (id: string) => `${PLUGIN_ID}.${id}`;
4 |
5 | export { getTranslation };
6 |
--------------------------------------------------------------------------------
/admin/src/utils/types.ts:
--------------------------------------------------------------------------------
1 | import { StrapiApp } from '@strapi/strapi/admin';
2 |
3 | type ExtractSingleType = T extends (infer U)[] ? U : T;
4 |
5 | export type CustomField = ExtractSingleType[0]>;
6 |
7 | export type CustomFieldOptions = CustomField['options'];
8 |
9 | export type CustomFieldOption = ExtractSingleType['base']>;
10 |
--------------------------------------------------------------------------------
/admin/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig",
3 | "include": ["./src", "./custom.d.ts"],
4 | "exclude": ["**/*.test.ts", "**/*.test.tsx"],
5 | "compilerOptions": {
6 | "rootDir": "../",
7 | "baseUrl": ".",
8 | "outDir": "./dist"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/admin/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@strapi/typescript-utils/tsconfigs/admin",
3 | "include": ["./src", "./custom.d.ts"],
4 | "compilerOptions": {
5 | "rootDir": "../",
6 | "baseUrl": "."
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/logo.svg:
--------------------------------------------------------------------------------
1 |
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.4",
3 | "keywords": [
4 | "strapi select",
5 | "strapi multi selects",
6 | "strapi input",
7 | "strapi select fetch options"
8 | ],
9 | "type": "commonjs",
10 | "exports": {
11 | "./package.json": "./package.json",
12 | "./strapi-admin": {
13 | "types": "./dist/admin/src/index.d.ts",
14 | "source": "./admin/src/index.ts",
15 | "import": "./dist/admin/index.mjs",
16 | "require": "./dist/admin/index.js",
17 | "default": "./dist/admin/index.js"
18 | },
19 | "./strapi-server": {
20 | "types": "./dist/server/src/index.d.ts",
21 | "source": "./server/src/index.ts",
22 | "import": "./dist/server/index.mjs",
23 | "require": "./dist/server/index.js",
24 | "default": "./dist/server/index.js"
25 | }
26 | },
27 | "files": [
28 | "dist"
29 | ],
30 | "scripts": {
31 | "build": "strapi-plugin build",
32 | "watch": "strapi-plugin watch",
33 | "watch:link": "strapi-plugin watch:link",
34 | "verify": "strapi-plugin verify",
35 | "test:ts:front": "run -T tsc -p admin/tsconfig.json",
36 | "test:ts:back": "run -T tsc -p server/tsconfig.json"
37 | },
38 | "dependencies": {
39 | "@strapi/design-system": "^2.0.0-rc.12",
40 | "@strapi/icons": "^2.0.0-rc.12",
41 | "react-intl": "^6.8.4",
42 | "jsonpath": "^1.1.1",
43 | "lodash-es": "^4.17.21"
44 | },
45 | "devDependencies": {
46 | "@strapi/strapi": "^5.2.0",
47 | "@strapi/sdk-plugin": "^5.2.7",
48 | "prettier": "^3.3.3",
49 | "react": "^18.3.1",
50 | "react-dom": "^18.3.1",
51 | "react-router-dom": "^6.27.0",
52 | "styled-components": "^6.1.13",
53 | "@types/react": "^18.3.12",
54 | "@types/react-dom": "^18.3.1",
55 | "@strapi/typescript-utils": "^5.2.0",
56 | "typescript": "^5.6.3",
57 | "@types/jsonpath": "^0.2.4",
58 | "@types/lodash-es": "^4.17.12",
59 | "prettier-plugin-organize-imports": "^3.2.4"
60 | },
61 | "peerDependencies": {
62 | "@strapi/strapi": "^5.2.0",
63 | "@strapi/sdk-plugin": "^5.2.7",
64 | "react": "^18.3.1",
65 | "react-dom": "^18.3.1",
66 | "react-router-dom": "^6.27.0",
67 | "styled-components": "^6.1.13"
68 | },
69 | "strapi": {
70 | "name": "remote-select",
71 | "description": "A powerful tool that adds select type inputs to your strapi with the ability to dynamically load options via API. Supports static and searchable endpoints—autocomplete.",
72 | "kind": "plugin",
73 | "displayName": "Remote select"
74 | },
75 | "name": "strapi-plugin-remote-select",
76 | "description": "A powerful tool that adds select type inputs to your strapi with the ability to dynamically load options via API. Supports static and searchable endpoints—autocomplete.",
77 | "license": "MIT",
78 | "author": {
79 | "name": "Dmytro Nazarenko"
80 | },
81 | "repository": {
82 | "type": "git",
83 | "url": "https://github.com/dmitriy-nz/strapi-plugin-remote-select.git"
84 | },
85 | "engines": {
86 | "node": ">=18.0.0 <=20.x.x"
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/screenshots/remote-select-configured-window.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dmitriy-nz/strapi-plugin-remote-select/1da6a893029f1c63c4b7f95e206ac0fb3dc52cce/screenshots/remote-select-configured-window.png
--------------------------------------------------------------------------------
/screenshots/remote-select-input.multiple.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dmitriy-nz/strapi-plugin-remote-select/1da6a893029f1c63c4b7f95e206ac0fb3dc52cce/screenshots/remote-select-input.multiple.png
--------------------------------------------------------------------------------
/screenshots/remote-select-input.single.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dmitriy-nz/strapi-plugin-remote-select/1da6a893029f1c63c4b7f95e206ac0fb3dc52cce/screenshots/remote-select-input.single.png
--------------------------------------------------------------------------------
/screenshots/remote-select-settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dmitriy-nz/strapi-plugin-remote-select/1da6a893029f1c63c4b7f95e206ac0fb3dc52cce/screenshots/remote-select-settings.png
--------------------------------------------------------------------------------
/screenshots/searchable-remote-select-configured-window.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dmitriy-nz/strapi-plugin-remote-select/1da6a893029f1c63c4b7f95e206ac0fb3dc52cce/screenshots/searchable-remote-select-configured-window.png
--------------------------------------------------------------------------------
/screenshots/searchable-remote-select-input.multiple.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dmitriy-nz/strapi-plugin-remote-select/1da6a893029f1c63c4b7f95e206ac0fb3dc52cce/screenshots/searchable-remote-select-input.multiple.gif
--------------------------------------------------------------------------------
/screenshots/searchable-remote-select-input.single.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dmitriy-nz/strapi-plugin-remote-select/1da6a893029f1c63c4b7f95e206ac0fb3dc52cce/screenshots/searchable-remote-select-input.single.gif
--------------------------------------------------------------------------------
/screenshots/searchable-remote-select-settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dmitriy-nz/strapi-plugin-remote-select/1da6a893029f1c63c4b7f95e206ac0fb3dc52cce/screenshots/searchable-remote-select-settings.png
--------------------------------------------------------------------------------
/server/src/bootstrap.ts:
--------------------------------------------------------------------------------
1 | import type { Core } from '@strapi/strapi';
2 |
3 | const bootstrap = ({ strapi }: { strapi: Core.Strapi }) => {
4 | // bootstrap phase
5 | };
6 |
7 | export default bootstrap;
8 |
--------------------------------------------------------------------------------
/server/src/config/index.ts:
--------------------------------------------------------------------------------
1 | import { RemoteSelectPluginOptions } from '../../../types/RemoteSelectPluginOptions';
2 |
3 | export default {
4 | default: {
5 | variables: {},
6 | } as RemoteSelectPluginOptions,
7 | validator() {},
8 | };
9 |
--------------------------------------------------------------------------------
/server/src/content-types/index.ts:
--------------------------------------------------------------------------------
1 | export default {};
2 |
--------------------------------------------------------------------------------
/server/src/controllers/FetchOptionsProxy.controller.ts:
--------------------------------------------------------------------------------
1 | import type { Core } from '@strapi/strapi';
2 | import { errors } from '@strapi/utils';
3 | import { RemoteSelectFetchOptions } from '../../../types/RemoteSelectFetchOptions';
4 | import { OptionsProxyService } from '../services/OptionsProxy.service';
5 | import { RemoteSelectFetchOptionsSchema } from '../validation/RemoteSelectFetchOptions.schema';
6 |
7 | const { ValidationError } = errors;
8 | export default ({ strapi }: { strapi: Core.Strapi }) => ({
9 | async index(ctx: any): Promise {
10 | try {
11 | /**
12 | * Represents the configuration for a flexible select options fetch.
13 | */
14 | const flexibleSelectConfig = (await RemoteSelectFetchOptionsSchema.validate(
15 | ctx.request.body,
16 | {
17 | strict: true,
18 | stripUnknown: true, // Removing unknown fields
19 | abortEarly: false, // Returning all errors
20 | }
21 | )) as any as RemoteSelectFetchOptions;
22 |
23 | ctx.body = await (
24 | strapi.plugin('remote-select').service('OptionsProxyService') as ReturnType<
25 | typeof OptionsProxyService
26 | >
27 | ).getOptionsByConfig(flexibleSelectConfig);
28 | } catch (error) {
29 | // Handling error
30 | if (error.name === 'ValidationError')
31 | throw new ValidationError('Validation error', error.errors); // Throwing validation error
32 | throw error; // Throwing error
33 | }
34 | },
35 | });
36 |
--------------------------------------------------------------------------------
/server/src/controllers/index.ts:
--------------------------------------------------------------------------------
1 | import FetchOptionsProxyController from './FetchOptionsProxy.controller';
2 |
3 | export default {
4 | FetchOptionsProxyController,
5 | };
6 |
--------------------------------------------------------------------------------
/server/src/destroy.ts:
--------------------------------------------------------------------------------
1 | import type { Core } from '@strapi/strapi';
2 |
3 | const destroy = ({ strapi }: { strapi: Core.Strapi }) => {
4 | // destroy phase
5 | };
6 |
7 | export default destroy;
8 |
--------------------------------------------------------------------------------
/server/src/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Application methods
3 | */
4 | import bootstrap from './bootstrap';
5 | import destroy from './destroy';
6 | import register from './register';
7 |
8 | /**
9 | * Plugin server methods
10 | */
11 | import config from './config';
12 | import contentTypes from './content-types';
13 | import controllers from './controllers';
14 | import middlewares from './middlewares';
15 | import policies from './policies';
16 | import routes from './routes';
17 | import services from './services';
18 |
19 | export default {
20 | register,
21 | bootstrap,
22 | destroy,
23 | config,
24 | controllers,
25 | routes,
26 | services,
27 | contentTypes,
28 | policies,
29 | middlewares,
30 | };
31 |
--------------------------------------------------------------------------------
/server/src/middlewares/index.ts:
--------------------------------------------------------------------------------
1 | export default {};
2 |
--------------------------------------------------------------------------------
/server/src/policies/index.ts:
--------------------------------------------------------------------------------
1 | export default {};
2 |
--------------------------------------------------------------------------------
/server/src/register.ts:
--------------------------------------------------------------------------------
1 | import type { Core } from '@strapi/strapi';
2 | import pluginId from '../../admin/src/pluginId';
3 |
4 | const register = ({ strapi }: { strapi: Core.Strapi }) => {
5 | strapi.customFields.register({
6 | name: 'remote-select',
7 | plugin: pluginId,
8 | type: 'text',
9 | });
10 |
11 | strapi.customFields.register({
12 | name: 'searchable-remote-select',
13 | plugin: pluginId,
14 | type: 'text',
15 | });
16 | };
17 |
18 | export default register;
19 |
--------------------------------------------------------------------------------
/server/src/routes/index.ts:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | method: 'POST',
4 | path: '/options-proxy',
5 | handler: 'FetchOptionsProxyController.index',
6 | config: {
7 | policies: [],
8 | auth: false,
9 | },
10 | },
11 | ];
12 |
--------------------------------------------------------------------------------
/server/src/services/OptionsProxy.service.ts:
--------------------------------------------------------------------------------
1 | import { Core } from '@strapi/strapi';
2 | import { query } from 'jsonpath';
3 | import type { FlexibleSelectMappingConfig } from '../../../types/FlexibleSelectConfig';
4 | import type { RemoteSelectFetchOptions } from '../../../types/RemoteSelectFetchOptions';
5 | import { RemoteSelectPluginOptions } from '../../../types/RemoteSelectPluginOptions';
6 | import { SearchableRemoteSelectValue } from '../../../types/SearchableRemoteSelectValue';
7 |
8 | export const OptionsProxyService = ({ strapi }: { strapi: Core.Strapi }) => ({
9 | /**
10 | * Fetches options based on a provided configuration object, processes the response,
11 | * and maps the data into the desired format.
12 | *
13 | * @param config - The configuration object containing fetch details,
14 | * including URL, method, headers, body, and mapping instructions for processing the response.
15 | * @return A promise that resolves to the processed options extracted and mapped from the response.
16 | */
17 | async getOptionsByConfig(config: RemoteSelectFetchOptions) {
18 | const fetchOptions = {
19 | method: config.fetch.method,
20 | headers: this.parseStringHeaders(config.fetch.headers)
21 | }
22 |
23 | if(config.fetch.method !== 'GET' && config.fetch.body) {
24 | fetchOptions['body'] = this.replaceVariables(config.fetch.body);
25 | }
26 |
27 | const res = await fetch(this.replaceVariables(config.fetch.url), fetchOptions);
28 |
29 | const response = await res.json();
30 |
31 | return this.parseOptions(response, config.mapping);
32 | },
33 |
34 | /**
35 | * Parses a string of headers into an object where each key is a header name and each value is the corresponding header value.
36 | *
37 | * @param [headers] - A string representing the headers, where each header is separated by a newline and the key-value pairs are separated by a colon.
38 | * @return An object containing the parsed headers where the keys are the header names in lowercase, and the values are the corresponding header values.
39 | */
40 | parseStringHeaders(headers?: string): Record {
41 | if (!headers) return {};
42 |
43 | const result: Record = {};
44 |
45 | headers = this.replaceVariables(headers);
46 |
47 | const headersArr = this.trim(headers).split('\n');
48 |
49 | for (let i = 0; i < headersArr.length; i++) {
50 | const row = headersArr[i];
51 | const index = row.indexOf(':'),
52 | key = this.trim(row.slice(0, index)).toLowerCase(),
53 | value = this.trim(row.slice(index + 1));
54 |
55 | if (typeof result[key] === 'undefined') {
56 | result[key] = value;
57 | } else {
58 | result[key] = `${result[key]}, ${value}`;
59 | }
60 | }
61 |
62 | return result;
63 | },
64 |
65 | /**
66 | * Removes leading and trailing whitespace characters from a given string.
67 | *
68 | * @param {string} val - The string to be trimmed.
69 | * @return {string} The trimmed string without leading or trailing whitespace.
70 | */
71 | trim(val: string): string {
72 | return val.replace(/^\s+|\s+$/g, '');
73 | },
74 |
75 | /**
76 | * Parses options from the provided response using the given mapping configuration.
77 | *
78 | * @param {any} response - The JSON response to parse and extract options from.
79 | * @param mappingConfig - The configuration defining the paths for extracting values and labels.
80 | * @return {SearchableRemoteSelectValue[]} An array of unique options with `value` and `label` properties.
81 | */
82 | parseOptions(
83 | response: any,
84 | mappingConfig: FlexibleSelectMappingConfig
85 | ): SearchableRemoteSelectValue[] {
86 | /**
87 | * Query options for mapping JSON response.
88 | */
89 | const options = query(response, mappingConfig.sourceJsonPath || '$');
90 |
91 | /**
92 | * Filter and map options array to prepare options with value and label.
93 | *
94 | * @param {Array} options - The options array to filter and map.
95 | * @returns {Array} The prepared options array with value and label.
96 | */
97 | const preparedOptionsArray = options
98 | .filter((item: any) => item !== undefined && item !== null)
99 | .map((option: any) => {
100 | if (typeof option !== 'object') {
101 | return {
102 | value: option,
103 | label: option,
104 | };
105 | }
106 |
107 | const value = this.getOptionItem(option, mappingConfig.valueJsonPath);
108 | const label = this.getOptionItem(option, mappingConfig.labelJsonPath);
109 |
110 | return {
111 | value,
112 | label,
113 | };
114 | });
115 |
116 | const uniqueValuesOptionsMap: Map =
117 | preparedOptionsArray.reduce(
118 | (store: Map, option: SearchableRemoteSelectValue) => {
119 | if (!store.has(option.value)) {
120 | store.set(option.value, option);
121 | }
122 | return store;
123 | },
124 | new Map()
125 | );
126 |
127 | /**
128 | * Convert Map to array of unique values
129 | */
130 | return Array.from(uniqueValuesOptionsMap.values());
131 | },
132 |
133 | /**
134 | * Retrieves the value of a specific item from a JSON object based on a given JSON path.
135 | * If the item is not a string, it is converted to a string representation using JSON.stringify.
136 | *
137 | * @param {any} rawOption - The JSON object from which to extract the item.
138 | * @param {string} jsonPath - The JSON path to locate the item. Defaults to "$" (root object).
139 | *
140 | * @return {string} The value of the item as a string.
141 | */
142 | getOptionItem(rawOption: any, jsonPath?: string): string {
143 | const value = query(rawOption, jsonPath || '$', 1)?.[0];
144 |
145 | if (typeof value !== 'string') {
146 | if (typeof value === 'number') {
147 | return value.toString();
148 | } else {
149 | return JSON.stringify(value);
150 | }
151 | }
152 |
153 | return value;
154 | },
155 |
156 | /**
157 | * Replaces variables in a given string with corresponding values from the configuration.
158 | * Variables in the input string are denoted by `{variableName}`.
159 | *
160 | * @param {string} str - The input string containing variables to be replaced.
161 | * @return {string} The string with variables replaced by their corresponding values.
162 | * If a variable does not exist in the configuration, it remains unchanged.
163 | */
164 | replaceVariables(str: string): string {
165 | const variables =
166 | strapi.config.get('plugin.remote-select')?.variables ?? {};
167 |
168 | if (!str || typeof str !== 'string') {
169 | return str;
170 | }
171 |
172 | return str.replace(/\{([^}]+)\}/g, (match, key) => {
173 | return variables[key] !== undefined ? String(variables[key]) : match;
174 | });
175 | },
176 | });
177 | export default OptionsProxyService;
178 |
--------------------------------------------------------------------------------
/server/src/services/index.ts:
--------------------------------------------------------------------------------
1 | import { OptionsProxyService } from './OptionsProxy.service';
2 |
3 | export default {
4 | // service,
5 | OptionsProxyService,
6 | };
7 |
--------------------------------------------------------------------------------
/server/src/services/service.ts:
--------------------------------------------------------------------------------
1 | import type { Core } from '@strapi/strapi';
2 | import { query } from 'jsonpath';
3 |
4 | const service = ({ strapi }: { strapi: Core.Strapi }) => ({
5 | getWelcomeMessage() {
6 | return query({}, '$', 1)?.[0];
7 | },
8 | });
9 |
10 | export default service;
11 |
--------------------------------------------------------------------------------
/server/src/validation/RemoteSelectFetchOptions.schema.ts:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 |
3 | export const RemoteSelectFetchOptionsSchema = yup.object().shape({
4 | fetch: yup.object().shape({
5 | url: yup.string().required(),
6 | headers: yup.string().optional(),
7 | body: yup.string().optional(),
8 | }),
9 | mapping: yup.object().shape({
10 | sourceJsonPath: yup.string().required(),
11 | valueJsonPath: yup.string().optional(),
12 | labelJsonPath: yup.string().optional(),
13 | }),
14 | });
15 |
--------------------------------------------------------------------------------
/server/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig",
3 | "include": ["./src"],
4 | "exclude": ["**/*.test.ts"],
5 | "compilerOptions": {
6 | "rootDir": "../",
7 | "baseUrl": ".",
8 | "outDir": "./dist"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@strapi/typescript-utils/tsconfigs/server",
3 | "include": ["./src"],
4 | "compilerOptions": {
5 | "rootDir": "../",
6 | "baseUrl": "."
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/types/FlexibleSelectConfig.ts:
--------------------------------------------------------------------------------
1 | export interface FlexibleSelectConfig {
2 | fetch: FlexibleSelectFetchConfig;
3 | mapping: FlexibleSelectMappingConfig;
4 | select: FlexibleSelectSelectConfig;
5 | }
6 |
7 | export interface FlexibleSelectFetchConfig {
8 | url: string;
9 | method: 'GET' | 'POST' | 'PUT';
10 | body?: string;
11 | headers?: string;
12 | }
13 |
14 | export interface FlexibleSelectMappingConfig {
15 | sourceJsonPath: string;
16 | labelJsonPath: string;
17 | valueJsonPath: string;
18 | }
19 |
20 | export interface FlexibleSelectSelectConfig {
21 | multi: boolean;
22 | }
23 |
--------------------------------------------------------------------------------
/types/RemoteSelectFetchOptions.ts:
--------------------------------------------------------------------------------
1 | import { FlexibleSelectConfig } from './FlexibleSelectConfig';
2 |
3 | export type RemoteSelectFetchOptions = Pick;
4 |
--------------------------------------------------------------------------------
/types/RemoteSelectPluginOptions.ts:
--------------------------------------------------------------------------------
1 | export interface RemoteSelectPluginOptions {
2 | variables: Record;
3 | }
4 |
--------------------------------------------------------------------------------
/types/SearchableRemoteSelectValue.ts:
--------------------------------------------------------------------------------
1 | export interface SearchableRemoteSelectValue {
2 | label: string;
3 | value: string;
4 | }
5 |
--------------------------------------------------------------------------------