├── .github ├── CODEOWNERS ├── renovate.json └── workflows │ └── main.yml ├── .husky ├── pre-commit └── commit-msg ├── commitlint.config.js ├── .releaserc.json ├── .eslintignore ├── .prettierrc.json ├── lint-staged.config.js ├── sanity.json ├── .editorconfig ├── babel.config.js ├── .eslintrc ├── v2-incompatible.js ├── src ├── index.ts ├── languageSubscription.ts ├── types.ts ├── filterField.ts ├── useSelectedLanguageIds.ts ├── usePaneLanguages.ts ├── LanguageFilterObjectInput.tsx ├── plugin.tsx ├── LanguageFilterStudioContext.tsx ├── filterField.test.ts └── LanguageFilterMenuButton.tsx ├── jest.config.js ├── package.config.ts ├── tsconfig.json ├── .gitignore ├── LICENSE ├── package.json ├── README.md └── CHANGELOG.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @sanity-io/ecosystem 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | } 4 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "" 5 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/semantic-release-preset", 3 | "branches": ["main"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | commitlint.config.js 3 | lib 4 | lint-staged.config.js 5 | package.config.ts 6 | *.js 7 | dts 8 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 100, 4 | "bracketSpacing": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '**/*.{js,jsx}': ['eslint'], 3 | '**/*.{ts,tsx}': ['eslint', () => 'tsc --noEmit'], 4 | } 5 | -------------------------------------------------------------------------------- /sanity.json: -------------------------------------------------------------------------------- 1 | { 2 | "parts": [ 3 | { 4 | "implements": "part:@sanity/base/sanity-root", 5 | "path": "./v2-incompatible.js" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>sanity-io/renovate-config", 5 | "github>sanity-io/renovate-config:studio-v3" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // used only by jest, .parcelrc configured to ignore this file 2 | module.exports = { 3 | presets: [ 4 | '@babel/preset-env', 5 | [ 6 | '@babel/preset-react', 7 | { 8 | runtime: 'automatic', 9 | }, 10 | ], 11 | ], 12 | } 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "browser": true, 6 | "jest": true 7 | }, 8 | "extends": [ 9 | "sanity", 10 | "sanity/typescript", 11 | "sanity/react", 12 | "plugin:react-hooks/recommended", 13 | "plugin:prettier/recommended" 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: '^2.35.0', 9 | }, 10 | sanityExchangeUrl, 11 | }) 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Plugin function 3 | */ 4 | export {defaultFilterField, isLanguageFilterEnabled} from './filterField' 5 | export {useLanguageFilterStudioContext} from './LanguageFilterStudioContext' 6 | export {languageFilter} from './plugin' 7 | export type { 8 | FilterFieldFunction, 9 | Language, 10 | LanguageFilterConfig, 11 | LanguageFilterOptions, 12 | LanguageFilterSchema, 13 | } from './types' 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testPathIgnorePatterns: ['.yalc', 'node_modules', '.idea', 'lib', '.parcel-cache'], 6 | transform: { 7 | '^.+\\.(ts|tsx)?$': ['ts-jest', {babelConfig: true}], 8 | '^.+\\.(mjs|js|jsx)$': 'babel-jest', 9 | }, 10 | transformIgnorePatterns: ['node_modules/(?!(nanoid|uuid|get-random-values-esm))'], 11 | } 12 | -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from '@sanity/pkg-utils' 2 | 3 | export default defineConfig({ 4 | dist: 'lib', 5 | minify: true, 6 | legacyExports: true, 7 | // Remove this block to enable strict export validation 8 | extract: { 9 | rules: { 10 | 'ae-forgotten-export': 'off', 11 | 'ae-incompatible-release-tags': 'off', 12 | 'ae-internal-missing-underscore': 'off', 13 | 'ae-missing-release-tag': 'off', 14 | }, 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "jsx": "preserve", 5 | "module": "preserve", 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "strict": true, 8 | "sourceMap": false, 9 | "inlineSourceMap": false, 10 | "downlevelIteration": true, 11 | "declaration": true, 12 | "allowSyntheticDefaultImports": true, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "outDir": "lib", 16 | "skipLibCheck": true, 17 | "isolatedModules": true, 18 | "checkJs": false, 19 | "rootDir": "src" 20 | }, 21 | "include": ["src/**/*"] 22 | } 23 | -------------------------------------------------------------------------------- /.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 | lib 61 | dts 62 | -------------------------------------------------------------------------------- /src/languageSubscription.ts: -------------------------------------------------------------------------------- 1 | export type LanguageSubscription = (ids: string[]) => void 2 | export type Unsubscribe = () => void 3 | export type LanguageSubscribe = (subscription: LanguageSubscription) => Unsubscribe 4 | 5 | export interface SelectedLanguageIdsBus { 6 | onSelectedIdsChange: (ids: string[]) => void 7 | subscribeSelectedIds: LanguageSubscribe 8 | } 9 | 10 | /** 11 | * We need a way to communicate state changes between the pane menu and input components. 12 | * LanguageFilter button lives outside the input-render tree, so Context is out. 13 | * This is a workaround for that. 14 | */ 15 | export function createSelectedLanguageIdsBus(): SelectedLanguageIdsBus { 16 | const subs: LanguageSubscription[] = [] 17 | 18 | const onSelectedIdsChange = (ids: string[]) => { 19 | subs.forEach((s) => s(ids)) 20 | } 21 | const subscribeSelectedIds = (subscription: LanguageSubscription) => { 22 | subs.push(subscription) 23 | return () => { 24 | subs.splice(subs.indexOf(subscription), 1) 25 | } 26 | } 27 | 28 | return { 29 | onSelectedIdsChange, 30 | subscribeSelectedIds, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sanity.io 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 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type {FieldMember, FieldsetState, ObjectSchemaType, SanityClient} from 'sanity' 2 | 3 | export interface LanguageFilterOptions { 4 | languageFilter?: boolean 5 | } 6 | 7 | export interface LanguageFilterSchema extends ObjectSchemaType { 8 | options?: LanguageFilterOptions 9 | } 10 | 11 | export type Language = { 12 | id: Intl.UnicodeBCP47LocaleIdentifier 13 | title: string 14 | } 15 | 16 | export type LanguageCallback = ( 17 | client: SanityClient, 18 | selectedValue: Record, 19 | ) => Promise 20 | 21 | export type FilterFieldFunction = ( 22 | enclosingType: ObjectSchemaType, 23 | field: FieldMember | FieldsetState, 24 | selectedLanguageIds: string[], 25 | ) => boolean 26 | 27 | export interface LanguageFilterConfig { 28 | supportedLanguages: Language[] | LanguageCallback 29 | defaultLanguages?: string[] 30 | documentTypes?: string[] 31 | filterField?: FilterFieldFunction 32 | /** 33 | * https://www.sanity.io/docs/api-versioning 34 | * @defaultValue '2022-11-27' 35 | */ 36 | apiVersion?: string 37 | } 38 | 39 | export interface LanguageFilterConfigProcessed extends LanguageFilterConfig { 40 | supportedLanguages: Language[] 41 | } 42 | -------------------------------------------------------------------------------- /src/filterField.ts: -------------------------------------------------------------------------------- 1 | import type {SchemaType} from 'sanity' 2 | 3 | import type {FilterFieldFunction, LanguageFilterConfig, LanguageFilterSchema} from './types' 4 | 5 | export const defaultFilterField: FilterFieldFunction = ( 6 | enclosingType, 7 | field, 8 | selectedLanguageIds, 9 | ) => !enclosingType.name.startsWith('locale') || selectedLanguageIds.includes(field.name) 10 | 11 | export function isLanguageFilterEnabled( 12 | schemaType: SchemaType | undefined, 13 | options: LanguageFilterConfig, 14 | ): boolean { 15 | const schemaFilter = 16 | isDocument(schemaType) && (schemaType as LanguageFilterSchema)?.options?.languageFilter 17 | const defaultEnabled = !options.documentTypes 18 | 19 | return !!( 20 | (defaultEnabled && schemaFilter !== false) || 21 | (!defaultEnabled && schemaFilter) || 22 | (schemaType && options.documentTypes?.includes(schemaType.name)) 23 | ) 24 | } 25 | 26 | function isDocument(schemaType?: SchemaType) { 27 | return schemaType?.jsonType === 'object' && getRootType(schemaType).name === 'document' 28 | } 29 | 30 | function getRootType(schema: SchemaType): SchemaType { 31 | if (schema.type) { 32 | return getRootType(schema.type) 33 | } 34 | return schema 35 | } 36 | -------------------------------------------------------------------------------- /src/useSelectedLanguageIds.ts: -------------------------------------------------------------------------------- 1 | import {useState} from 'react' 2 | 3 | import type {Language, LanguageFilterConfig} from './types' 4 | const storageKey = '@sanity/plugin/language-filter/selected-languages' 5 | 6 | export function getPersistedLanguageIds(options: LanguageFilterConfig): string[] { 7 | const selectableLangs = getSelectableLanguages(options).map((l) => l.id) 8 | 9 | let selected: string[] = selectableLangs 10 | try { 11 | const persistedValue = window.localStorage.getItem(storageKey) 12 | if (persistedValue) { 13 | selected = JSON.parse(persistedValue) 14 | } 15 | } catch (err) {} // eslint-disable-line no-empty 16 | 17 | // constrain persisted/selected languages to the ones currently supported 18 | selected = intersection(selected, selectableLangs) 19 | return selected 20 | } 21 | 22 | export function persistLanguageIds(languageIds: string[]): void { 23 | window.localStorage.setItem(storageKey, JSON.stringify(languageIds)) 24 | } 25 | 26 | function intersection(array1: string[], array2: string[]) { 27 | return array1.filter((value) => array2.includes(value)) 28 | } 29 | 30 | export function getSelectableLanguages({ 31 | supportedLanguages, 32 | defaultLanguages, 33 | }: LanguageFilterConfig): Language[] { 34 | return Array.isArray(supportedLanguages) 35 | ? supportedLanguages.filter((lang) => !defaultLanguages?.includes(lang.id)) 36 | : [] 37 | } 38 | 39 | export function useSelectedLanguageIds( 40 | options: LanguageFilterConfig, 41 | ): [string[], (ids: string[]) => void] { 42 | return useState(() => [...(options.defaultLanguages ?? []), ...getPersistedLanguageIds(options)]) 43 | } 44 | -------------------------------------------------------------------------------- /src/usePaneLanguages.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useMemo} from 'react' 2 | 3 | import {useLanguageFilterStudioContext} from './LanguageFilterStudioContext' 4 | import {getSelectableLanguages, persistLanguageIds} from './useSelectedLanguageIds' 5 | 6 | const unique = (arr: string[]) => Array.from(new Set(arr)) 7 | 8 | export function usePaneLanguages(): { 9 | activeLanguages: string[] 10 | allSelected: boolean 11 | selectAll: () => void 12 | selectNone: () => void 13 | toggleLanguage: (languageId: string) => void 14 | } { 15 | const {selectedLanguageIds, setSelectedLanguageIds, options} = useLanguageFilterStudioContext() 16 | const {defaultLanguages = []} = options 17 | 18 | const selectableLanguages = useMemo(() => getSelectableLanguages(options), [options]) 19 | 20 | const updateSelectedIds = useCallback( 21 | (ids: string[]) => { 22 | setSelectedLanguageIds(unique([...defaultLanguages, ...ids])) 23 | persistLanguageIds(unique([...defaultLanguages, ...ids])) 24 | }, 25 | [defaultLanguages, setSelectedLanguageIds], 26 | ) 27 | 28 | const selectAll = useCallback( 29 | () => updateSelectedIds(selectableLanguages.map((l) => l.id)), 30 | [updateSelectedIds, selectableLanguages], 31 | ) 32 | 33 | const selectNone = useCallback(() => { 34 | updateSelectedIds(defaultLanguages) 35 | }, [defaultLanguages, updateSelectedIds]) 36 | 37 | const toggleLanguage = useCallback( 38 | (languageId: string) => { 39 | let lang = selectedLanguageIds 40 | 41 | if (lang.includes(languageId)) { 42 | lang = lang.filter((l) => l !== languageId) 43 | } else { 44 | lang = unique([...lang, languageId]) 45 | } 46 | 47 | updateSelectedIds(lang) 48 | }, 49 | [updateSelectedIds, selectedLanguageIds], 50 | ) 51 | 52 | const activeLanguages = useMemo( 53 | () => unique([...(defaultLanguages ?? []), ...selectedLanguageIds]), 54 | [defaultLanguages, selectedLanguageIds], 55 | ) 56 | 57 | return { 58 | activeLanguages, 59 | allSelected: 60 | selectedLanguageIds.length === selectableLanguages.length + defaultLanguages.length, 61 | selectAll, 62 | selectNone, 63 | toggleLanguage, 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/LanguageFilterObjectInput.tsx: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react' 2 | import {type ObjectInputProps, type ObjectMember, useFormValue, useSchema} from 'sanity' 3 | 4 | import {isLanguageFilterEnabled} from './filterField' 5 | import {useLanguageFilterStudioContext} from './LanguageFilterStudioContext' 6 | 7 | // First check that this Object is in a schema type for which language-filter is enabled 8 | export function FilteredObjectWrapper(props: ObjectInputProps) { 9 | const {options} = useLanguageFilterStudioContext() 10 | 11 | const documentType = useFormValue(['_type']) as string 12 | const schema = useSchema() 13 | const languageFilterEnabled = isLanguageFilterEnabled(schema.get(documentType), options) 14 | return languageFilterEnabled ? : props.renderDefault(props) 15 | } 16 | 17 | // Modify the object members based on selected languages in the filter 18 | export function FilteredObjectInput(props: ObjectInputProps) { 19 | const {members: membersProp, schemaType, renderDefault, ...restProps} = props 20 | const {selectedLanguageIds, options} = useLanguageFilterStudioContext() 21 | const {filterField} = options 22 | 23 | const members: ObjectMember[] = useMemo(() => { 24 | return membersProp 25 | .filter((member) => { 26 | return ( 27 | (member.kind === 'field' && filterField(schemaType, member, selectedLanguageIds)) || 28 | member.kind === 'fieldSet' || 29 | member.kind === 'error' 30 | ) 31 | }) 32 | .map((member) => { 33 | if (member.kind === 'fieldSet') { 34 | return { 35 | ...member, 36 | fieldSet: { 37 | ...member.fieldSet, 38 | members: member.fieldSet.members.filter((fieldsetMember) => { 39 | return ( 40 | fieldsetMember.kind === 'field' && 41 | filterField(schemaType, fieldsetMember, selectedLanguageIds) 42 | ) 43 | }), 44 | }, 45 | } 46 | } 47 | return member 48 | }) 49 | }, [schemaType, membersProp, filterField, selectedLanguageIds]) 50 | 51 | return renderDefault({...restProps, members, schemaType, renderDefault}) 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sanity/language-filter", 3 | "version": "4.0.6", 4 | "description": "A Sanity plugin that supports filtering localized fields by language", 5 | "homepage": "https://github.com/sanity-io/language-filter#readme", 6 | "bugs": { 7 | "url": "https://github.com/sanity-io/language-filter/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git@github.com:sanity-io/language-filter.git" 12 | }, 13 | "license": "MIT", 14 | "author": "Sanity.io ", 15 | "sideEffects": false, 16 | "exports": { 17 | ".": { 18 | "source": "./src/index.ts", 19 | "import": "./lib/index.mjs", 20 | "require": "./lib/index.js", 21 | "default": "./lib/index.js" 22 | }, 23 | "./package.json": "./package.json" 24 | }, 25 | "main": "./lib/index.js", 26 | "module": "./lib/index.esm.js", 27 | "types": "./lib/index.d.ts", 28 | "files": [ 29 | "src", 30 | "lib", 31 | "v2-incompatible.js", 32 | "sanity.json" 33 | ], 34 | "scripts": { 35 | "prebuild": "npm run clean", 36 | "build": "pkg build --strict && pkg check --strict", 37 | "clean": "rimraf lib", 38 | "link-watch": "plugin-kit link-watch", 39 | "lint": "eslint .", 40 | "prepare": "husky install", 41 | "prepublishOnly": "npm run build", 42 | "test": "jest", 43 | "watch": "pkg-utils watch" 44 | }, 45 | "dependencies": { 46 | "@sanity/icons": "^3.5.3", 47 | "@sanity/incompatible-plugin": "^1.0.5", 48 | "@sanity/ui": "^3.1.11", 49 | "@sanity/util": "^5.0.1" 50 | }, 51 | "devDependencies": { 52 | "@babel/preset-env": "^7.24.4", 53 | "@babel/preset-react": "^7.24.1", 54 | "@commitlint/cli": "^19.2.1", 55 | "@commitlint/config-conventional": "^19.1.0", 56 | "@sanity/pkg-utils": "^6.1.0", 57 | "@sanity/plugin-kit": "^3.1.10", 58 | "@sanity/semantic-release-preset": "^4.1.7", 59 | "@types/jest": "^29.5.12", 60 | "@typescript-eslint/eslint-plugin": "^7.6.0", 61 | "@typescript-eslint/parser": "^7.6.0", 62 | "eslint": "^8.57.0", 63 | "eslint-config-prettier": "^9.1.0", 64 | "eslint-config-sanity": "^7.1.2", 65 | "eslint-plugin-prettier": "^5.1.3", 66 | "eslint-plugin-react": "^7.34.1", 67 | "eslint-plugin-react-hooks": "^4.6.0", 68 | "husky": "^8.0.1", 69 | "jest": "^29.7.0", 70 | "lint-staged": "^15.2.2", 71 | "prettier": "^3.2.5", 72 | "prettier-plugin-packagejson": "^2.4.14", 73 | "react": "^18.3.1", 74 | "react-dom": "^18.3.1", 75 | "rimraf": "^4.4.1", 76 | "sanity": "^3.67.1", 77 | "styled-components": "^6.1.8", 78 | "ts-jest": "^29.1.2", 79 | "typescript": "^5.4.4" 80 | }, 81 | "peerDependencies": { 82 | "react": "^18 || ^19", 83 | "sanity": "^3.36.4 || ^4.0.0-0 || ^5", 84 | "styled-components": "^6.1" 85 | }, 86 | "engines": { 87 | "node": ">=14" 88 | }, 89 | "sanityPlugin": { 90 | "verifyPackage": { 91 | "babelConfig": false, 92 | "tsconfig": false 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/plugin.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | definePlugin, 3 | type DocumentLanguageFilterComponent, 4 | isObjectSchemaType, 5 | type ObjectInputProps, 6 | } from 'sanity' 7 | 8 | import {isLanguageFilterEnabled} from './filterField' 9 | import {LanguageFilterMenuButton} from './LanguageFilterMenuButton' 10 | import {FilteredObjectWrapper} from './LanguageFilterObjectInput' 11 | import {defaultContextValue, LanguageFilterStudioProvider} from './LanguageFilterStudioContext' 12 | import type {LanguageFilterConfig} from './types' 13 | 14 | /** 15 | * ## Usage in sanity.config.ts (or .js) 16 | * 17 | * ``` 18 | * import {defineConfig} from 'sanity' 19 | * import {languageFilter} from '@sanity/language-filter' 20 | * 21 | * export const defineConfig({ 22 | * /... 23 | * plugins: [ 24 | * languageFilter({ 25 | * supportedLanguages: [ 26 | * {id: 'nb', title: 'Norwegian (Bokmål)'}, 27 | * {id: 'nn', title: 'Norwegian (Nynorsk)'}, 28 | * {id: 'en', title: 'English'}, 29 | * {id: 'es', title: 'Spanish'}, 30 | * {id: 'arb', title: 'Arabic'}, 31 | * {id: 'pt', title: 'Portuguese'}, 32 | * //... 33 | * ], 34 | * // Select Norwegian (Bokmål) by default 35 | * defaultLanguages: ['nb'], 36 | * // Only show language filter for document type `page` (schemaType.name) 37 | * // Can also enable via document-options: options.languageFilter: true 38 | * documentTypes: ['page'], 39 | * // default filter function shown 40 | * filterField: (enclosingType, field, selectedLanguageIds) => 41 | * !enclosingType.name.startsWith('locale') || selectedLanguageIds.includes(field.name), 42 | * }) 43 | * ] 44 | * }) 45 | * ``` 46 | */ 47 | export const languageFilter = definePlugin((options) => { 48 | const RenderLanguageFilter: DocumentLanguageFilterComponent = () => { 49 | return 50 | } 51 | 52 | const pluginOptions = { 53 | ...defaultContextValue.options, 54 | ...options, 55 | } 56 | 57 | return { 58 | name: '@sanity/language-filter', 59 | studio: { 60 | components: { 61 | layout: (props) => LanguageFilterStudioProvider({...props, options: pluginOptions}), 62 | }, 63 | }, 64 | 65 | document: { 66 | unstable_languageFilter: (prev, {schemaType, schema}) => { 67 | if (isLanguageFilterEnabled(schema.get(schemaType), options)) { 68 | return [...prev, RenderLanguageFilter] 69 | } 70 | return prev 71 | }, 72 | }, 73 | 74 | form: { 75 | components: { 76 | input: (props) => { 77 | if (props.id !== 'root' && isObjectSchemaType(props.schemaType)) { 78 | return FilteredObjectWrapper(props as ObjectInputProps) 79 | } 80 | 81 | return props.renderDefault(props) 82 | }, 83 | }, 84 | }, 85 | } 86 | }) 87 | -------------------------------------------------------------------------------- /src/LanguageFilterStudioContext.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, useContext, useEffect, useMemo, useState} from 'react' 2 | import {type LayoutProps, useClient} from 'sanity' 3 | 4 | import {defaultFilterField} from './filterField' 5 | import type { 6 | Language, 7 | LanguageCallback, 8 | LanguageFilterConfig, 9 | LanguageFilterConfigProcessed, 10 | } from './types' 11 | import {useSelectedLanguageIds} from './useSelectedLanguageIds' 12 | 13 | export interface LanguageFilterStudioContextProps { 14 | // eslint-disable-next-line react/require-default-props 15 | options: Required 16 | } 17 | 18 | export interface LanguageFilterStudioContextProcessed { 19 | options: Required 20 | } 21 | 22 | export interface LanguageFilterStudioContextValue extends LanguageFilterStudioContextProcessed { 23 | selectedLanguageIds: string[] 24 | setSelectedLanguageIds: (ids: string[]) => void 25 | } 26 | 27 | export const defaultContextValue: LanguageFilterStudioContextValue = { 28 | options: { 29 | apiVersion: '2022-11-27', 30 | supportedLanguages: [], 31 | defaultLanguages: [], 32 | documentTypes: [], 33 | filterField: defaultFilterField, 34 | }, 35 | selectedLanguageIds: [], 36 | setSelectedLanguageIds: () => console.error('LanguageFilterStudioContext not initialized'), 37 | } 38 | 39 | const LanguageFilterStudioContext = 40 | createContext(defaultContextValue) 41 | 42 | /** 43 | * This is a separate Provider from the Context that wraps the document pane 44 | * but it used to listen to changes to the selected language IDs inside it 45 | * and provide them to a Studio-wide context 46 | */ 47 | export function LanguageFilterStudioProvider( 48 | props: LayoutProps & LanguageFilterStudioContextProps, 49 | ) { 50 | const client = useClient({apiVersion: '2023-01-01'}) 51 | const [languages, setLanguages] = useState( 52 | Array.isArray(props.options.supportedLanguages) ? props.options.supportedLanguages : [], 53 | ) 54 | useEffect(() => { 55 | let asyncLanguages: Language[] = [] 56 | 57 | async function getLanguages(supportedLanguagesCallback: LanguageCallback) { 58 | asyncLanguages = await supportedLanguagesCallback(client, {}) 59 | setLanguages(asyncLanguages) 60 | } 61 | 62 | if (!Array.isArray(props.options.supportedLanguages)) { 63 | getLanguages(props.options.supportedLanguages) 64 | } 65 | }, [client, props.options.supportedLanguages]) 66 | 67 | const options = useMemo>(() => { 68 | return { 69 | ...defaultContextValue.options, 70 | ...props.options, 71 | supportedLanguages: languages, 72 | } 73 | }, [props.options, languages]) 74 | 75 | const [selectedLanguageIds, setSelectedLanguageIds] = useSelectedLanguageIds(options) 76 | 77 | return ( 78 | 81 | {props.renderDefault(props)} 82 | 83 | ) 84 | } 85 | 86 | /** 87 | * Retrieves plugin options and the currently selected 88 | * language IDs from anywhere in the Studio 89 | */ 90 | export function useLanguageFilterStudioContext() { 91 | return useContext(LanguageFilterStudioContext) 92 | } 93 | -------------------------------------------------------------------------------- /src/filterField.test.ts: -------------------------------------------------------------------------------- 1 | import type {FieldMember, ObjectSchemaType} from 'sanity' 2 | 3 | import {defaultFilterField, isLanguageFilterEnabled} from './filterField' 4 | 5 | describe('filterField', () => { 6 | describe('isLanguageFilterEnabled', () => { 7 | const docType: ObjectSchemaType = { 8 | name: 'some-doc', 9 | jsonType: 'object', 10 | fields: [], 11 | // eslint-disable-next-line camelcase 12 | __experimental_search: [], 13 | type: { 14 | name: 'document', 15 | jsonType: 'object', 16 | fields: [], 17 | // eslint-disable-next-line camelcase 18 | __experimental_search: [], 19 | }, 20 | } 21 | it('should be enabled when documentTypes is missing', () => { 22 | const enabled = isLanguageFilterEnabled(docType, {supportedLanguages: []}) 23 | expect(enabled).toBeTruthy() 24 | }) 25 | 26 | it('should be disabled when documentTypes is missing and options.languageFilter: false', () => { 27 | const enabled = isLanguageFilterEnabled( 28 | {...docType, options: {languageFilter: false}}, 29 | {supportedLanguages: []}, 30 | ) 31 | expect(enabled).toBeFalsy() 32 | }) 33 | 34 | it('should be enabled when documentTypes is contains doc-type name', () => { 35 | const enabled = isLanguageFilterEnabled( 36 | {...docType, options: {languageFilter: false}}, 37 | {supportedLanguages: [], documentTypes: [docType.name]}, 38 | ) 39 | expect(enabled).toBeTruthy() 40 | }) 41 | 42 | it('should be enabled when documentTypes does not contain doc-type name, but options.languageFilter: true', () => { 43 | const enabled = isLanguageFilterEnabled( 44 | {...docType, options: {languageFilter: true}}, 45 | {supportedLanguages: [], documentTypes: []}, 46 | ) 47 | expect(enabled).toBeTruthy() 48 | }) 49 | }) 50 | 51 | describe('defaultFilterField', () => { 52 | const localePrefixedObject: ObjectSchemaType = { 53 | name: 'locale_parent', 54 | jsonType: 'object', 55 | fields: [], 56 | // eslint-disable-next-line camelcase 57 | __experimental_search: [], 58 | } 59 | const member: FieldMember = { 60 | name: 'nb', 61 | key: 'nb', 62 | collapsed: undefined, 63 | collapsible: undefined, 64 | kind: 'field', 65 | open: true, 66 | index: 0, 67 | field: { 68 | schemaType: {name: 'string', jsonType: 'string'}, 69 | level: 1, 70 | id: 'nb', 71 | path: [], 72 | validation: [], 73 | presence: [], 74 | changed: false, 75 | value: undefined, 76 | }, 77 | groups: [], 78 | inSelectedGroup: false, 79 | } 80 | 81 | it('should filter -> true for nb field inside local-prefixed object', () => { 82 | const result = defaultFilterField(localePrefixedObject, member, ['nb']) 83 | expect(result).toBeTruthy() 84 | }) 85 | 86 | it('should filter -> false for unselected field inside local-prefixed object', () => { 87 | const result = defaultFilterField(localePrefixedObject, member, ['other']) 88 | expect(result).toBeFalsy() 89 | }) 90 | 91 | it('should filter -> true for nb field inside non-prefixed object', () => { 92 | const result = defaultFilterField( 93 | {...localePrefixedObject, name: 'not-start-with-locale-field'}, 94 | member, 95 | ['nb'], 96 | ) 97 | expect(result).toBeTruthy() 98 | }) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /src/LanguageFilterMenuButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CheckmarkCircleIcon, 3 | CircleIcon, 4 | EyeClosedIcon, 5 | EyeOpenIcon, 6 | TranslateIcon, 7 | } from '@sanity/icons' 8 | import { 9 | Badge, 10 | Box, 11 | Button, 12 | Card, 13 | Flex, 14 | Popover, 15 | Stack, 16 | Text, 17 | TextInput, 18 | useClickOutside, 19 | } from '@sanity/ui' 20 | import {type FormEvent, type MouseEventHandler, useCallback, useState} from 'react' 21 | import {TextWithTone} from 'sanity' 22 | import {styled} from 'styled-components' 23 | 24 | import {useLanguageFilterStudioContext} from './LanguageFilterStudioContext' 25 | import {usePaneLanguages} from './usePaneLanguages' 26 | 27 | const StyledBox = styled(Box)` 28 | max-height: calc(100vh - 200px); 29 | ` 30 | 31 | export function LanguageFilterMenuButton() { 32 | const {options} = useLanguageFilterStudioContext() 33 | 34 | const defaultLanguages = options.supportedLanguages.filter((l) => 35 | options.defaultLanguages?.includes(l.id), 36 | ) 37 | 38 | const languageOptions = options.supportedLanguages.filter( 39 | (l) => !options.defaultLanguages?.includes(l.id), 40 | ) 41 | const [open, setOpen] = useState(false) 42 | const {activeLanguages, allSelected, selectAll, selectNone, toggleLanguage} = usePaneLanguages() 43 | const [button, setButton] = useState(null) 44 | const [popover, setPopover] = useState(null) 45 | 46 | const handleToggleAll: MouseEventHandler = useCallback( 47 | (event) => { 48 | const checked = event.currentTarget.value === 'ALL' 49 | 50 | if (checked) { 51 | selectAll() 52 | } else { 53 | selectNone() 54 | } 55 | }, 56 | [selectAll, selectNone], 57 | ) 58 | 59 | const handleClick = useCallback(() => setOpen((o) => !o), []) 60 | 61 | const handleClickOutside = useCallback(() => setOpen(false), []) 62 | 63 | useClickOutside(handleClickOutside, [button, popover]) 64 | 65 | const langCount = options.supportedLanguages.length 66 | 67 | // Search filter query 68 | const [query, setQuery] = useState('') 69 | const handleQuery = useCallback((event: FormEvent) => { 70 | if (event.currentTarget.value) { 71 | setQuery(event.currentTarget.value) 72 | } else { 73 | setQuery('') 74 | } 75 | }, []) 76 | 77 | const showSearch = langCount > 4 78 | 79 | const content = ( 80 | 81 | 82 | {defaultLanguages.length > 0 && ( 83 | <> 84 | {defaultLanguages.map((l) => ( 85 | 86 | ))} 87 | 88 | 89 | )} 90 | 91 | 113 | 114 | {showSearch ? ( 115 | 116 | ) : ( 117 | 118 | )} 119 | 120 | {languageOptions 121 | .filter((language) => { 122 | if (query) { 123 | return language.title.toLowerCase().includes(query.toLowerCase()) 124 | } 125 | return true 126 | }) 127 | .map((lang) => ( 128 | 135 | ))} 136 | 137 | 138 | ) 139 | 140 | const buttonText = 141 | activeLanguages.length === langCount 142 | ? 'Showing all' 143 | : `Showing ${activeLanguages.length} / ${langCount}` 144 | return ( 145 | 146 | 193 | ) 194 | } 195 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI & Release 3 | 4 | # Workflow name based on selected inputs. Fallback to default Github naming when expression evaluates to empty string 5 | run-name: >- 6 | ${{ 7 | inputs.release && inputs.test && 'Build ➤ Test ➤ Publish to NPM' || 8 | inputs.release && !inputs.test && 'Build ➤ Skip Tests ➤ Publish to NPM' || 9 | github.event_name == 'workflow_dispatch' && inputs.test && 'Build ➤ Test' || 10 | github.event_name == 'workflow_dispatch' && !inputs.test && 'Build ➤ Skip Tests' || 11 | '' 12 | }} 13 | 14 | on: 15 | # Build on pushes branches that have a PR (including drafts) 16 | pull_request: 17 | # Build on commits pushed to branches without a PR if it's in the allowlist 18 | push: 19 | branches: [main] 20 | # https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow 21 | workflow_dispatch: 22 | inputs: 23 | test: 24 | description: Run tests 25 | required: true 26 | default: true 27 | type: boolean 28 | release: 29 | description: Release new version 30 | required: true 31 | default: false 32 | type: boolean 33 | 34 | concurrency: 35 | # On PRs builds will cancel if new pushes happen before the CI completes, as it defines `github.head_ref` and gives it the name of the branch the PR wants to merge into 36 | # Otherwise `github.run_id` ensures that you can quickly merge a queue of PRs without causing tests to auto cancel on any of the commits pushed to main. 37 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 38 | cancel-in-progress: true 39 | 40 | permissions: 41 | contents: read # for checkout 42 | 43 | jobs: 44 | log-the-inputs: 45 | name: Log inputs 46 | runs-on: ubuntu-latest 47 | steps: 48 | - run: | 49 | echo "Inputs: $INPUTS" 50 | env: 51 | INPUTS: ${{ toJSON(inputs) }} 52 | 53 | build: 54 | runs-on: ubuntu-latest 55 | name: Lint & Build 56 | steps: 57 | - uses: actions/checkout@v4 58 | - uses: actions/setup-node@v4 59 | with: 60 | cache: npm 61 | node-version: lts/* 62 | - run: npm ci 63 | # Linting can be skipped 64 | - run: npm run lint --if-present 65 | if: github.event.inputs.test != 'false' 66 | # But not the build script, as semantic-release will crash if this command fails so it makes sense to test it early 67 | - run: npm run prepublishOnly --if-present 68 | 69 | test: 70 | needs: build 71 | # The test matrix can be skipped, in case a new release needs to be fast-tracked and tests are already passing on main 72 | if: github.event.inputs.test != 'false' 73 | runs-on: ${{ matrix.os }} 74 | name: Node.js ${{ matrix.node }} / ${{ matrix.os }} 75 | strategy: 76 | # A test failing on windows doesn't mean it'll fail on macos. It's useful to let all tests run to its completion to get the full picture 77 | fail-fast: false 78 | matrix: 79 | # Run the testing suite on each major OS with the latest LTS release of Node.js 80 | os: [macos-latest, ubuntu-latest, windows-latest] 81 | node: [lts/*] 82 | # It makes sense to also test the oldest, and latest, versions of Node.js, on ubuntu-only since it's the fastest CI runner 83 | include: 84 | - os: ubuntu-latest 85 | # Test the oldest LTS release of Node that's still receiving bugfixes and security patches, versions older than that have reached End-of-Life 86 | node: lts/-2 87 | - os: ubuntu-latest 88 | # Test the actively developed version that will become the latest LTS release next October 89 | node: current 90 | steps: 91 | # It's only necessary to do this for windows, as mac and ubuntu are sane OS's that already use LF 92 | - name: Set git to use LF 93 | if: matrix.os == 'windows-latest' 94 | run: | 95 | git config --global core.autocrlf false 96 | git config --global core.eol lf 97 | - uses: actions/checkout@v4 98 | - uses: actions/setup-node@v4 99 | with: 100 | cache: npm 101 | node-version: ${{ matrix.node }} 102 | - run: npm i 103 | - run: npm test --if-present 104 | 105 | release: 106 | permissions: 107 | contents: write # to be able to publish a GitHub release 108 | issues: write # to be able to comment on released issues 109 | pull-requests: write # to be able to comment on released pull requests 110 | id-token: write # to enable use of OIDC for npm provenance 111 | needs: [build, test] 112 | # only run if opt-in during workflow_dispatch 113 | if: always() && github.event.inputs.release == 'true' && needs.build.result != 'failure' && needs.test.result != 'failure' && needs.test.result != 'cancelled' 114 | runs-on: ubuntu-latest 115 | name: Semantic release 116 | steps: 117 | - uses: actions/checkout@v4 118 | with: 119 | # Need to fetch entire commit history to 120 | # analyze every commit since last release 121 | fetch-depth: 0 122 | - uses: actions/setup-node@v4 123 | with: 124 | cache: npm 125 | node-version: lts/* 126 | - run: npm ci 127 | # Branches that will release new versions are defined in .releaserc.json 128 | - run: npx semantic-release 129 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 130 | # e.g. git tags were pushed but it exited before `npm publish` 131 | if: always() 132 | env: 133 | NPM_CONFIG_PROVENANCE: true 134 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 135 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 136 | # Re-run semantic release with rich logs if it failed to publish for easier debugging 137 | - run: npx semantic-release --dry-run --debug 138 | if: failure() 139 | env: 140 | NPM_CONFIG_PROVENANCE: true 141 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 142 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @sanity/language-filter 2 | 3 | > For the v2 version, please refer to the [v2 version](https://github.com/sanity-io/sanity/tree/next/packages/%40sanity/language-filter). 4 | 5 | # Field-level translation filter Plugin for Sanity.io 6 | 7 | A Sanity plugin that supports filtering localized fields by language 8 | 9 | ![Language Filter UI](https://github.com/sanity-io/language-filter/assets/9684022/a48fe4b7-975b-424d-9740-386f09ed9cd8) 10 | 11 | ## What this plugin solves 12 | 13 | There are two popular methods of internationalization in Sanity Studio: 14 | 15 | - **Field-level translation** 16 | - A single document with many languages of content 17 | - Achieved by mapping over languages on each field, to create an object 18 | - Best for documents that have a mix of language-specific and common fields 19 | - Not recommended for Portable Text 20 | - **Document-level translation** 21 | - A unique document version for every language 22 | - Joined together by references and/or a predictable `_id` 23 | - Best for documents that have unique, language-specific fields and no common content across languages 24 | - Best for translating content using Portable Text 25 | 26 | This plugin adds features to the Studio to improve handling **field-level translations**. 27 | 28 | - A "Filter Languages" button to show/hide fields in an object of language-specific fields 29 | - Configuration to set "default" languages which are always visible 30 | 31 | For **document-level translations** you should use the [@sanity/document-internationalization plugin](https://www.npmjs.com/package/@sanity/document-internationalization). 32 | 33 | ## Installation 34 | 35 | ``` 36 | npm install --save @sanity/language-filter 37 | ``` 38 | 39 | or 40 | 41 | ``` 42 | yarn add @sanity/language-filter 43 | ``` 44 | 45 | ## Usage 46 | 47 | Add it as a plugin in sanity.config.ts (or .js), and configure it: 48 | 49 | ```ts 50 | import {defineConfig} from 'sanity' 51 | import {languageFilter} from '@sanity/language-filter' 52 | 53 | export const defineConfig({ 54 | //... 55 | plugins: [ 56 | languageFilter({ 57 | supportedLanguages: [ 58 | {id: 'nb', title: 'Norwegian (Bokmål)'}, 59 | {id: 'nn', title: 'Norwegian (Nynorsk)'}, 60 | {id: 'en', title: 'English'}, 61 | {id: 'es', title: 'Spanish'}, 62 | {id: 'arb', title: 'Arabic'}, 63 | {id: 'pt', title: 'Portuguese'}, 64 | //... 65 | ], 66 | // Select Norwegian (Bokmål) by default 67 | defaultLanguages: ['nb'], 68 | // Only show language filter for document type `page` (schemaType.name) 69 | documentTypes: ['page'], 70 | filterField: (enclosingType, member, selectedLanguageIds) => 71 | !enclosingType.name.startsWith('locale') || selectedLanguageIds.includes(member.name), 72 | }) 73 | ] 74 | }) 75 | ``` 76 | 77 | Config properties: 78 | 79 | - `supportedLanguages` can be either: 80 | -- An static array of language objects with `id` and `title`. If your localized fields are defined using our recommended way described here (https://www.sanity.io/docs/localization), you probably want to share this list of supported languages between this config and your schema. 81 | -- A function that returns a promise resolving to an array of language objects with `id` and `title`. This is useful if you want to fetch the list of supported languages from an external source. See [Loading languages](#loading-languages) for more details. 82 | - `defaultLanguages` (optional) is an array of strings where each entry must match an `id` from the `supportedLanguages` array. These languages will be listed by default and will not be possible to unselect. If no `defaultLanguages` is configured, all localized fields will be selected by default. 83 | - `documentTypes` (optional) is an array of strings where each entry must match a `name` from your document schemas. If defined, this property will be used to conditionally show the language filter on specific document schema types. If undefined, the language filter will show on all document schema types. 84 | - `filterField` (optional) is a function that must return true if the field should be displayed. It is passed the enclosing type (e.g the object type containing the localized fields, the field, and an array of the currently selected language ids. 85 | This function is called for all fields and in objects for documents that have language filter enabled. 86 | _Default:_ `!enclosingType.name.startsWith('locale') || selectedLanguageIds.includes(field.name)` 87 | - `apiVersion` (optional) used for the Sanity Client when asynchronously loading languages. 88 | 89 | ## Loading languages 90 | 91 | Languages must be an array of objects with an `id` and `title`. 92 | 93 | ```ts 94 | languages: [ 95 | {id: 'en', title: 'English'}, 96 | {id: 'fr', title: 'French'} 97 | ], 98 | ``` 99 | 100 | Or an asynchronous function that returns an array of objects with an `id` and `title`. 101 | 102 | ```ts 103 | languages: async () => { 104 | const response = await fetch('https://example.com/languages') 105 | return response.json() 106 | } 107 | ``` 108 | 109 | The async function contains a configured Sanity Client in the first parameter, allowing you to store Language options as documents. Your query should return an array of objects with an `id` and `title`. 110 | 111 | ```ts 112 | languages: async (client) => { 113 | const response = await client.fetch(`*[_type == "language"]{ id, title }`) 114 | return response 115 | }, 116 | ``` 117 | 118 | `@sanity/language-filter`'s asynchronous language loading does not currently support modifying the query based on a value in the current document. 119 | 120 | ## Changes in V3 121 | 122 | ### documentTypes 123 | 124 | Language filter can now be enabled/disabled directly from a schema, using `options.languageFilter: boolean`. 125 | When `documentTypes` is omitted from plugin config, use `options.languageFilter: false` in a document-definition to hide the filter button. 126 | When `documentTypes` is provided in plugin config, use `options.languageFilter: true` in a document-definition to show the filter button. 127 | 128 | Example: 129 | 130 | ```js 131 | export const myDocumentSchema = { 132 | type: 'document', 133 | name: 'my-enabled-language-filter-document', 134 | /** ... */ 135 | options: { 136 | // show language filter for this document type, regardless of how documentTypes for the plugin is configured 137 | languageFilter: true, 138 | }, 139 | } 140 | ``` 141 | 142 | ## License 143 | 144 | MIT-licensed. See LICENSE. 145 | 146 | ## Develop & test 147 | 148 | This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit) 149 | with default configuration for build & watch scripts. 150 | 151 | See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio) 152 | on how to run this plugin with hotreload in the studio. 153 | 154 | ### Release new version 155 | 156 | Run ["CI & Release" workflow](https://github.com/sanity-io/language-filter/actions/workflows/main.yml). 157 | Make sure to select the main branch and check "Release new version". 158 | 159 | Semantic release will only release on configured branches, so it is safe to run release on any branch. 160 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 📓 Changelog 4 | 5 | All notable changes to this project will be documented in this file. See 6 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 7 | 8 | ## 4.0.6 (2025-12-17) 9 | 10 | - fix(deps): allow sanity v5 as peer dependency (#74) ([f472f43](https://github.com/sanity-io/language-filter/commit/f472f43)), closes [#74](https://github.com/sanity-io/language-filter/issues/74) 11 | - Update README.md ([e363cec](https://github.com/sanity-io/language-filter/commit/e363cec)) 12 | 13 | ## [4.0.5](https://github.com/sanity-io/language-filter/compare/v4.0.4...v4.0.5) (2025-07-10) 14 | 15 | ### Bug Fixes 16 | 17 | - **deps:** allow studio v4 in peer dep ranges ([#73](https://github.com/sanity-io/language-filter/issues/73)) ([14abbd1](https://github.com/sanity-io/language-filter/commit/14abbd11cc8090ed504a8cdef04d7f3d1cf95248)) 18 | 19 | ## [4.0.4](https://github.com/sanity-io/language-filter/compare/v4.0.3...v4.0.4) (2025-03-31) 20 | 21 | ### Bug Fixes 22 | 23 | - prevent the plugin from filtering out 'FieldError' members ([c551683](https://github.com/sanity-io/language-filter/commit/c551683302803290d133b0f67d0c6bb966c6e5a5)) 24 | 25 | ## [4.0.3](https://github.com/sanity-io/language-filter/compare/v4.0.2...v4.0.3) (2024-12-17) 26 | 27 | ### Bug Fixes 28 | 29 | - make `@sanity/util` a regular dependency again ([15b9f6c](https://github.com/sanity-io/language-filter/commit/15b9f6c0b546f671ab4de0eca216251ed5dbc305)) 30 | - make react 19 compatible ([#70](https://github.com/sanity-io/language-filter/issues/70)) ([1c92729](https://github.com/sanity-io/language-filter/commit/1c927297a5107e14e8128d05fa9065e26edefc9d)) 31 | 32 | ## [4.0.2](https://github.com/sanity-io/language-filter/compare/v4.0.1...v4.0.2) (2024-04-09) 33 | 34 | ### Bug Fixes 35 | 36 | - animate popover ([6ec06c8](https://github.com/sanity-io/language-filter/commit/6ec06c86d3585ecf5fc7c20c69e783bf31270753)) 37 | 38 | ## [4.0.1](https://github.com/sanity-io/language-filter/compare/v4.0.0...v4.0.1) (2024-04-09) 39 | 40 | ### Bug Fixes 41 | 42 | - add provenance ([4f99791](https://github.com/sanity-io/language-filter/commit/4f99791881ab96a16c455d466d184b56b0020402)) 43 | 44 | ## [4.0.0](https://github.com/sanity-io/language-filter/compare/v3.2.2...v4.0.0) (2024-04-09) 45 | 46 | ### ⚠ BREAKING CHANGES 47 | 48 | - support strictESM 49 | 50 | ### Features 51 | 52 | - support strictESM ([810c823](https://github.com/sanity-io/language-filter/commit/810c823773b203711ffe2089657fff8958cc6020)) 53 | 54 | ## [3.2.2](https://github.com/sanity-io/language-filter/compare/v3.2.1...v3.2.2) (2024-01-19) 55 | 56 | ### Bug Fixes 57 | 58 | - update dependencies ([#61](https://github.com/sanity-io/language-filter/issues/61)) ([dee0b9f](https://github.com/sanity-io/language-filter/commit/dee0b9f6fe1b93bca75bfef43ce1cbcca7c1a6c4)) 59 | 60 | ## [3.2.1](https://github.com/sanity-io/language-filter/compare/v3.2.0...v3.2.1) (2023-07-20) 61 | 62 | ### Bug Fixes 63 | 64 | - ensure filterField runs on schema-configured types ([cbd7e6f](https://github.com/sanity-io/language-filter/commit/cbd7e6f35df79aec622449945de871674e1bca0e)) 65 | 66 | ## [3.2.0](https://github.com/sanity-io/language-filter/compare/v3.1.2...v3.2.0) (2023-07-17) 67 | 68 | ### Features 69 | 70 | - async language support ([#48](https://github.com/sanity-io/language-filter/issues/48)) ([72dce7e](https://github.com/sanity-io/language-filter/commit/72dce7ee50b45d46be02e740ef1da980474319b7)) 71 | 72 | ## [3.1.2](https://github.com/sanity-io/language-filter/compare/v3.1.1...v3.1.2) (2023-06-19) 73 | 74 | ### Bug Fixes 75 | 76 | - menu was open-by-default, better gif ([#45](https://github.com/sanity-io/language-filter/issues/45)) ([c40792d](https://github.com/sanity-io/language-filter/commit/c40792d360c326701dcd52ceaf52f108f79cae5c)) 77 | 78 | ## [3.1.1](https://github.com/sanity-io/language-filter/compare/v3.1.0...v3.1.1) (2023-06-19) 79 | 80 | ### Bug Fixes 81 | 82 | - issue with defaultLanguages ([#44](https://github.com/sanity-io/language-filter/issues/44)) ([4e038d7](https://github.com/sanity-io/language-filter/commit/4e038d7f0615cb7454ca4d1a80530bc1e7b3382f)) 83 | 84 | ## [3.1.0](https://github.com/sanity-io/language-filter/compare/v3.0.1...v3.1.0) (2023-06-19) 85 | 86 | ### Features 87 | 88 | - new studio-wide context and exported hooks ([#43](https://github.com/sanity-io/language-filter/issues/43)) ([cc99912](https://github.com/sanity-io/language-filter/commit/cc999120507d3de7e54385166afce26008210066)) 89 | 90 | ## [3.0.1](https://github.com/sanity-io/language-filter/compare/v3.0.0...v3.0.1) (2023-03-08) 91 | 92 | ### Bug Fixes 93 | 94 | - prevent dropdown overflow when language list is too long ([62256cd](https://github.com/sanity-io/language-filter/commit/62256cdc3d771e4ded14a80ad0e13ae5610a4bfa)), closes [#27](https://github.com/sanity-io/language-filter/issues/27) 95 | 96 | ## [3.0.0](https://github.com/sanity-io/language-filter/compare/v2.35.2...v3.0.0) (2022-11-25) 97 | 98 | ### ⚠ BREAKING CHANGES 99 | 100 | - initial Sanity Studio v3 release 101 | 102 | ### Features 103 | 104 | - initial Sanity Studio v3 release ([750f13a](https://github.com/sanity-io/language-filter/commit/750f13af998dd7149f97489933eb5677cba0c1fe)) 105 | - initial v3 plugin impl ([#4](https://github.com/sanity-io/language-filter/issues/4)) ([0bc3072](https://github.com/sanity-io/language-filter/commit/0bc3072ee852e62dc1b2ce957b3a3aa798f37e7f)) 106 | 107 | ### Bug Fixes 108 | 109 | - compiled for sanity 3.0.0-rc.0 ([67b94ea](https://github.com/sanity-io/language-filter/commit/67b94ead55f4cda1ff981b2d5665a98d3b810473)) 110 | - **deps:** @sanity/util ([2c00b6e](https://github.com/sanity-io/language-filter/commit/2c00b6e6f39ad9cb5c873a807059809b0c58d9b3)) 111 | - **deps:** dev-preview.21 ([96b2250](https://github.com/sanity-io/language-filter/commit/96b2250050de0d417fa894061c4f34158974919c)) 112 | - **deps:** dev-preview.22 ([36658f2](https://github.com/sanity-io/language-filter/commit/36658f2a6821dce0188b4bdc8d187d46b06fa063)) 113 | - **deps:** pin dependencies ([#12](https://github.com/sanity-io/language-filter/issues/12)) ([6dc9c88](https://github.com/sanity-io/language-filter/commit/6dc9c8896b51871a48267658845767ef1f6e8b0e)) 114 | - **deps:** pkg-utils & @sanity/plugin-kit ([5359efc](https://github.com/sanity-io/language-filter/commit/5359efc2a82da556b5b3db5ea2c1f370a5401cd9)) 115 | - **deps:** sanity 3.0.0-dev-preview.17 ([efc0030](https://github.com/sanity-io/language-filter/commit/efc003094b3018c7842f0019d19c4cede7fedc3e)) 116 | - **deps:** update sanity-ui-pin ([#13](https://github.com/sanity-io/language-filter/issues/13)) ([d061ad7](https://github.com/sanity-io/language-filter/commit/d061ad7b28ad3d5c5d17e757c0e57e3388541663)) 117 | - documents without languge-filter should no longer crash ([6bffbe7](https://github.com/sanity-io/language-filter/commit/6bffbe7d1051be45f7f3a0c49e281305b929f857)) 118 | - fields within fieldsets are now filtered ([afb7c14](https://github.com/sanity-io/language-filter/commit/afb7c1496fef4fe088fdfdd8af58fb789d7835d7)) 119 | - removed some exports ([7656488](https://github.com/sanity-io/language-filter/commit/7656488f7ad876e3e8b1898ca003d1fc15a3a491)) 120 | 121 | ## [2.36.0](https://github.com/sanity-io/language-filter/compare/v2.35.2...v2.36.0) (2022-11-25) 122 | 123 | ### Features 124 | 125 | - initial v3 plugin impl ([#4](https://github.com/sanity-io/language-filter/issues/4)) ([0bc3072](https://github.com/sanity-io/language-filter/commit/0bc3072ee852e62dc1b2ce957b3a3aa798f37e7f)) 126 | 127 | ### Bug Fixes 128 | 129 | - compiled for sanity 3.0.0-rc.0 ([67b94ea](https://github.com/sanity-io/language-filter/commit/67b94ead55f4cda1ff981b2d5665a98d3b810473)) 130 | - **deps:** @sanity/util ([2c00b6e](https://github.com/sanity-io/language-filter/commit/2c00b6e6f39ad9cb5c873a807059809b0c58d9b3)) 131 | - **deps:** dev-preview.21 ([96b2250](https://github.com/sanity-io/language-filter/commit/96b2250050de0d417fa894061c4f34158974919c)) 132 | - **deps:** dev-preview.22 ([36658f2](https://github.com/sanity-io/language-filter/commit/36658f2a6821dce0188b4bdc8d187d46b06fa063)) 133 | - **deps:** pin dependencies ([#12](https://github.com/sanity-io/language-filter/issues/12)) ([6dc9c88](https://github.com/sanity-io/language-filter/commit/6dc9c8896b51871a48267658845767ef1f6e8b0e)) 134 | - **deps:** pkg-utils & @sanity/plugin-kit ([5359efc](https://github.com/sanity-io/language-filter/commit/5359efc2a82da556b5b3db5ea2c1f370a5401cd9)) 135 | - **deps:** sanity ^3.0.0 (works with rc.3) ([8480344](https://github.com/sanity-io/language-filter/commit/84803444bcf7dc9a5df072cae7d76ce6edf77de6)) 136 | - **deps:** sanity 3.0.0-dev-preview.17 ([efc0030](https://github.com/sanity-io/language-filter/commit/efc003094b3018c7842f0019d19c4cede7fedc3e)) 137 | - **deps:** update sanity-ui-pin ([#13](https://github.com/sanity-io/language-filter/issues/13)) ([d061ad7](https://github.com/sanity-io/language-filter/commit/d061ad7b28ad3d5c5d17e757c0e57e3388541663)) 138 | - documents without languge-filter should no longer crash ([6bffbe7](https://github.com/sanity-io/language-filter/commit/6bffbe7d1051be45f7f3a0c49e281305b929f857)) 139 | - fields within fieldsets are now filtered ([afb7c14](https://github.com/sanity-io/language-filter/commit/afb7c1496fef4fe088fdfdd8af58fb789d7835d7)) 140 | - removed some exports ([7656488](https://github.com/sanity-io/language-filter/commit/7656488f7ad876e3e8b1898ca003d1fc15a3a491)) 141 | 142 | ## [3.0.0-v3-studio.10](https://github.com/sanity-io/language-filter/compare/v3.0.0-v3-studio.9...v3.0.0-v3-studio.10) (2022-11-04) 143 | 144 | ### Bug Fixes 145 | 146 | - **deps:** pkg-utils & @sanity/plugin-kit ([5359efc](https://github.com/sanity-io/language-filter/commit/5359efc2a82da556b5b3db5ea2c1f370a5401cd9)) 147 | 148 | ## [3.0.0-v3-studio.9](https://github.com/sanity-io/language-filter/compare/v3.0.0-v3-studio.8...v3.0.0-v3-studio.9) (2022-11-04) 149 | 150 | ### Bug Fixes 151 | 152 | - **deps:** pin dependencies ([#12](https://github.com/sanity-io/language-filter/issues/12)) ([6dc9c88](https://github.com/sanity-io/language-filter/commit/6dc9c8896b51871a48267658845767ef1f6e8b0e)) 153 | - **deps:** update sanity-ui-pin ([#13](https://github.com/sanity-io/language-filter/issues/13)) ([d061ad7](https://github.com/sanity-io/language-filter/commit/d061ad7b28ad3d5c5d17e757c0e57e3388541663)) 154 | 155 | ## [3.0.0-v3-studio.8](https://github.com/sanity-io/language-filter/compare/v3.0.0-v3-studio.7...v3.0.0-v3-studio.8) (2022-11-03) 156 | 157 | ### Bug Fixes 158 | 159 | - **deps:** @sanity/util ([2c00b6e](https://github.com/sanity-io/language-filter/commit/2c00b6e6f39ad9cb5c873a807059809b0c58d9b3)) 160 | 161 | ## [3.0.0-v3-studio.7](https://github.com/sanity-io/language-filter/compare/v3.0.0-v3-studio.6...v3.0.0-v3-studio.7) (2022-11-03) 162 | 163 | ### Bug Fixes 164 | 165 | - compiled for sanity 3.0.0-rc.0 ([67b94ea](https://github.com/sanity-io/language-filter/commit/67b94ead55f4cda1ff981b2d5665a98d3b810473)) 166 | 167 | ## [3.0.0-v3-studio.6](https://github.com/sanity-io/language-filter/compare/v3.0.0-v3-studio.5...v3.0.0-v3-studio.6) (2022-10-27) 168 | 169 | ### Bug Fixes 170 | 171 | - **deps:** dev-preview.22 ([36658f2](https://github.com/sanity-io/language-filter/commit/36658f2a6821dce0188b4bdc8d187d46b06fa063)) 172 | 173 | ## [3.0.0-v3-studio.5](https://github.com/sanity-io/language-filter/compare/v3.0.0-v3-studio.4...v3.0.0-v3-studio.5) (2022-10-07) 174 | 175 | ### Bug Fixes 176 | 177 | - **deps:** dev-preview.21 ([96b2250](https://github.com/sanity-io/language-filter/commit/96b2250050de0d417fa894061c4f34158974919c)) 178 | 179 | ## [3.0.0-v3-studio.4](https://github.com/sanity-io/language-filter/compare/v3.0.0-v3-studio.3...v3.0.0-v3-studio.4) (2022-09-15) 180 | 181 | ### Bug Fixes 182 | 183 | - documents without languge-filter should no longer crash ([6bffbe7](https://github.com/sanity-io/language-filter/commit/6bffbe7d1051be45f7f3a0c49e281305b929f857)) 184 | 185 | ## [3.0.0-v3-studio.3](https://github.com/sanity-io/language-filter/compare/v3.0.0-v3-studio.2...v3.0.0-v3-studio.3) (2022-09-15) 186 | 187 | ### Bug Fixes 188 | 189 | - **deps:** sanity 3.0.0-dev-preview.17 ([efc0030](https://github.com/sanity-io/language-filter/commit/efc003094b3018c7842f0019d19c4cede7fedc3e)) 190 | 191 | ## [3.0.0-v3-studio.2](https://github.com/sanity-io/language-filter/compare/v3.0.0-v3-studio.1...v3.0.0-v3-studio.2) (2022-08-31) 192 | 193 | ### Bug Fixes 194 | 195 | - fields within fieldsets are now filtered ([afb7c14](https://github.com/sanity-io/language-filter/commit/afb7c1496fef4fe088fdfdd8af58fb789d7835d7)) 196 | 197 | ## [3.0.0-v3-studio.1](https://github.com/sanity-io/language-filter/compare/v3.0.0-v3-studio.0...v3.0.0-v3-studio.1) (2022-08-31) 198 | 199 | ### Bug Fixes 200 | 201 | - removed some exports ([7656488](https://github.com/sanity-io/language-filter/commit/7656488f7ad876e3e8b1898ca003d1fc15a3a491)) 202 | 203 | ## 1.0.0-firstpass-v3-impl.1 (2022-08-29) 204 | 205 | ### Features 206 | 207 | - initial v3 plugin impl ([f745b63](https://github.com/sanity-io/language-filter/commit/f745b6354ffb087e558b566bf290ed7e973bec1a)) 208 | --------------------------------------------------------------------------------