├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── lint-package.yml │ └── publish-package.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── docs └── example.png ├── package-lock.json ├── package.config.ts ├── package.json ├── sanity.json ├── src ├── components │ ├── ReferenceWarnings.tsx │ ├── TagsInput.module.css │ └── TagsInput.tsx ├── index.ts ├── modules.d.ts ├── schemas │ ├── tag.ts │ └── tags.ts ├── types.ts └── utils │ ├── client.ts │ ├── helpers.ts │ ├── hooks.ts │ ├── mutators.ts │ └── observables.ts ├── tsconfig.dist.json ├── tsconfig.json ├── tsconfig.settings.json └── v2-incompatible.js /.editorconfig: -------------------------------------------------------------------------------- 1 | ; editorconfig.org 2 | root = true 3 | charset= utf8 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js 2 | .eslintrc.js 3 | commitlint.config.js 4 | dist 5 | lint-staged.config.js 6 | package.config.ts 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "browser": true 6 | }, 7 | "extends": [ 8 | "sanity", 9 | "sanity/typescript", 10 | "sanity/react", 11 | "plugin:react-hooks/recommended", 12 | "plugin:prettier/recommended", 13 | "plugin:react/jsx-runtime" 14 | ], 15 | "rules": { 16 | "no-redeclare": "off", 17 | "require-await": "off" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/lint-package.yml: -------------------------------------------------------------------------------- 1 | name: Lint NPM Package 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout Git Repository 11 | uses: actions/checkout@v3 12 | - name: Setup Node.js 13 | uses: actions/setup-node@v2 14 | with: 15 | node-version: '16.x' 16 | registry-url: 'https://registry.npmjs.org' 17 | - name: Install Node.js Dependencies 18 | run: npm ci 19 | - name: Lint NPM Package 20 | run: npm run lint 21 | -------------------------------------------------------------------------------- /.github/workflows/publish-package.yml: -------------------------------------------------------------------------------- 1 | name: Publish NPM Package 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout Git Repository 10 | uses: actions/checkout@v3 11 | - run: | 12 | git config user.name pcbowers 13 | git config user.email pcbowers@gmail.com 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: '16.x' 18 | registry-url: 'https://registry.npmjs.org' 19 | - name: Install Node.js Dependencies 20 | run: npm ci 21 | - name: Version NPM Package 22 | run: "npm version ${{ github.event.release.tag_name }} -m 'chore(release): :bookmark: Updated Package Version to ${{ github.event.release.tag_name }} [skip-ci]'" 23 | - run: git push origin HEAD:main 24 | - name: Build, Lint and Publish NPM Package 25 | run: npm publish 26 | env: 27 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # macOS finder cache file 40 | .DS_Store 41 | 42 | # VS Code settings 43 | .vscode 44 | 45 | # IntelliJ 46 | .idea 47 | *.iml 48 | 49 | # Cache 50 | .cache 51 | 52 | # Yalc 53 | .yalc 54 | yalc.lock 55 | 56 | # npm package zips 57 | *.tgz 58 | 59 | # Compiled plugin 60 | dist 61 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | pnpm-lock.yaml 3 | yarn.lock 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "semi": false, 5 | "bracketSpacing": false, 6 | "printWidth": 100, 7 | "singleQuote": true, 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 P Christopher Bowers 4 | Copyright (c) 2023 Leo Baker-Hytch 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sanity-plugin-tags 2 | 3 | > This is a **Sanity Studio v3** plugin. 4 | 5 | A multi-tag input for sanity studio. Fully featured with autocomplete capabilities, live updates, predefined tag options, style and component customizability, and much more. 6 | 7 | ![Example Picture](https://github.com/pcbowers/sanity-plugin-tags/blob/main/docs/example.png?raw=true) 8 | 9 | ## Install 10 | 11 | Sanity v3: `npm install sanity-plugin-tags` 12 | Sanity v2: `sanity install tags` 13 | 14 | ## Use 15 | 16 | Add it as a plugin in `sanity.config.ts` (or .js): 17 | 18 | ```ts 19 | import {defineConfig} from 'sanity' 20 | import {tags} from 'sanity-plugin-tags' 21 | 22 | export default defineConfig({ 23 | //... 24 | plugins: [tags({})], 25 | }) 26 | ``` 27 | 28 | Simply use 'tag' or 'tags' as a type (single or multi select respectively) in your fields. If you want autocompletion, set the `includeFromRelated` option to the name of your field. 29 | 30 | That's it! It will even update the autocompletion list live as changes are made to other documents! 31 | 32 | Dive into the [Options Section](#options) for more advanced use cases like predefined tags and the `onCreate` hook. 33 | 34 | ```javascript 35 | { 36 | name: 'myTags', 37 | title: 'Tags', 38 | type: 'tags', 39 | options: { 40 | includeFromRelated: 'myTags' 41 | ... 42 | } 43 | } 44 | ``` 45 | 46 | ## Options 47 | 48 | ```typescript 49 | { 50 | name: string, 51 | type: "tags" | "tag", 52 | options: { 53 | predefinedTags?: Tag | Tag[] | () => Tag[] | Tag | () => Promise 54 | includeFromReference?: false | string 55 | includeFromRelated?: false | string 56 | customLabel?: string 57 | customValue?: string 58 | allowCreate?: boolean 59 | onCreate?: (inputValue: string) => Tag | Promise 60 | checkValid?: (inputValue: string, currentValues: string[]) => boolean 61 | reactSelectOptions?: { 62 | [key: string]: any 63 | } 64 | }, 65 | //... all other Sanity Properties 66 | }, 67 | ``` 68 | 69 | ### What is a Tag? 70 | 71 | A tag is simply an object with a label and value. Example: 72 | 73 | ```json 74 | { 75 | "label": "My Tag", 76 | "value": "my-tag" 77 | } 78 | ``` 79 | 80 | This can be used for all sorts of things: categorization, single select, and much more. Essentially, if you want to limit people to a single or multi list of strings, tags will fit your use case perfectly. 81 | 82 | ### predefinedTags 83 | 84 | `default: []` 85 | 86 | This option allows you to add any tags that you would like to the autocomplete list. This can take any form from a single tag to an array of tags, to a function that dynamically returns a tag or tags. 87 | 88 | ```javascript 89 | { 90 | // ... 91 | predefinedTags: { label: "My Tag", value: 'my-tag' } 92 | // ... 93 | } 94 | ``` 95 | 96 | ```javascript 97 | { 98 | // ... 99 | predefinedTags: [ 100 | {label: 'My Tag 1', value: 'my-tag-1'}, 101 | {label: 'My Tag 2', value: 'my-tag-2'}, 102 | ] 103 | // ... 104 | } 105 | ``` 106 | 107 | ```javascript 108 | { 109 | // ... 110 | predefinedTags: async () => client.fetch(...) 111 | // ... 112 | } 113 | ``` 114 | 115 | ### includeFromReference 116 | 117 | `default: false` 118 | 119 | If you already have a sanity schema that contains a tag-like structure and want to add them to the autocomplete list, set this option to the name of your sanity schema document. This option applies no filters. If you would like to filter, use the `predefinedTags` option. 120 | 121 | ```javascript 122 | { 123 | // ... 124 | includeFromReference: 'category' 125 | // ... 126 | } 127 | ``` 128 | 129 | ### includeFromRelated 130 | 131 | `default: false` 132 | 133 | This option is similar to `includeFromReference`, but it allows you to add to the autocomplete list from a field in the related document. Typically, you would set this option to the name of the current field to allow autocompletion for tags that were already selected previously. 134 | 135 | ```javascript 136 | { 137 | // ... 138 | includeFromRelated: 'category' 139 | // ... 140 | } 141 | ``` 142 | 143 | ### customLabel 144 | 145 | `default: 'label'` 146 | 147 | If you want to change the label key for your tags, set this option. Useful when you want to use the default label key to store some other value. 148 | 149 | _Note: If you set this option, all tags specified by `predefinedTags` and the structure returned by `onCreate` **must** use this custom label_ 150 | 151 | ```javascript 152 | { 153 | // ... 154 | customLabel: 'myLabelKey' 155 | // ... 156 | } 157 | ``` 158 | 159 | ### customValue 160 | 161 | `default: 'value'` 162 | 163 | If you want to change the value key for your tags, set this option. Useful when you want to use the default value key to store some other value. 164 | 165 | _Note: If you set this option, all tags specified by `predefinedTags` and the structure returned by `onCreate` **must** use this custom value_ 166 | 167 | ```javascript 168 | { 169 | // ... 170 | customValue: 'myValueKey' 171 | // ... 172 | } 173 | ``` 174 | 175 | ### allowCreate 176 | 177 | `default: true` 178 | 179 | By default, new tags can be created inline from this input. If you implement the input with a reference, this does not work. See [Parts](#parts) for more information. 180 | 181 | ```javascript 182 | { 183 | // ... 184 | allowCreate: false 185 | // ... 186 | } 187 | ``` 188 | 189 | ### onCreate 190 | 191 | `default: (value) => ({ [customLabel]: value, [customValue]: value})` 192 | 193 | If you want to edit the label or value of the tag when a new one is created before saving it, use this hook. You do **not** need to specify this property if you set `customLabel` or `customValue` and like the default value. If you do specify it, make sure it returns an object that contains the custom label key and the custom value key. This hook provides an easy solution for 'slugifying' the label. 194 | 195 | ```javascript 196 | { 197 | // ... 198 | onCreate: (value) => ({ 199 | label: value, 200 | value: value.toLowerCase().replace(/\W/g, '-'), 201 | }) 202 | // ... 203 | } 204 | ``` 205 | 206 | ### checkValid 207 | 208 | `default: (inputValue: string, currentValues: string[]) => !currentValues.includes(inputValue) && !!inputValue && inputValue.trim() === inputValue` 209 | 210 | This allows you to check the validity of a tag when creation is allowed. `inputValue` contains the string of the input while `currentValues` contains an array of strings that represent all of the values of any options available to select as well as any already-selected options. 211 | 212 | ```javascript 213 | { 214 | // ... 215 | checkValid: (input, values) => { 216 | return ( 217 | !!input && 218 | input.trim() === input && 219 | !values.includes(input.trim().toLowerCase().replace(/\W/g, '-')) 220 | ) 221 | } 222 | // ... 223 | } 224 | ``` 225 | 226 | ### reactSelectOptions 227 | 228 | `default: {}` 229 | 230 | The input component uses [React Select](https://react-select.com/home) under the hood. If you want to change and override any of the options passed to the select component, specify this option. Specify this option at your own risk! 231 | 232 | If you want to override React Select's components see [Parts](#parts) for more information. 233 | 234 | ```javascript 235 | { 236 | // ... 237 | reactSelectOptions: { 238 | closeMenuOnSelect: false 239 | } 240 | // ... 241 | } 242 | ``` 243 | 244 | ## Develop & test 245 | 246 | ## Contribute 247 | 248 | I love feedback, and any help is appreciated! Feel free to install the plugin, submit an issue, or open a PR. 249 | 250 | This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit) 251 | with default configuration for build & watch scripts. 252 | 253 | See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio) 254 | on how to run this plugin with hotreload in the studio. 255 | 256 | ## Acknowledgements 257 | 258 | This plugin is based off of [sanity-plugin-autocomplete-tags](https://github.com/rosnovsky/sanity-plugin-autocomplete-tags), though it enhances it by adding a couple additional options while improving support for default sanity values like `initialValues` and `readOnly`. 259 | -------------------------------------------------------------------------------- /docs/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pcbowers/sanity-plugin-tags/6ec78cae461b2f1e06e5f03438e9c6d5ebe68db5/docs/example.png -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from '@sanity/pkg-utils' 2 | import postcss from 'rollup-plugin-postcss' 3 | 4 | export default defineConfig({ 5 | legacyExports: true, 6 | dist: 'dist', 7 | tsconfig: 'tsconfig.dist.json', 8 | 9 | // Remove this block to enable strict export validation 10 | extract: { 11 | rules: { 12 | 'ae-forgotten-export': 'off', 13 | 'ae-incompatible-release-tags': 'off', 14 | 'ae-internal-missing-underscore': 'off', 15 | 'ae-missing-release-tag': 'off', 16 | }, 17 | }, 18 | 19 | rollup: { 20 | plugins: [ 21 | postcss({ 22 | extract: false, 23 | modules: true, 24 | }), 25 | ], 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-plugin-tags", 3 | "version": "2.1.1", 4 | "description": "A multi-tag input for Sanity Studio", 5 | "keywords": [ 6 | "sanity", 7 | "sanity-plugin" 8 | ], 9 | "homepage": "https://github.com/pcbowers/sanity-plugin-tags#readme", 10 | "bugs": { 11 | "url": "https://github.com/pcbowers/sanity-plugin-tags/issues" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+ssh://git@github.com/pcbowers/sanity-plugin-tags.git" 16 | }, 17 | "license": "MIT", 18 | "exports": { 19 | ".": { 20 | "types": "./dist/index.d.ts", 21 | "source": "./src/index.ts", 22 | "require": "./dist/index.js", 23 | "import": "./dist/index.esm.js", 24 | "default": "./dist/index.esm.js" 25 | }, 26 | "./package.json": "./package.json" 27 | }, 28 | "main": "./dist/index.js", 29 | "module": "./dist/index.esm.js", 30 | "source": "./src/index.ts", 31 | "types": "./dist/index.d.ts", 32 | "files": [ 33 | "dist", 34 | "sanity.json", 35 | "src", 36 | "v2-incompatible.js" 37 | ], 38 | "scripts": { 39 | "build": "run-s clean && plugin-kit verify-package --silent && pkg-utils build --strict && pkg-utils --strict", 40 | "clean": "rimraf dist", 41 | "format": "prettier --write --cache --ignore-unknown .", 42 | "link-watch": "plugin-kit link-watch", 43 | "lint": "eslint .", 44 | "prepublishOnly": "run-s build", 45 | "watch": "pkg-utils watch --strict" 46 | }, 47 | "dependencies": { 48 | "@sanity/incompatible-plugin": "^1.0.4", 49 | "react-select": "^5.7.0" 50 | }, 51 | "devDependencies": { 52 | "@sanity/pkg-utils": "^2.2.13", 53 | "@sanity/plugin-kit": "^3.1.6", 54 | "@types/react": "^18.0.28", 55 | "@typescript-eslint/eslint-plugin": "^5.52.0", 56 | "@typescript-eslint/parser": "^5.52.0", 57 | "eslint": "^8.34.0", 58 | "eslint-config-prettier": "^8.6.0", 59 | "eslint-config-sanity": "^6.0.0", 60 | "eslint-plugin-prettier": "^4.2.1", 61 | "eslint-plugin-react": "^7.32.2", 62 | "eslint-plugin-react-hooks": "^4.6.0", 63 | "npm-run-all": "^4.1.5", 64 | "prettier": "^2.8.4", 65 | "prettier-plugin-packagejson": "^2.4.3", 66 | "react": "^18.2.0", 67 | "react-dom": "^18.2.0", 68 | "react-is": "^18.2.0", 69 | "rimraf": "^4.1.2", 70 | "rollup-plugin-postcss": "^4.0.2", 71 | "typescript": "^4.9.5" 72 | }, 73 | "peerDependencies": { 74 | "react": "^18", 75 | "rxjs": "^7.8.0", 76 | "sanity": "^3", 77 | "styled-components": "^5.3.6 || ^6.0.0" 78 | }, 79 | "engines": { 80 | "node": ">=14" 81 | }, 82 | "authors": [ 83 | "P Christopher Bowers ", 84 | "Leo Baker-Hytch " 85 | ], 86 | "sanityExchangeUrl": "https://www.sanity.io/plugins/tags" 87 | } 88 | -------------------------------------------------------------------------------- /sanity.json: -------------------------------------------------------------------------------- 1 | { 2 | "parts": [ 3 | { 4 | "implements": "part:@sanity/base/sanity-root", 5 | "path": "./v2-incompatible.js" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/components/ReferenceWarnings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Card} from '@sanity/ui' 3 | 4 | export const ReferenceCreateWarning = () => ( 5 | 6 | Tag References cannot be created inline. Please set the allowCreate option 7 | explicitly to false to remove this warning message. 8 | 9 | ) 10 | 11 | export const ReferencePredefinedWarning = () => ( 12 | 13 | Tag References cannot have predefined tags. Please unset the predefinedTags option 14 | to remove this warning message. 15 | 16 | ) 17 | -------------------------------------------------------------------------------- /src/components/TagsInput.module.css: -------------------------------------------------------------------------------- 1 | .control { 2 | background-color: var(--card-bg-color) !important; 3 | border-color: var(--card-border-color) !important; 4 | transition: none !important; 5 | } 6 | 7 | .input { 8 | color: var(--input-fg-color) !important; 9 | } 10 | 11 | .control:hover { 12 | border-color: var(--card-border-color) !important; 13 | } 14 | 15 | .menu { 16 | background-color: var(--card-bg-color) !important; 17 | border: 1px solid var(--card-border-color) !important; 18 | } 19 | 20 | .option { 21 | background-color: var(--card-bg-color) !important; 22 | color: var(--input-fg-color) !important; 23 | } 24 | 25 | .option:hover { 26 | background-color: var(--card-border-color) !important; 27 | } 28 | 29 | .indicatorSeparator { 30 | background-color: var(--card-border-color) !important; 31 | } 32 | 33 | .placeholder, 34 | .singleValue { 35 | color: var(--input-fg-color) !important; 36 | } 37 | 38 | .multiValue { 39 | background-color: var(--card-border-color) !important; 40 | } 41 | 42 | .multiValueLabel { 43 | color: var(--card-fg-color) !important; 44 | } 45 | 46 | .multiValueRemove { 47 | color: var(--card-fg-color) !important; 48 | } 49 | 50 | .multiValueRemove:hover { 51 | color: #de350b !important; 52 | } 53 | -------------------------------------------------------------------------------- /src/components/TagsInput.tsx: -------------------------------------------------------------------------------- 1 | import React, {forwardRef, useCallback, useEffect} from 'react' 2 | import Select from 'react-select' 3 | import CreatableSelect from 'react-select/creatable' 4 | import StateManagedSelect from 'react-select/dist/declarations/src/stateManager' 5 | import {set, unset, useFormValue} from 'sanity' 6 | import {usePrefersDark} from '@sanity/ui' 7 | import { 8 | GeneralSubscription, 9 | GeneralTag, 10 | RefinedTags, 11 | SelectProps, 12 | Tag, 13 | TagsInputProps, 14 | } from '../types' 15 | import {useClient} from '../utils/client' 16 | import {isSchemaMulti, isSchemaReference, setAtPath} from '../utils/helpers' 17 | import {useLoading, useOptions} from '../utils/hooks' 18 | import {prepareTags, revertTags} from '../utils/mutators' 19 | import { 20 | getPredefinedTags, 21 | getSelectedTags, 22 | getTagsFromReference, 23 | getTagsFromRelated, 24 | } from '../utils/observables' 25 | import {ReferenceCreateWarning, ReferencePredefinedWarning} from './ReferenceWarnings' 26 | import styles from './TagsInput.module.css' 27 | 28 | // TODO: Allow reference creation inline 29 | // TODO: Allow reference merging inline (stretch ??) 30 | // TODO: Allow reference editing inline (stretch ??) 31 | // TODO: Allow reference deleting inline (stretch ??) 32 | // TODO: Allow object merging inline (stretch ??) 33 | // TODO: Allow object editing inline (stretch ??) 34 | // TODO: Allow object deleting inline (stretch ??) 35 | 36 | export const TagsInput = forwardRef( 37 | (props: TagsInputProps, ref: React.Ref 257 | )} 258 | 259 | ) 260 | } 261 | ) 262 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {definePlugin} from 'sanity' 2 | import {tagSchema} from './schemas/tag' 3 | import {tagsSchema} from './schemas/tags' 4 | 5 | interface TagsPluginConfig {} 6 | 7 | export const tags = definePlugin((config = {}) => ({ 8 | name: 'sanity-plugin-tags', 9 | schema: { 10 | types: [tagSchema, tagsSchema], 11 | }, 12 | })) 13 | -------------------------------------------------------------------------------- /src/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.css' { 2 | const classes: {[key: string]: string} 3 | export default classes 4 | } 5 | -------------------------------------------------------------------------------- /src/schemas/tag.ts: -------------------------------------------------------------------------------- 1 | import {SchemaTypeDefinition} from 'sanity' 2 | import {TagsInput} from '../components/TagsInput' 3 | 4 | export const tagSchema: SchemaTypeDefinition = { 5 | name: 'tag', 6 | title: 'Tag', 7 | type: 'object', 8 | components: { 9 | input: TagsInput, 10 | }, 11 | fields: [ 12 | { 13 | name: 'value', 14 | type: 'string', 15 | }, 16 | { 17 | name: 'label', 18 | type: 'string', 19 | }, 20 | ], 21 | } 22 | -------------------------------------------------------------------------------- /src/schemas/tags.ts: -------------------------------------------------------------------------------- 1 | import {SchemaTypeDefinition} from 'sanity' 2 | import {TagsInput} from '../components/TagsInput' 3 | 4 | export const tagsSchema: SchemaTypeDefinition = { 5 | name: 'tags', 6 | title: 'Tags', 7 | type: 'array', 8 | components: { 9 | input: TagsInput, 10 | }, 11 | of: [{type: 'tag'}], 12 | } 13 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArrayOfObjectsInputProps, 3 | ArraySchemaType, 4 | ObjectInputProps, 5 | ObjectSchemaType, 6 | ReferenceSchemaType, 7 | } from 'sanity' 8 | import {GroupBase, Props, SelectComponentsConfig} from 'react-select' 9 | import {Subscription} from 'rxjs' 10 | 11 | export type GeneralSubscription = Subscription | {unsubscribe: () => any} 12 | 13 | export interface RefTag { 14 | _ref: string 15 | _type: string 16 | } 17 | 18 | export interface GeneralTag { 19 | [key: string]: any 20 | } 21 | 22 | export interface Tag { 23 | _type: 'tag' 24 | _key: string 25 | label: string 26 | value: string 27 | [key: string]: any 28 | } 29 | 30 | export type UnrefinedTags = RefTag | GeneralTag | RefTag[] | GeneralTag[] | undefined 31 | 32 | export type RefinedTags = Tag | Tag[] | undefined 33 | 34 | export type PredefinedTags = 35 | | GeneralTag[] 36 | | RefTag[] 37 | | GeneralTag 38 | | RefTag 39 | | (() => Promise) 40 | | (() => GeneralTag[] | RefTag[] | GeneralTag | RefTag) 41 | 42 | export interface InputOptions { 43 | predefinedTags?: PredefinedTags 44 | includeFromReference?: false | string 45 | includeFromRelated?: false | string 46 | customLabel?: string 47 | customValue?: string 48 | allowCreate?: boolean 49 | onCreate?: (inputValue: string) => GeneralTag | Promise 50 | checkValid?: (inputValue: string, currentValues: string[]) => boolean 51 | reactSelectOptions?: Props> 52 | } 53 | 54 | export type SelectProps = Props> 55 | export type SelectComponents = SelectComponentsConfig< 56 | Tag, 57 | IsMulti, 58 | GroupBase 59 | > 60 | 61 | export type InputType = (ArraySchemaType | ReferenceSchemaType | ObjectSchemaType) & { 62 | options?: InputOptions 63 | } 64 | 65 | type TagSchema = ObjectSchemaType & {options?: InputOptions} 66 | type TagArraySchema = ArraySchemaType & {options?: InputOptions} 67 | type TagRefSchema = ReferenceSchemaType & {options?: InputOptions} 68 | 69 | export type TagsSchema = TagSchema | TagArraySchema | TagRefSchema 70 | 71 | type TagInputProps = ObjectInputProps 72 | type TagArrayInputProps = ArrayOfObjectsInputProps 73 | type TagRefInputProps = ObjectInputProps 74 | type TagRefArrayInputProps = ArrayOfObjectsInputProps 75 | 76 | export type TagsInputProps = 77 | | TagInputProps 78 | | TagArrayInputProps 79 | | TagRefInputProps 80 | | TagRefArrayInputProps 81 | -------------------------------------------------------------------------------- /src/utils/client.ts: -------------------------------------------------------------------------------- 1 | import {useClient as useSanityClient} from 'sanity' 2 | import {ListenOptions, SanityClient} from '@sanity/client' 3 | 4 | /** 5 | * Default listen options to be used with the `listen` method provided by the sanity client 6 | */ 7 | export const listenOptions: ListenOptions = { 8 | includeResult: false, 9 | includePreviousRevision: false, 10 | visibility: 'query', 11 | events: ['welcome', 'mutation', 'reconnect'], 12 | } 13 | 14 | export function useClient(): SanityClient { 15 | return useSanityClient({apiVersion: '2023-02-01'}) 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import {InputType, Tag} from '../types' 2 | 3 | /** 4 | * 5 | * @param type type prop passed by sanity to input components 6 | * @returns boolean defining whether the schema is an array or not 7 | */ 8 | export const isSchemaMulti = (type: InputType): boolean => { 9 | return type.jsonType !== 'object' 10 | } 11 | 12 | /** 13 | * 14 | * @param type type prop passed by sanity to input components 15 | * @returns boolean defining whether the schema is a reference or not 16 | */ 17 | export const isSchemaReference = (type: InputType): boolean => { 18 | return 'to' in type || ('of' in type && type.of[0] && 'to' in type.of[0]) 19 | } 20 | 21 | /** 22 | * 23 | * @param tags an array of tags (i.e. { label: string, value: string }) 24 | * @returns a filtered and flattened version of the initial tags array by uniqueness 25 | */ 26 | export const filterUniqueTags = (tags: Tag[] = []): Tag[] => { 27 | return tags.flat(Infinity).filter((firstTag, index) => { 28 | const firstTagStringified = JSON.stringify({label: firstTag.label, value: firstTag.value}) 29 | 30 | return ( 31 | index === 32 | tags.flat(Infinity).findIndex((secondTag) => { 33 | return ( 34 | JSON.stringify({label: secondTag.label, value: secondTag.value}) === firstTagStringified 35 | ) 36 | }) 37 | ) 38 | }) 39 | } 40 | 41 | /** 42 | * Get value from object through string/array path 43 | * @param obj The object with the key you want to retrieve 44 | * @param path The path (either a string or an array of strings) to the key (i.e. a.b.c or ['a', 'b', 'c']) 45 | * @param defaultValue A value to return 46 | * @returns The value at the end of the path or a default value 47 | */ 48 | export const get = ( 49 | object: Record | unknown, 50 | path: string | string[], 51 | defaultValue?: DefaultValue 52 | ): any => { 53 | if (!object) return defaultValue 54 | 55 | let props: string[] | boolean = false 56 | let prop: string | undefined 57 | 58 | if (Array.isArray(path)) props = path.slice(0) 59 | if (typeof path === 'string') props = path.split('.') 60 | if (!Array.isArray(props)) throw new Error('path must be an array or a string') 61 | 62 | let obj: object | unknown = object 63 | while (props.length) { 64 | prop = props.shift() 65 | if (!prop) return defaultValue 66 | if (!obj) return defaultValue 67 | if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return defaultValue 68 | if (!(prop in obj)) return defaultValue 69 | obj = (obj as {[key: string]: unknown})[prop] 70 | } 71 | 72 | return obj 73 | } 74 | 75 | /** 76 | * Checks to make sure the prop passed is not a prototype 77 | * @param prop A string defining a prop 78 | * @returns True if not prototype, else false 79 | */ 80 | function prototypeCheck(prop: string) { 81 | if (prop === '__proto__' || prop === 'constructor' || prop === 'prototype') return false 82 | return true 83 | } 84 | 85 | /** 86 | * Set value from object through string/array path 87 | * @param obj The object you want to add to 88 | * @param path The path to store the new value (either a string or an array of strings) to the key (i.e. a.b.c or ['a', 'b', 'c']) 89 | * @param value The value to add to the object 90 | * @returns True or false defining whether it is sucessfully added 91 | */ 92 | export const setAtPath = ( 93 | object: Record, 94 | path: string | string[], 95 | value: Value 96 | ): boolean => { 97 | let props: string[] | boolean = false 98 | 99 | if (Array.isArray(path)) props = path.slice(0) 100 | if (typeof path === 'string') props = path.split('.') 101 | if (!Array.isArray(props)) throw new Error('path must be an array or a string') 102 | 103 | const lastProp = props.pop() 104 | if (!lastProp) return false 105 | if (!prototypeCheck(lastProp)) throw new Error('setting of prototype values not supported') 106 | 107 | let thisProp: string | undefined 108 | let obj = object 109 | while ((thisProp = props.shift())) { 110 | if (!prototypeCheck(thisProp)) throw new Error('setting of prototype values not supported') 111 | if (!thisProp) return false 112 | if (!(thisProp in obj)) obj[thisProp] = {} 113 | obj = obj[thisProp] as Record 114 | if (!obj || typeof obj !== 'object') return false 115 | } 116 | 117 | obj[lastProp] = value 118 | 119 | return true 120 | } 121 | 122 | export function isPlainObject(value: unknown): value is Record { 123 | return ( 124 | typeof value === 'object' && 125 | value !== null && 126 | value.constructor === Object && 127 | Object.prototype.toString.call(value) === '[object Object]' 128 | ) 129 | } 130 | -------------------------------------------------------------------------------- /src/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Tag} from '../types' 3 | import {filterUniqueTags} from './helpers' 4 | 5 | type LoadingOptions = {[key: string]: boolean} 6 | interface UseLoadingInput { 7 | initialLoadingOptions?: LoadingOptions 8 | initialState?: boolean 9 | } 10 | 11 | /** 12 | * Expands on a basic `isLoading` state by allowing multiple keyed options with separate loading states to be tracked 13 | * @param initialLoadingOptions An object with several keys, each defining a boolean state of loaded/not loaded 14 | * @param initialState The initial state (whether or not it should start in a loading state or a loaded state) 15 | * @returns An array containing the overall loading state, the individual loading states, and a function to change the loading states respectively 16 | */ 17 | export const useLoading = ({ 18 | initialLoadingOptions = {}, 19 | initialState = true, 20 | }: UseLoadingInput): [boolean, LoadingOptions, (properties: LoadingOptions) => void] => { 21 | const [loadingOptions, setLoadingOptions] = React.useState(initialLoadingOptions) 22 | const [isLoading, setIsLoading] = React.useState(initialState) 23 | 24 | React.useEffect(() => { 25 | let loaded = false 26 | if (Object.keys(loadingOptions).length) { 27 | for (const option in loadingOptions) { 28 | if (loadingOptions[option]) loaded = true 29 | } 30 | } 31 | 32 | setIsLoading(loaded) 33 | }, [loadingOptions]) 34 | 35 | const setLoadOption = React.useCallback((properties: LoadingOptions) => { 36 | setLoadingOptions((oldValue) => { 37 | return {...oldValue, ...properties} 38 | }) 39 | }, []) 40 | 41 | return [isLoading, loadingOptions, setLoadOption] 42 | } 43 | 44 | type Options = {[key: string]: Tag[]} 45 | interface UseOptionsInput { 46 | initialState?: Tag[] 47 | } 48 | 49 | /** 50 | * Expands on a basic list of tag options by allowing groups of tags to be passed 51 | * @param initialState A list of tags (i.e. {label: string, value: string}) 52 | * @returns An array containing a full list of tags, a list of tags keyed by respective groups, and a function to change/add a group of tag options respectively 53 | */ 54 | export const useOptions = ({ 55 | initialState = [], 56 | }: UseOptionsInput): [Tag[], Options, (properties: Options) => void] => { 57 | const [options, setOptions] = React.useState(initialState) 58 | const [groupOptions, setGroupOptions] = React.useState({} as Options) 59 | 60 | React.useEffect(() => { 61 | const opts: Tag[] = [] 62 | for (const group in groupOptions) { 63 | if (Array.isArray(groupOptions[group])) opts.push(...groupOptions[group]) 64 | } 65 | 66 | setOptions(filterUniqueTags(opts)) 67 | }, [groupOptions]) 68 | 69 | const setTagOption = React.useCallback((properties: Options) => { 70 | setGroupOptions((oldValue) => ({...oldValue, ...properties})) 71 | }, []) 72 | 73 | return [options, groupOptions, setTagOption] 74 | } 75 | -------------------------------------------------------------------------------- /src/utils/mutators.ts: -------------------------------------------------------------------------------- 1 | import {SanityClient} from '@sanity/client' 2 | import {GeneralTag, RefinedTags, RefTag, Tag, UnrefinedTags} from '../types' 3 | import {get, isPlainObject, setAtPath} from './helpers' 4 | 5 | interface PrepareTagInput { 6 | customLabel?: string 7 | customValue?: string 8 | } 9 | 10 | /** 11 | * Prepares tags for react-select 12 | * @param customLabel a string with a custom label key to be swapped on the tag 13 | * @param customValue a string with a custom value key to be swapped on the tag 14 | * @returns a formatted tag that can be used with react-select without overriding custom labels or values 15 | */ 16 | const prepareTag = ({customLabel = 'label', customValue = 'value'}: PrepareTagInput) => { 17 | return (tag: GeneralTag) => { 18 | const tempTag: Tag = { 19 | ...tag, 20 | _type: 'tag', 21 | _key: tag.value, 22 | _labelTemp: tag.label, 23 | _valueTemp: tag.value, 24 | label: get(tag, customLabel), 25 | value: get(tag, customValue), 26 | } 27 | return tempTag 28 | } 29 | } 30 | 31 | interface RevertTagInput { 32 | customLabel?: string 33 | customValue?: string 34 | isReference: IsReference 35 | } 36 | 37 | /** 38 | * Reverts tag for saving to sanity 39 | * @param customLabel a string with a custom label key to be swapped on the tag 40 | * @param customValue a string with a custom value key to be swapped on the tag 41 | * @param isReference whether or not the tag should be saved as a reference or an object 42 | * @returns a formatted tag that restores any custom labels or values while also preparing the tag to be saved by sanity 43 | */ 44 | function revertTag( 45 | params: RevertTagInput 46 | ): (tag: Tag) => RefTag 47 | function revertTag( 48 | params: RevertTagInput 49 | ): (tag: Tag) => GeneralTag 50 | function revertTag( 51 | params: RevertTagInput 52 | ): (tag: Tag) => RefTag | GeneralTag 53 | function revertTag({ 54 | customLabel = 'label', 55 | customValue = 'value', 56 | isReference, 57 | }: RevertTagInput) { 58 | return (tag: Tag): RefTag | GeneralTag => { 59 | if (isReference === true) { 60 | const tempTag: RefTag = { 61 | _ref: tag._id, 62 | _type: 'reference', 63 | } 64 | 65 | return tempTag 66 | } 67 | 68 | const tempTag: GeneralTag = { 69 | ...tag, 70 | label: tag._labelTemp, 71 | value: tag._valueTemp, 72 | } 73 | 74 | setAtPath(tempTag, customLabel, tag.label) 75 | setAtPath(tempTag, customValue, tag.value) 76 | 77 | delete tempTag._labelTemp 78 | delete tempTag._valueTemp 79 | if (tempTag.label === undefined) delete tempTag.label 80 | if (tempTag.value === undefined) delete tempTag.value 81 | 82 | return tempTag 83 | } 84 | } 85 | 86 | interface PrepareTagsInput { 87 | client: SanityClient 88 | tags: TagType 89 | customLabel?: string 90 | customValue?: string 91 | } 92 | 93 | /** 94 | * Prepares a list of tag(s) for react-select 95 | * @param tags A list of tags that need to be prepared for react-select (general objects or references) 96 | * @returns A prepared list of tag(s) that preserves any custom labels or values 97 | */ 98 | export const prepareTags = async ({ 99 | client, 100 | tags, 101 | customLabel = 'label', 102 | customValue = 'value', 103 | }: PrepareTagsInput): Promise => { 104 | const prepare = prepareTag({customLabel, customValue}) 105 | 106 | // undefined single 107 | if (tags === undefined || tags === null) return undefined 108 | 109 | // undefined array 110 | if (Array.isArray(tags) && !tags.length) return [] 111 | 112 | // reference array 113 | if (Array.isArray(tags) && '_ref' in tags[0] && '_type' in tags[0]) 114 | if ('_ref' in tags[0] && '_type' in tags[0]) { 115 | return ( 116 | await client.fetch('*[_id in $refs]', { 117 | refs: tags.map((tag) => tag._ref), 118 | }) 119 | ).map(prepare) 120 | } 121 | 122 | // object array 123 | if (Array.isArray(tags)) return tags.map(prepare) 124 | 125 | // reference singleton 126 | if (isPlainObject(tags) && '_ref' in tags && '_type' in tags) 127 | return prepare(await client.fetch('*[_id == $ref][0]', {ref: tags._ref})) 128 | 129 | // object singleton 130 | return prepare(tags) 131 | } 132 | 133 | /** 134 | * Prepares a list of tags for react-select 135 | * @param tags A list of tags or single tag that need to be prepared for react-select (general objects or references) 136 | * @returns A prepared list of tags that preserves any custom labels or values 137 | */ 138 | export const prepareTagsAsList = async ( 139 | preparedTagsOptions: PrepareTagsInput 140 | ): Promise => { 141 | const preparedTags = await prepareTags(preparedTagsOptions) 142 | 143 | if (preparedTags === undefined) return [] 144 | if (!Array.isArray(preparedTags)) return [preparedTags] 145 | return preparedTags 146 | } 147 | 148 | interface RevertTagsInput< 149 | IsReference extends boolean = boolean, 150 | IsMulti extends boolean = boolean 151 | > { 152 | tags: RefinedTags 153 | customLabel?: string 154 | customValue?: string 155 | isMulti: IsMulti 156 | isReference: IsReference 157 | } 158 | 159 | /** 160 | * Reverts tag(s) for saving to sanity 161 | * @param customLabel a string with a custom label key to be swapped on the tag(s) 162 | * @param customValue a string with a custom value key to be swapped on the tag(s) 163 | * @param isReference whether or not the tag(s) should be saved as a reference or an object 164 | * @returns a formatted list of tag(s) that restores any custom labels or values while also preparing the tag(s) to be saved by sanity 165 | */ 166 | export function revertTags( 167 | params: RevertTagsInput 168 | ): RefTag[] 169 | export function revertTags( 170 | params: RevertTagsInput 171 | ): RefTag | undefined 172 | export function revertTags( 173 | params: RevertTagsInput 174 | ): GeneralTag[] 175 | export function revertTags( 176 | params: RevertTagsInput 177 | ): GeneralTag | undefined 178 | export function revertTags( 179 | params: RevertTagsInput 180 | ): RefTag | GeneralTag | undefined 181 | export function revertTags( 182 | params: RevertTagsInput 183 | ): RefTag[] | GeneralTag[] 184 | export function revertTags( 185 | params: RevertTagsInput 186 | ): GeneralTag | GeneralTag[] | undefined 187 | export function revertTags( 188 | params: RevertTagsInput 189 | ): RefTag | RefTag[] | undefined 190 | export function revertTags( 191 | params: RevertTagsInput 192 | ): UnrefinedTags 193 | export function revertTags({ 194 | tags, 195 | customLabel = 'label', 196 | customValue = 'value', 197 | isMulti, 198 | isReference, 199 | }: RevertTagsInput): UnrefinedTags { 200 | const revert = revertTag({customLabel, customValue, isReference}) 201 | 202 | // if tags are undefined 203 | if (tags === undefined) return undefined 204 | 205 | if (isMulti) { 206 | // ensure it is actually an array 207 | const tagsArray = Array.isArray(tags) ? tags : [tags] 208 | 209 | // revert and return array 210 | return tagsArray.map(revert) 211 | } 212 | 213 | // not multi, so ensure is a single tag 214 | const tag = Array.isArray(tags) ? tags[0] : tags 215 | 216 | // revert tag 217 | return revert(tag) 218 | } 219 | -------------------------------------------------------------------------------- /src/utils/observables.ts: -------------------------------------------------------------------------------- 1 | import {SanityClient} from '@sanity/client' 2 | import {from, defer, pipe, Observable} from 'rxjs' 3 | import {map, switchMap} from 'rxjs/operators' 4 | import {GeneralTag, RefTag, PredefinedTags, Tag, UnrefinedTags} from '../types' 5 | import {listenOptions} from './client' 6 | import {filterUniqueTags} from './helpers' 7 | import {prepareTagsAsList} from './mutators' 8 | 9 | interface RefineTagsPipeInput { 10 | client: SanityClient 11 | customLabel?: string 12 | customValue?: string 13 | } 14 | 15 | /** 16 | * A custom pipe function that can be used in an observable pipe to refine tags 17 | * @param client A Sanity client 18 | * @param customLabel a string with a custom label key to be swapped on the tag(s) 19 | * @param customValue a string with a value label key to be swapped on the tag(s) 20 | * @returns A custom pipe function 21 | */ 22 | const refineTagsPipe = ({ 23 | client, 24 | customLabel = 'label', 25 | customValue = 'value', 26 | }: RefineTagsPipeInput) => 27 | pipe( 28 | map((val) => (Array.isArray(val) ? val.flat(Infinity) : val) as UnrefinedTags), 29 | switchMap((val) => prepareTagsAsList({client, tags: val, customLabel, customValue})), 30 | map((val) => filterUniqueTags(val)) 31 | ) 32 | 33 | interface GetGeneralObservableInput { 34 | client: SanityClient 35 | query: string 36 | params: { 37 | [key: string]: any 38 | } 39 | customLabel?: string 40 | customValue?: string 41 | } 42 | 43 | /** 44 | * A generic observable that will watch a query and return refined tags 45 | * @param client A Sanity client 46 | * @param query A GROQ query for the sanity client 47 | * @param params A list of GROQ params for the sanity client 48 | * @param customLabel a string with a custom label key to be swapped on the tag(s) 49 | * @param customValue a string with a value label key to be swapped on the tag(s) 50 | * @returns An observable that watches for any changes on the query and params 51 | */ 52 | const getGeneralObservable = ({ 53 | client, 54 | query, 55 | params, 56 | customLabel = 'label', 57 | customValue = 'value', 58 | }: GetGeneralObservableInput) => { 59 | return client.listen>(query, params, listenOptions).pipe( 60 | switchMap(() => client.fetch(query, params)), 61 | refineTagsPipe({client, customLabel, customValue}) 62 | ) 63 | } 64 | 65 | interface GetSelectedTagsInput { 66 | client: SanityClient 67 | tags: UnrefinedTags 68 | isMulti: IsMulti 69 | customLabel?: string 70 | customValue?: string 71 | } 72 | 73 | /** 74 | * Manipulate the selected tags into a list of refined tags 75 | * @param client A Sanity client 76 | * @param tags A list or singleton of RefTag or GeneralTag that will act as the selected tags for react-select 77 | * @param customLabel a string with a custom label key to be swapped on the tag(s) 78 | * @param customValue a string with a value label key to be swapped on the tag(s) 79 | * @returns An observable that returns pre-refined tags received from the predefined tags option 80 | */ 81 | export function getSelectedTags( 82 | params: GetSelectedTagsInput 83 | ): Observable 84 | export function getSelectedTags( 85 | params: GetSelectedTagsInput 86 | ): Observable 87 | export function getSelectedTags( 88 | params: GetSelectedTagsInput 89 | ): Observable 90 | export function getSelectedTags({ 91 | client, 92 | tags, 93 | isMulti, 94 | customLabel = 'label', 95 | customValue = 'value', 96 | }: GetSelectedTagsInput): Observable { 97 | const tagFunction = async () => tags 98 | return defer(() => from(tagFunction())).pipe( 99 | refineTagsPipe({client, customLabel, customValue}), 100 | map((val) => (isMulti ? val : val[0])) 101 | ) 102 | } 103 | 104 | /** 105 | * Takes a function that can possibly return singleton tags and forces it to return an array of tags 106 | * @param predefinedTags A function that returns an unrefined tag(s) 107 | * @returns A list of the tags 108 | */ 109 | const predefinedTagWrapper = async ( 110 | predefinedTags: 111 | | (() => Promise) 112 | | (() => GeneralTag | GeneralTag[] | RefTag | RefTag[]) 113 | ): Promise => { 114 | const tags = await predefinedTags() 115 | if (!Array.isArray(tags)) return [tags] 116 | return tags 117 | } 118 | 119 | interface GetPredefinedTagsInput { 120 | client: SanityClient 121 | predefinedTags: PredefinedTags 122 | customLabel?: string 123 | customValue?: string 124 | } 125 | 126 | /** 127 | * Manipulate the predefined tags into a list of refined tags 128 | * @param client A Sanity client 129 | * @param predefinedTags A list or singleton of RefTag or GeneralTag that will act as predefined tags for react-select 130 | * @param customLabel a string with a custom label key to be swapped on the tag(s) 131 | * @param customValue a string with a value label key to be swapped on the tag(s) 132 | * @returns An observable that returns pre-refined tags received from the predefined tags option 133 | */ 134 | export const getPredefinedTags = ({ 135 | client, 136 | predefinedTags, 137 | customLabel = 'label', 138 | customValue = 'value', 139 | }: GetPredefinedTagsInput): Observable => { 140 | const tagFunction = 141 | predefinedTags instanceof Function ? predefinedTags : async () => predefinedTags 142 | 143 | return defer(() => 144 | from(predefinedTagWrapper(tagFunction)).pipe(refineTagsPipe({client, customLabel, customValue})) 145 | ) 146 | } 147 | 148 | interface GetTagsFromReferenceInput { 149 | client: SanityClient 150 | document: string 151 | customLabel?: string 152 | customValue?: string 153 | } 154 | 155 | /** 156 | * Observes changes to a referenced document and returns refined tags 157 | * @param client A Sanity client 158 | * @param document a string that matches a document type in the sanity schema 159 | * @param customLabel a string with a custom label key to be swapped on the tag(s) 160 | * @param customValue a string with a value label key to be swapped on the tag(s) 161 | * @returns An observable that returns pre-refined tags received from the referenced document 162 | */ 163 | export const getTagsFromReference = ({ 164 | client, 165 | document, 166 | customLabel = 'label', 167 | customValue = 'value', 168 | }: GetTagsFromReferenceInput): Observable => { 169 | const query = ` 170 | *[ _type == $document && defined(@[$customLabel]) && defined(@[$customValue])] { 171 | _id, 172 | "value": @[$customValue], 173 | "label": @[$customLabel] 174 | } 175 | ` 176 | 177 | const params = { 178 | document, 179 | customLabel: customLabel.split('.')[0], 180 | customValue: customValue.split('.')[0], 181 | } 182 | 183 | return getGeneralObservable({ 184 | client, 185 | query, 186 | params, 187 | customLabel, 188 | customValue, 189 | }) 190 | } 191 | 192 | interface GetTagsFromRelatedInput { 193 | client: SanityClient 194 | documentType: string 195 | field: string 196 | isMulti: boolean 197 | customLabel?: string 198 | customValue?: string 199 | } 200 | 201 | /** 202 | * Observes changes to related objects and returns refined tags 203 | * @param client A Sanity client 204 | * @param documentType a string that matches the current document type 205 | * @param field a string that matches the name of the field to pull from 206 | * @param isMulti whether or not the related field is an array or an object 207 | * @param customLabel a string with a custom label key to be swapped on the tag(s) 208 | * @param customValue a string with a value label key to be swapped on the tag(s) 209 | * @returns An observable that returns pre-refined tags received from the related field within the document 210 | */ 211 | export const getTagsFromRelated = ({ 212 | client, 213 | documentType, 214 | field, 215 | isMulti, 216 | customLabel = 'label', 217 | customValue = 'value', 218 | }: GetTagsFromRelatedInput): Observable => { 219 | const query = ` 220 | *[ 221 | _type == $documentType && 222 | defined(@[$field]) && 223 | defined(@[$field][]) == $isMulti && 224 | ( 225 | (!$isMulti && defined(@[$field]->[$customLabel]) && defined(@[$field]->[$customValue])) || 226 | (!$isMulti && defined(@[$field][$customLabel]) && defined(@[$field][$customValue])) || 227 | ($isMulti && defined(@[$field][]->[$customLabel]) && defined(@[$field][]->[$customValue])) || 228 | ($isMulti && defined(@[$field][][$customLabel]) && defined(@[$field][][$customValue])) 229 | ) 230 | ][$field] 231 | ` 232 | 233 | const params = { 234 | documentType, 235 | field, 236 | isMulti, 237 | customLabel: customLabel.split('.')[0], 238 | customValue: customValue.split('.')[0], 239 | } 240 | 241 | return getGeneralObservable({ 242 | client, 243 | query, 244 | params, 245 | customLabel, 246 | customValue, 247 | }) 248 | } 249 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "exclude": [ 5 | "./src/**/__fixtures__", 6 | "./src/**/__mocks__", 7 | "./src/**/*.test.ts", 8 | "./src/**/*.test.tsx" 9 | ], 10 | "compilerOptions": { 11 | "rootDir": ".", 12 | "outDir": "./dist", 13 | "jsx": "react-jsx", 14 | "emitDeclarationOnly": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src", "./package.config.ts"], 4 | "compilerOptions": { 5 | "rootDir": ".", 6 | "jsx": "react-jsx", 7 | "noEmit": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "esnext", 5 | "module": "esnext", 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "downlevelIteration": true, 10 | "declaration": true, 11 | "allowSyntheticDefaultImports": true, 12 | "skipLibCheck": true, 13 | "isolatedModules": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /v2-incompatible.js: -------------------------------------------------------------------------------- 1 | const {showIncompatiblePluginDialog} = require('@sanity/incompatible-plugin') 2 | const {name, version, sanityExchangeUrl} = require('./package.json') 3 | 4 | export default showIncompatiblePluginDialog({ 5 | name: name, 6 | versions: { 7 | v3: version, 8 | v2: '^1.5.0', 9 | }, 10 | sanityExchangeUrl, 11 | }) 12 | --------------------------------------------------------------------------------