├── .editorconfig ├── .eslintignore ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── admin ├── custom.d.ts ├── src │ ├── components │ │ ├── Initializer.tsx │ │ ├── PluginIcon.tsx │ │ ├── RemoteSelect │ │ │ ├── RemoteSelect.tsx │ │ │ └── registerRemoteSelect.ts │ │ ├── RemoteSelectInputIcon │ │ │ └── index.tsx │ │ ├── SearchableRemoteSelect │ │ │ ├── SearchableRemoteSelect.tsx │ │ │ └── registerSearchableRemoteSelect.ts │ │ └── SearchableRemoteSelectInputIcon │ │ │ └── index.tsx │ ├── index.ts │ ├── pages │ │ ├── App.tsx │ │ └── HomePage.tsx │ ├── pluginId.ts │ ├── translations │ │ └── en.json │ └── utils │ │ ├── getRemoteSelectRegisterOptions.ts │ │ ├── getTrad.ts │ │ ├── getTranslation.ts │ │ └── types.ts ├── tsconfig.build.json └── tsconfig.json ├── logo.svg ├── package-lock.json ├── package.json ├── screenshots ├── remote-select-configured-window.png ├── remote-select-input.multiple.png ├── remote-select-input.single.png ├── remote-select-settings.png ├── searchable-remote-select-configured-window.png ├── searchable-remote-select-input.multiple.gif ├── searchable-remote-select-input.single.gif └── searchable-remote-select-settings.png ├── server ├── src │ ├── bootstrap.ts │ ├── config │ │ └── index.ts │ ├── content-types │ │ └── index.ts │ ├── controllers │ │ ├── FetchOptionsProxy.controller.ts │ │ └── index.ts │ ├── destroy.ts │ ├── index.ts │ ├── middlewares │ │ └── index.ts │ ├── policies │ │ └── index.ts │ ├── register.ts │ ├── routes │ │ └── index.ts │ ├── services │ │ ├── OptionsProxy.service.ts │ │ ├── index.ts │ │ └── service.ts │ └── validation │ │ └── RemoteSelectFetchOptions.schema.ts ├── tsconfig.build.json └── tsconfig.json ├── types ├── FlexibleSelectConfig.ts ├── RemoteSelectFetchOptions.ts ├── RemoteSelectPluginOptions.ts └── SearchableRemoteSelectValue.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{package.json,*.yml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintignore 2 | /node_modules/** 3 | /public/** 4 | /build/** 5 | /dist/** 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ############################ 2 | # OS X 3 | ############################ 4 | 5 | .DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | Icon 9 | .Spotlight-V100 10 | .Trashes 11 | ._* 12 | 13 | 14 | ############################ 15 | # Linux 16 | ############################ 17 | 18 | *~ 19 | 20 | 21 | ############################ 22 | # Windows 23 | ############################ 24 | 25 | Thumbs.db 26 | ehthumbs.db 27 | Desktop.ini 28 | $RECYCLE.BIN/ 29 | *.cab 30 | *.msi 31 | *.msm 32 | *.msp 33 | 34 | 35 | ############################ 36 | # Packages 37 | ############################ 38 | 39 | *.7z 40 | *.csv 41 | *.dat 42 | *.dmg 43 | *.gz 44 | *.iso 45 | *.jar 46 | *.rar 47 | *.tar 48 | *.zip 49 | *.com 50 | *.class 51 | *.dll 52 | *.exe 53 | *.o 54 | *.seed 55 | *.so 56 | *.swo 57 | *.swp 58 | *.swn 59 | *.swm 60 | *.out 61 | *.pid 62 | 63 | 64 | ############################ 65 | # Logs and databases 66 | ############################ 67 | 68 | .tmp 69 | *.log 70 | *.sql 71 | *.sqlite 72 | *.sqlite3 73 | 74 | 75 | ############################ 76 | # Misc. 77 | ############################ 78 | 79 | *# 80 | ssl 81 | .idea 82 | nbproject 83 | public/uploads/* 84 | !public/uploads/.gitkeep 85 | 86 | ############################ 87 | # Node.js 88 | ############################ 89 | 90 | lib-cov 91 | lcov.info 92 | pids 93 | logs 94 | results 95 | node_modules 96 | .node_history 97 | 98 | ############################ 99 | # Tests 100 | ############################ 101 | 102 | coverage 103 | 104 | ############################ 105 | # Strapi 106 | ############################ 107 | 108 | .env 109 | license.txt 110 | exports 111 | *.cache 112 | dist 113 | build 114 | .strapi-updater.json 115 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | /.output 4 | /.vscode 5 | /node_modules 6 | 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "tabWidth": 2, 4 | "printWidth": 100, 5 | "singleQuote": true, 6 | "trailingComma": "es5", 7 | "plugins": ["prettier-plugin-organize-imports"] 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Dmytro Nazarenko 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Remote select](https://github.com/dmitriy-nz/strapi-plugin-remote-select/raw/main/logo.svg) 2 | 3 | 🎉 **Supports Strapi 4 and Strapi 5** 🎉 4 | 5 | A powerful tool that adds select type inputs to your strapi with the ability to dynamically load options via API. 6 | Supports static and searchable endpoints—autocomplete. 7 | This module adds two inputs: 8 | 9 | **Remote select**, allow selecting one or several values from the remote options endpoint. 10 | **Searchable select**, allow selecting one or several values from the searchable remote endpoint. 11 | 12 | Features (all selects support remote options loading): 13 | 14 | - plain select 15 | - multi select 16 | - searchable select, single model 17 | - searchable select, multiple model 18 | 19 | ## 💭 Motivation 20 | 21 | Too often I've had situations where I need to allow selecting one or more values from a list that is available as an API. 22 | Simply transferring the value is not an option, as the values can change dynamically on the remote resource, 23 | and the native Select has the same value as the one displayed to the user, which is not always convenient. 24 | That's why I was inspired to create this module, which solves this problem, you just need to add the desired input and configure it and that's it, you will always get the latest options, and the ability to use the searchable API or auto-complete significantly expands the use cases for this module. 25 | 26 | ## ⚠️ Requirements 27 | 28 | | Strapi version | Requirements | 29 | | -------------- | ----------------------------------------------------------- | 30 | | **Strapi 5** | `"node": ">=18.0.0 <=20.x.x"` `"@strapi/strapi": "^5.2.0"` | 31 | | **Strapi 4** | `"node": ">=18.0.0 <=20.x.x"` `"@strapi/strapi": "^4.24.5"` | 32 | 33 | ## 🔃 Versions 34 | 35 | This plugin supports several strapi versions, use this table to choose the correct one for your strapi version 36 | 37 | | Strapi 4 | Strapi 5 | 38 | | -------- | -------- | 39 | | 1.x.x | 2.x.x | 40 | 41 | ## ⏳ Installation 42 | 43 | ```bash 44 | # using yarn 45 | yarn add strapi-plugin-remote-select 46 | 47 | # using npm 48 | npm install strapi-plugin-remote-select --save 49 | ``` 50 | 51 | Enable plugin in your `config/plugins.js` file, just add: 52 | 53 | ```js 54 | module.exports = { 55 | 'remote-select': { 56 | enabled: true, 57 | }, 58 | }; 59 | ``` 60 | 61 | ## 🪄 Usage 62 | 63 | Each input select that this plugin adds has a similar configuration: 64 | 65 | ### Basic settings 66 | 67 | Module is using JSON path for allow configurable way to get an option array, label, and value for options objects. Learn more about [JSON path here](https://github.com/dchester/jsonpath) 68 | 69 | | Field name | Type | Description | 70 | | --------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | 71 | | Fetch options url | `string` | A url for fetch options for select. Endpoint should return a valid json string as response | 72 | | Fetch options method | `string` | HTTP method for requesting options. Default: `GET` | 73 | | Fetch options request body | `string` | HTTP body for requesting options. Provide a your custom body for options fetching. | 74 | | Fetch options request custom headers | `string` | Custom fetch options request headers in raw format, one header per line. For example: Content-type: application/json | 75 | | JSON path to options array | `string` | `$` - here it is the body answer to the options request | 76 | | JSON path to label for each item object | `string` | `$` - here it is the each options item selected from "JSON path to options array" | 77 | | JSON path to value for each item object | `string` | `$` - here it is the each options item selected from "JSON path to options array" | 78 | 79 | Advanced settings: 80 | 81 | | Field name | Type | Description | 82 | | ------------- | -------- | ----------- | 83 | | Default value | `string` | | 84 | | Multi mode | `string` | | 85 | | Private field | `string` | | 86 | 87 | 88 | ## Variables in API Requests 89 | The plugin now supports using variables in your API URLs, headers, and request bodies. 90 | This allows for dynamic configuration of your API requests. 91 | 92 | ### Setting up variables 93 | Add variables to your plugin configuration in `config/plugins.js`: 94 | 95 | ``` js 96 | module.exports = { 97 | "remote-select": { 98 | enabled: true, 99 | variables: { 100 | apiBaseUrl: "https://api.example.com", 101 | apiVersion: "v2", 102 | authToken: "your-auth-token" 103 | } 104 | }, 105 | }; 106 | ``` 107 | 108 | ### Using variables 109 | You can use variables in your configuration by surrounding the variable name with curly braces: 110 | - In API URLs: `{apiBaseUrl}/products/{apiVersion}/list` 111 | - In request headers: `Authorization: Bearer {authToken}` 112 | - In request bodies: `{ "version": "{apiVersion}" }` 113 | 114 | Variables provide a convenient way to: 115 | - Manage environment-specific API endpoints 116 | - Share authentication tokens across multiple select configurations 117 | - Update common values in one place instead of modifying each select configuration 118 | 119 | If a variable isn't defined in your configuration, the placeholder will remain unchanged in the request. 120 | 121 | 122 | ### Remote select input 123 | 124 | Depends on `multi` option you will have in the model a single string from selected `value` option or array of selected `value` string. 125 | 126 | Basic configuration window: 127 | 128 | ![Remote select settings window](https://github.com/dmitriy-nz/strapi-plugin-remote-select/raw/main/screenshots/remote-select-settings.png) 129 | 130 | for example, let's consider the next api endpoint 'https://dummyjson.com/products' with response structure: 131 | 132 | ```json 133 | { 134 | "products": [ 135 | { 136 | "id": 1, 137 | "title": "Essence Mascara Lash Princess", 138 | "description": "The Essence Mascara Lash Princess is a popular mascara known for its volumizing and lengthening effects. Achieve dramatic lashes with this long-lasting and cruelty-free formula.", 139 | "category": "beauty", 140 | "price": 9.99, 141 | "discountPercentage": 7.17, 142 | "rating": 4.94, 143 | "stock": 5 144 | }, 145 | { 146 | "id": 2, 147 | "title": "Eyeshadow Palette with Mirror", 148 | "description": "The Eyeshadow Palette with Mirror offers a versatile range of eyeshadow shades for creating stunning eye looks. With a built-in mirror, it's convenient for on-the-go makeup application.", 149 | "category": "beauty", 150 | "price": 19.99, 151 | "discountPercentage": 5.5, 152 | "rating": 3.28, 153 | "stock": 44 154 | }, 155 | ... 156 | ] 157 | } 158 | ``` 159 | 160 | Configured remote select window will look like that: 161 | _We are going to use `id` field from a product object as value and `title` field as label_ 162 | 163 | ![Remote select example configured settings window](https://github.com/dmitriy-nz/strapi-plugin-remote-select/raw/main/screenshots/remote-select-configured-window.png) 164 | 165 | and as a result, we have: (single mode) 166 | 167 | ![Remote select single input](https://github.com/dmitriy-nz/strapi-plugin-remote-select/raw/main/screenshots/remote-select-input.single.png) 168 | 169 | multiple mode: 170 | 171 | ![Remote select multi input](https://github.com/dmitriy-nz/strapi-plugin-remote-select/raw/main/screenshots/remote-select-input.multiple.png) 172 | 173 | ### Searchable remote select input 174 | 175 | Depends on `multi` option you will have in the model a JSON string with the object: 176 | 177 | ```json 178 | { 179 | "label": "Label from option", 180 | "value": "Value from option" 181 | } 182 | ``` 183 | 184 | or JSON string with the array of objects: 185 | 186 | ```json 187 | [ 188 | { 189 | "label": "Label from option", 190 | "value": "Value from option" 191 | }, 192 | { 193 | "label": "Label from option", 194 | "value": "Value from option" 195 | } 196 | ] 197 | ``` 198 | 199 | Basic configuration window: 200 | It's the same as in Remote select input: 201 | 202 | ![Remote select settings window](https://github.com/dmitriy-nz/strapi-plugin-remote-select/raw/main/screenshots/searchable-remote-select-input.single.gif) 203 | 204 | for example, let's consider the next api endpoint with search ability 'https://dummyjson.com/products/search?q=searchphrase' with the same response structure like as in Remote select example. 205 | 206 | Configured remote select window will look like that: 207 | 208 | ![Searchable remote select configured window](https://github.com/dmitriy-nz/strapi-plugin-remote-select/raw/main/screenshots/searchable-remote-select-configured-window.png) 209 | 210 | and as a result, we have: (single mode) 211 | 212 | ![Searchable remote select single](https://github.com/dmitriy-nz/strapi-plugin-remote-select/raw/main/screenshots/searchable-remote-select-input.single.gif) 213 | 214 | multiple mode: 215 | 216 | ![Searchable remote select multi input](https://github.com/dmitriy-nz/strapi-plugin-remote-select/raw/main/screenshots/searchable-remote-select-input.multiple.gif) 217 | 218 | -------------------------------------------------------------------------------- /admin/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@strapi/design-system/*'; 2 | declare module '@strapi/design-system'; 3 | -------------------------------------------------------------------------------- /admin/src/components/Initializer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import { PLUGIN_ID } from '../pluginId'; 4 | 5 | type InitializerProps = { 6 | setPlugin: (id: string) => void; 7 | }; 8 | 9 | const Initializer = ({ setPlugin }: InitializerProps) => { 10 | const ref = useRef(setPlugin); 11 | 12 | useEffect(() => { 13 | ref.current(PLUGIN_ID); 14 | }, []); 15 | 16 | return null; 17 | }; 18 | 19 | export { Initializer }; 20 | -------------------------------------------------------------------------------- /admin/src/components/PluginIcon.tsx: -------------------------------------------------------------------------------- 1 | import { PuzzlePiece } from '@strapi/icons'; 2 | 3 | const PluginIcon = () => ; 4 | 5 | export { PluginIcon }; 6 | -------------------------------------------------------------------------------- /admin/src/components/RemoteSelect/RemoteSelect.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Field, 3 | MultiSelect, 4 | MultiSelectOption, 5 | SingleSelect, 6 | SingleSelectOption, 7 | } from '@strapi/design-system'; 8 | import { useEffect, useMemo, useState } from 'react'; 9 | import { useIntl } from 'react-intl'; 10 | import { FlexibleSelectConfig } from '../../../../types/FlexibleSelectConfig'; 11 | import { SearchableRemoteSelectValue } from '../../../../types/SearchableRemoteSelectValue'; 12 | 13 | export default function RemoteSelect({ 14 | value, 15 | onChange, 16 | name, 17 | label, 18 | required, 19 | attribute, 20 | hint, 21 | placeholder, 22 | disabled, 23 | error, 24 | }: any) { 25 | const defaultPlaceholder = { 26 | id: 'remote-select.select.placeholder', 27 | defaultMessage: 'Select a value', 28 | }; 29 | const selectConfiguration: FlexibleSelectConfig = attribute.options; 30 | 31 | const { formatMessage } = useIntl(); 32 | const isMulti = useMemo( 33 | () => !!selectConfiguration.select?.multi, 34 | [selectConfiguration] 35 | ); 36 | const [isLoading, setIsLoading] = useState(true); 37 | const [options, setOptions] = useState>([]); 38 | const [optionsLoadingError, setLoadingError] = useState(); 39 | 40 | const valueParsed = useMemo(() => { 41 | if (isMulti) { 42 | if (!value) { 43 | return []; 44 | } 45 | 46 | try { 47 | return JSON.parse(value); 48 | } catch (err) { 49 | return [value]; 50 | } 51 | } 52 | 53 | return value; 54 | }, [value]); 55 | 56 | useEffect(() => { 57 | loadOptions(); 58 | }, []); 59 | 60 | async function loadOptions(): Promise { 61 | setIsLoading(true); 62 | try { 63 | const res = await fetch('/remote-select/options-proxy', { 64 | method: 'POST', 65 | body: JSON.stringify({ 66 | fetch: selectConfiguration.fetch, 67 | mapping: selectConfiguration.mapping, 68 | }), 69 | headers: { 70 | 'Content-Type': 'application/json', 71 | }, 72 | }); 73 | 74 | if (res.status === 200) { 75 | setOptions(await res.json()); 76 | } else { 77 | setLoadingError(res.statusText + ', code: ' + res.status); 78 | } 79 | } catch (err) { 80 | setLoadingError((err as any)?.message || err?.toString()); 81 | } finally { 82 | setIsLoading(false); 83 | } 84 | } 85 | 86 | function handleChange(value?: string | string[]) { 87 | if (isMulti) { 88 | value = Array.isArray(value) ? value : []; 89 | value = value.filter((el) => el !== undefined && el !== null); 90 | value = (value as string[]).length ? JSON.stringify(value) : undefined; 91 | } 92 | 93 | onChange({ 94 | target: { name, type: attribute.type, value: value }, 95 | }); 96 | } 97 | 98 | function clear(event: PointerEvent) { 99 | event.stopPropagation(); 100 | event.preventDefault(); 101 | handleChange(undefined); 102 | } 103 | 104 | const optionsList = options.map((opt) => { 105 | return isMulti ? ( 106 | 107 | {opt.label} 108 | 109 | ) : ( 110 | 111 | {opt.label} 112 | 113 | ); 114 | }); 115 | 116 | const SelectToRender = isMulti ? MultiSelect : SingleSelect; 117 | 118 | return ( 119 | 120 | {label} 121 | 134 | {optionsLoadingError && 135 | `Options loading error: ${optionsLoadingError}. Please check the field configuration`} 136 | {optionsList} 137 | 138 | 139 | 140 | 141 | ); 142 | } 143 | -------------------------------------------------------------------------------- /admin/src/components/RemoteSelect/registerRemoteSelect.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 RemoteSelectInputIcon from '../RemoteSelectInputIcon'; 6 | 7 | export function registerRemoteSelect(app: StrapiApp): void { 8 | app.customFields.register({ 9 | name: 'remote-select', 10 | pluginId: pluginId, 11 | type: "text", 12 | intlLabel: { 13 | id: getTrad('remote-select.label'), 14 | defaultMessage: 'Remote select', 15 | }, 16 | intlDescription: { 17 | id: getTrad('remote-select.description'), 18 | defaultMessage: 'Select with remote options fetching', 19 | }, 20 | icon: RemoteSelectInputIcon, 21 | components: { 22 | Input: async () => import(/* webpackChunkName: "RemoteSelect" */ './RemoteSelect'), 23 | }, 24 | options: getRemoteSelectRegisterOptions('base'), 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /admin/src/components/RemoteSelectInputIcon/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * PluginIcon 4 | * 5 | */ 6 | 7 | const RemoteSelectInputIcon = () => ( 8 | 9 | 15 | 19 | 20 | ); 21 | 22 | export default RemoteSelectInputIcon; 23 | -------------------------------------------------------------------------------- /admin/src/components/SearchableRemoteSelect/SearchableRemoteSelect.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, Combobox, ComboboxOption, Field, Flex, Tag } from '@strapi/design-system'; 2 | import { Cross } from '@strapi/icons'; 3 | import { debounce } from 'lodash-es'; 4 | import { useCallback, useEffect, useId, useMemo, useState } from 'react'; 5 | import { useIntl } from 'react-intl'; 6 | import { FlexibleSelectConfig } from '../../../../types/FlexibleSelectConfig'; 7 | import { SearchableRemoteSelectValue } from '../../../../types/SearchableRemoteSelectValue'; 8 | 9 | export default function SearchableRemoteSelect(attrs: any) { 10 | const { name, error, hint, onChange, value, label, attribute, required } = attrs; 11 | 12 | const selectConfiguration: FlexibleSelectConfig = attribute.options; 13 | 14 | const generatedId = useId(); 15 | const { formatMessage } = useIntl(); 16 | const [options, setOptions] = useState>([]); 17 | const [loadingError, setLoadingError] = useState(undefined); 18 | const [isLoading, setIsLoading] = useState(false); 19 | const isMulti = useMemo( 20 | () => !!selectConfiguration.select?.multi, 21 | [selectConfiguration] 22 | ); 23 | const valueParsed = useMemo< 24 | SearchableRemoteSelectValue | SearchableRemoteSelectValue[] | undefined 25 | >(() => { 26 | if (isMulti) { 27 | if (!value || value === 'null') { 28 | return []; 29 | } 30 | 31 | try { 32 | const parseResult = JSON.parse(value); 33 | return Array.isArray(parseResult) ? parseResult : [parseResult]; 34 | } catch (err) { 35 | return []; 36 | } 37 | } else { 38 | if (!value) { 39 | return undefined; 40 | } 41 | 42 | try { 43 | const parseResult = JSON.parse(value); 44 | const option = Array.isArray(parseResult) ? parseResult[0] : parseResult; 45 | return !Object.keys(option).length ? undefined : option; 46 | } catch (err) { 47 | return undefined; 48 | } 49 | } 50 | }, [value]); 51 | const [searchModel, setSearchModel] = useState( 52 | valueParsed && isSingleParsed(valueParsed) ? valueParsed.label : '' 53 | ); 54 | const loadOptionsDebounced = useCallback( 55 | debounce((value: string) => { 56 | setIsLoading(true); 57 | loadOptions(value); 58 | }, 500), 59 | [] 60 | ); 61 | 62 | useEffect(() => { 63 | loadOptionsDebounced(valueParsed && isSingleParsed(valueParsed) ? valueParsed.label : ''); 64 | }, []); 65 | 66 | async function loadOptions(searchModel: string): Promise { 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 | 9 | 15 | 19 | 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 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 | --------------------------------------------------------------------------------