├── .prettierignore ├── .husky ├── pre-commit └── commit-msg ├── commitlint.config.js ├── .releaserc.json ├── docs ├── images │ ├── hero.jpg │ ├── hero-validation.jpg │ ├── hero-validation2.jpg │ └── examples │ │ ├── custom-rows.jpg │ │ ├── custom-rows2.jpg │ │ ├── dropdown-ui.jpg │ │ ├── multiple-ui.jpg │ │ ├── number-field.jpg │ │ ├── position-top.jpg │ │ ├── string-field.jpg │ │ ├── text-field.jpg │ │ ├── list-of-values.jpg │ │ ├── global-validation.jpg │ │ ├── children-validation.jpg │ │ ├── hidden-locale-field.jpg │ │ ├── selected-background.jpg │ │ ├── readonly-locale-field.jpg │ │ ├── alternative-locale-label.jpg │ │ └── alternative-locale-label-2.jpg └── examples │ ├── basic-configuration.md │ ├── text-field.md │ ├── number-field.md │ ├── string-field.md │ ├── global-validation.md │ ├── hide-specific-locale-for-a-single-field.md │ ├── locale-not-editable-for-a-specific-field.md │ ├── alternative-locale-label.md │ ├── dropdown-ui.md │ ├── slider-top-position.md │ ├── conditionally-set-a-locale-visible-or-not-editable.md │ ├── children-validation.md │ ├── global-user-roles-visibility.md │ ├── slider-with-background-ui-option.md │ ├── alternative-locale-label-2.md │ ├── list-of-values.md │ ├── custom-rows-for-i18ntext.md │ ├── multiple-ui-on-different-fields.md │ └── customized-prefix.md ├── .eslintignore ├── .prettierrc ├── lint-staged.config.js ├── sanity.json ├── src ├── index.ts ├── lib │ ├── getSchemaTitle.ts │ ├── constants.ts │ ├── getSchemaName.ts │ ├── checkFieldChanged.ts │ ├── validateConditionalProperty.ts │ ├── checkFieldError.ts │ ├── userCan.ts │ ├── mergeLocaleConfiguration.ts │ └── validateLocaleRestrictions.ts ├── hooks │ ├── useUiInfo.ts │ ├── useUserInfo.ts │ ├── useWrapperHeight.ts │ ├── useSetupCssVars.ts │ ├── useValidationInfo.ts │ ├── useSlider.ts │ ├── useMembersInfo.ts │ └── useLocalesInfo.ts ├── components │ ├── shared │ │ ├── IconLabel.tsx │ │ └── ErrorWarningIcons.tsx │ ├── Dropdown │ │ ├── Dropdown.Menu.tsx │ │ ├── Dropdown.Wrapper.tsx │ │ ├── Dropdown.Button.tsx │ │ └── Dropdown.Menu.Item.tsx │ ├── Slider │ │ ├── Slider.Wrapper.tsx │ │ └── Slider.Slide.tsx │ └── I18nDefaultField.tsx ├── schemas │ └── I18nDefaultSchema.ts ├── plugin.ts ├── types │ ├── I18nFields.ts │ ├── Locale.ts │ └── Sanity.ts └── style │ └── style.scss ├── tsconfig.json ├── .editorconfig ├── .eslintrc ├── v2-incompatible.js ├── tsconfig.dist.json ├── tsconfig.settings.json ├── package.config.ts ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── package.json ├── .github └── workflows │ └── main.yml └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | pnpm-lock.yaml 3 | yarn.lock 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /docs/images/hero.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/hero.jpg -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js 2 | .eslintrc.js 3 | commitlint.config.js 4 | dist 5 | lint-staged.config.js 6 | package.config.ts 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 100, 4 | "bracketSpacing": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /docs/images/hero-validation.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/hero-validation.jpg -------------------------------------------------------------------------------- /docs/images/hero-validation2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/hero-validation2.jpg -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '**/*.{js,jsx}': ['eslint'], 3 | '**/*.{ts,tsx}': ['eslint', () => 'tsc --build'], 4 | } 5 | -------------------------------------------------------------------------------- /docs/images/examples/custom-rows.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/custom-rows.jpg -------------------------------------------------------------------------------- /docs/images/examples/custom-rows2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/custom-rows2.jpg -------------------------------------------------------------------------------- /docs/images/examples/dropdown-ui.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/dropdown-ui.jpg -------------------------------------------------------------------------------- /docs/images/examples/multiple-ui.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/multiple-ui.jpg -------------------------------------------------------------------------------- /docs/images/examples/number-field.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/number-field.jpg -------------------------------------------------------------------------------- /docs/images/examples/position-top.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/position-top.jpg -------------------------------------------------------------------------------- /docs/images/examples/string-field.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/string-field.jpg -------------------------------------------------------------------------------- /docs/images/examples/text-field.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/text-field.jpg -------------------------------------------------------------------------------- /docs/images/examples/list-of-values.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/list-of-values.jpg -------------------------------------------------------------------------------- /docs/images/examples/global-validation.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/global-validation.jpg -------------------------------------------------------------------------------- /docs/images/examples/children-validation.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/children-validation.jpg -------------------------------------------------------------------------------- /docs/images/examples/hidden-locale-field.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/hidden-locale-field.jpg -------------------------------------------------------------------------------- /docs/images/examples/selected-background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/selected-background.jpg -------------------------------------------------------------------------------- /docs/images/examples/readonly-locale-field.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/readonly-locale-field.jpg -------------------------------------------------------------------------------- /sanity.json: -------------------------------------------------------------------------------- 1 | { 2 | "parts": [ 3 | { 4 | "implements": "part:@sanity/base/sanity-root", 5 | "path": "./v2-incompatible.js" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /docs/images/examples/alternative-locale-label.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/alternative-locale-label.jpg -------------------------------------------------------------------------------- /docs/images/examples/alternative-locale-label-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamiommi/sanity-plugin-i18n-fields/HEAD/docs/images/examples/alternative-locale-label-2.jpg -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './style/style.scss' 2 | 3 | export {I18nFields} from './plugin' 4 | 5 | export * from './types/I18nFields' 6 | export * from './types/Locale' 7 | export * from './types/Sanity' 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/lib/getSchemaTitle.ts: -------------------------------------------------------------------------------- 1 | import {I18nFieldType} from '../types/I18nFields' 2 | 3 | const getSchemaTitle = (prefix: string, type: I18nFieldType): string => { 4 | return `${prefix} ${type.charAt(0).toUpperCase()}${prefix.substring(1)}` 5 | } 6 | 7 | export default getSchemaTitle 8 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import {FormNodeValidation} from 'sanity' 2 | 3 | export const DEFAULT_PREFIX = 'i18n' 4 | 5 | export const ROLE_ADMIN = 'administrator' 6 | 7 | export const EMPTY_FORM_NODE_VALIDATION: FormNodeValidation = { 8 | level: 'error', 9 | message: ' ', 10 | path: [], 11 | } 12 | -------------------------------------------------------------------------------- /.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 | } 16 | -------------------------------------------------------------------------------- /src/lib/getSchemaName.ts: -------------------------------------------------------------------------------- 1 | import {I18nFieldType} from '../types/I18nFields' 2 | 3 | const getSchemaName = (prefix: string, type?: I18nFieldType): string => { 4 | const trimmedPrefix = prefix.toLowerCase().replace(/\s/g, '') 5 | if (!type) return trimmedPrefix 6 | return `${trimmedPrefix}.${type}` 7 | } 8 | 9 | export default getSchemaName 10 | -------------------------------------------------------------------------------- /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: undefined, 9 | }, 10 | sanityExchangeUrl, 11 | }) 12 | -------------------------------------------------------------------------------- /src/hooks/useUiInfo.ts: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react' 2 | import {I18nFieldsConfigUI} from '../types/I18nFields' 3 | 4 | const useUiInfo = (ui?: I18nFieldsConfigUI, fieldUi?: I18nFieldsConfigUI): I18nFieldsConfigUI => { 5 | const pluginUi = useMemo(() => ({...ui, ...fieldUi}), [ui, fieldUi]) 6 | return pluginUi 7 | } 8 | 9 | export default useUiInfo 10 | -------------------------------------------------------------------------------- /docs/examples/basic-configuration.md: -------------------------------------------------------------------------------- 1 | ### Example: Basic Configuration 2 | ```ts 3 | I18nFields({ 4 | locales: [ 5 | {code: 'en', label: '🇬🇧', title: 'English', default: true}, 6 | {code: 'en_us', label: '🇺🇸🇬🇧', title: 'American English'}, 7 | {code: 'it', label: '🇮🇹', title: 'Italian'}, 8 | {code: 'es', label: '🇪🇸', title: 'Spanish'}, 9 | ] 10 | }) 11 | ``` -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/examples/text-field.md: -------------------------------------------------------------------------------- 1 | ### Example: Text Field 2 | ```ts 3 | defineField({ 4 | type: 'i18n.text', 5 | name: 'title', 6 | title: 'Title', 7 | description: 'Title description', 8 | }) 9 | ``` 10 |

11 | Example: Text Field 12 |

-------------------------------------------------------------------------------- /docs/examples/number-field.md: -------------------------------------------------------------------------------- 1 | ### Example: Number Field 2 | ```ts 3 | defineField({ 4 | type: 'i18n.number', 5 | name: 'title', 6 | title: 'Title', 7 | description: 'Title description', 8 | }) 9 | ``` 10 |

11 | Example: Number Field 12 |

-------------------------------------------------------------------------------- /docs/examples/string-field.md: -------------------------------------------------------------------------------- 1 | ### Example: String Field 2 | ```ts 3 | defineField({ 4 | type: 'i18n.string', 5 | name: 'title', 6 | title: 'Title', 7 | description: 'Title description', 8 | }) 9 | ``` 10 |

11 | Example: String Field 12 |

-------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/examples/global-validation.md: -------------------------------------------------------------------------------- 1 | ### Example: Global Validation 2 | ```ts 3 | defineField({ 4 | type: 'i18n.string', 5 | name: 'field3', 6 | title: 'Field 3', 7 | validation: (Rule) => Rule.required(), 8 | }), 9 | ``` 10 |

11 | Example: Global Validation 12 |

-------------------------------------------------------------------------------- /src/lib/checkFieldChanged.ts: -------------------------------------------------------------------------------- 1 | import {FieldMember, ObjectMember} from 'sanity' 2 | import {InternalLocale} from '../types/Locale' 3 | 4 | const checkFieldChanged = (locale: InternalLocale, members: ObjectMember[]): InternalLocale => { 5 | const currentMember = (members as FieldMember[]).find((member) => member.name === locale.code) 6 | if (!currentMember) return locale 7 | return {...locale, isChanged: currentMember.field.changed} 8 | } 9 | 10 | export default checkFieldChanged 11 | -------------------------------------------------------------------------------- /docs/examples/hide-specific-locale-for-a-single-field.md: -------------------------------------------------------------------------------- 1 | ### Example: Hide specific locale for a single field 2 | ```ts 3 | defineField({ 4 | type: 'i18n.string', 5 | name: 'field1', 6 | title: 'Field 1', 7 | options: { 8 | locales: [{ 9 | code: 'it', 10 | hidden: true 11 | }] 12 | } 13 | }), 14 | ``` 15 |

16 | Example: Slider with background UI option 17 |

-------------------------------------------------------------------------------- /src/hooks/useUserInfo.ts: -------------------------------------------------------------------------------- 1 | import {CurrentUser, useCurrentUser} from 'sanity' 2 | import {useMemo} from 'react' 3 | 4 | interface useUserInfoResponse { 5 | currentUser: CurrentUser | null 6 | userRoles: string[] 7 | } 8 | 9 | const useUserInfo = (): useUserInfoResponse => { 10 | // get current user from sanity 11 | const currentUser = useCurrentUser() 12 | // extract roles from the current user 13 | const userRoles = useMemo(() => currentUser?.roles.map((role) => role.name) || [], [currentUser]) 14 | return {currentUser, userRoles} 15 | } 16 | 17 | export default useUserInfo 18 | -------------------------------------------------------------------------------- /docs/examples/locale-not-editable-for-a-specific-field.md: -------------------------------------------------------------------------------- 1 | ### Example: Locale not editable for a specific field 2 | ```ts 3 | defineField({ 4 | type: 'i18n.string', 5 | name: 'field1', 6 | title: 'Field 1', 7 | options: { 8 | locales: [{ 9 | code: 'en', 10 | readOnly: true 11 | }] 12 | } 13 | }), 14 | ``` 15 |

16 | Example: Locale not editable for a specific field 17 |

-------------------------------------------------------------------------------- /src/components/shared/IconLabel.tsx: -------------------------------------------------------------------------------- 1 | import {ComponentType, ReactNode, createElement, isValidElement} from 'react' 2 | import {isValidElementType} from 'react-is' 3 | import {UnknownIcon} from '@sanity/icons' 4 | 5 | interface IconLabelProps { 6 | label: ReactNode | ComponentType 7 | } 8 | 9 | const IconLabel = ({label}: IconLabelProps) => { 10 | if (isValidElement(label) || typeof label === 'string' || typeof label === 'number') return label 11 | if (isValidElementType(label)) return createElement(label) 12 | return 13 | } 14 | 15 | export default IconLabel 16 | -------------------------------------------------------------------------------- /src/lib/validateConditionalProperty.ts: -------------------------------------------------------------------------------- 1 | import {ConditionalProperty, CurrentUser, SanityDocument} from 'sanity' 2 | 3 | const validateConditionalProperty = ( 4 | condition: ConditionalProperty, 5 | document: SanityDocument | undefined, 6 | currentUser: CurrentUser | null, 7 | value: {[key: string]: unknown} | undefined 8 | ): boolean => { 9 | if (typeof condition === 'boolean') return condition 10 | if (typeof condition === 'function') 11 | return condition({currentUser, document, value, parent: document}) 12 | return false 13 | } 14 | 15 | export default validateConditionalProperty 16 | -------------------------------------------------------------------------------- /docs/examples/alternative-locale-label.md: -------------------------------------------------------------------------------- 1 | ### Example: Alternative Locale Label 2 | ```ts 3 | I18nFields({ 4 | locales: [ 5 | {code: 'en', label: 'EN', title: 'English', default: true}, 6 | {code: 'en_us', label: 'EN-US', title: 'American English'}, 7 | {code: 'it', label: 'IT', title: 'Italian'}, 8 | {code: 'es', label: 'ES', title: 'Spanish'}, 9 | ] 10 | }) 11 | ``` 12 |

13 | Example: Alternative Locale Label 14 |

-------------------------------------------------------------------------------- /docs/examples/dropdown-ui.md: -------------------------------------------------------------------------------- 1 | ### Example: Dropdown UI 2 | ```ts 3 | I18nFields({ 4 | ui: { 5 | type: 'dropdown' 6 | }, 7 | locales: [ 8 | {code: 'en', label: '🇬🇧', title: 'English', default: true}, 9 | {code: 'en_us', label: '🇺🇸🇬🇧', title: 'American English'}, 10 | {code: 'it', label: '🇮🇹', title: 'Italian'}, 11 | {code: 'es', label: '🇪🇸', title: 'Spanish'}, 12 | ] 13 | }) 14 | ``` 15 |

16 | Example: Dropdown UI 17 |

-------------------------------------------------------------------------------- /src/lib/checkFieldError.ts: -------------------------------------------------------------------------------- 1 | import {FieldMember, ObjectMember} from 'sanity' 2 | import {InternalLocale} from '../types/Locale' 3 | 4 | const checkFieldError = (locale: InternalLocale, members: ObjectMember[]): InternalLocale => { 5 | const currentMember = (members as FieldMember[]).find((member) => member.name === locale.code) 6 | if (!currentMember) return locale 7 | const hasError = !!currentMember.field.validation.find((val) => val.level === 'error') 8 | const hasWarning = !!currentMember.field.validation.find((val) => val.level === 'warning') 9 | return {...locale, hasError, hasWarning} 10 | } 11 | 12 | export default checkFieldError 13 | -------------------------------------------------------------------------------- /docs/examples/slider-top-position.md: -------------------------------------------------------------------------------- 1 | ### Example: Slider top position 2 | ```ts 3 | I18nFields({ 4 | ui: { 5 | position: 'top' 6 | }, 7 | locales: [ 8 | {code: 'en', label: '🇬🇧', title: 'English', default: true}, 9 | {code: 'en_us', label: '🇺🇸🇬🇧', title: 'American English'}, 10 | {code: 'it', label: '🇮🇹', title: 'Italian'}, 11 | {code: 'es', label: '🇪🇸', title: 'Spanish'}, 12 | ] 13 | }) 14 | ``` 15 |

16 | Example: Slider top position 17 |

-------------------------------------------------------------------------------- /docs/examples/conditionally-set-a-locale-visible-or-not-editable.md: -------------------------------------------------------------------------------- 1 | ### Example: Conditionally set a locale visible or not editable 2 | ```ts 3 | defineField({ 4 | type: 'i18n.string', 5 | name: 'field1', 6 | title: 'Field 1', 7 | options: { 8 | locales: [ 9 | { 10 | code: 'it', 11 | hidden: ({value}) => { 12 | return value?.en === 'pizza with 🍍' ? true : false 13 | }, 14 | }, 15 | { 16 | code: 'en', 17 | readOnly: ({value}) => { 18 | return value?.it === 'pizza' ? true : false 19 | }, 20 | }, 21 | ], 22 | }, 23 | }), 24 | ``` -------------------------------------------------------------------------------- /docs/examples/children-validation.md: -------------------------------------------------------------------------------- 1 | ### Example: Children Validation 2 | ```ts 3 | defineField({ 4 | type: 'i18n.string', 5 | name: 'field2', 6 | title: 'Field 2', 7 | validation: (Rule) => 8 | Rule.custom((value) => { 9 | return ( 10 | value?.en !== 'fast' || { 11 | message: `You can't use the word 'fast' for en locale`, 12 | paths: [['en']], 13 | } 14 | ) 15 | }), 16 | }), 17 | ``` 18 |

19 | Example: Children Validation 20 |

-------------------------------------------------------------------------------- /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 | rollup: { 9 | plugins: [ 10 | postcss({ 11 | extract: false, 12 | minimize: true, 13 | }), 14 | ], 15 | }, 16 | 17 | // Remove this block to enable strict export validation 18 | extract: { 19 | rules: { 20 | 'ae-forgotten-export': 'off', 21 | 'ae-incompatible-release-tags': 'off', 22 | 'ae-internal-missing-underscore': 'off', 23 | 'ae-missing-release-tag': 'off', 24 | }, 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /src/hooks/useWrapperHeight.ts: -------------------------------------------------------------------------------- 1 | import {RefObject, useEffect, useRef, useState} from 'react' 2 | 3 | interface useWrapperHeightResponse { 4 | memberRef: RefObject 5 | wrapperHeight: string 6 | } 7 | 8 | const useWrapperHeight = (collapsed?: boolean): useWrapperHeightResponse => { 9 | const memberRef = useRef(null) 10 | const [wrapperHeight, setWrapperHeight] = useState('0px') 11 | 12 | useEffect(() => { 13 | if (memberRef.current && !collapsed) { 14 | setWrapperHeight(`${memberRef.current.offsetHeight}px`) 15 | } 16 | }, [memberRef, collapsed]) 17 | 18 | return {memberRef, wrapperHeight} 19 | } 20 | 21 | export default useWrapperHeight 22 | -------------------------------------------------------------------------------- /docs/examples/global-user-roles-visibility.md: -------------------------------------------------------------------------------- 1 | ### Example: Global User Roles Visibility 2 | ```ts 3 | I18nFields({ 4 | locales: [ 5 | {code: 'en', label: '🇬🇧', title: 'English', default: true}, 6 | {code: 'en_us', label: '🇺🇸🇬🇧', title: 'American English', visibleFor: ['us_editor']}, // visible only for administrator and us_editor roles 7 | {code: 'it', label: '🇮🇹', title: 'Italian', editableFor: ['it_editor']}, // visible for everyone but editable only for administrator and it_editor roles. 8 | {code: 'es', label: '🇪🇸', title: 'Spanish', editableFor: ['!movie_editor']}, // visible and editable for everyone. It will be readonly for movie_editor role. 9 | ] 10 | }) 11 | ``` -------------------------------------------------------------------------------- /docs/examples/slider-with-background-ui-option.md: -------------------------------------------------------------------------------- 1 | ### Example: Slider with background UI option 2 | ```ts 3 | I18nFields({ 4 | ui: { 5 | selected: 'background' 6 | }, 7 | locales: [ 8 | {code: 'en', label: '🇬🇧', title: 'English', default: true}, 9 | {code: 'en_us', label: '🇺🇸🇬🇧', title: 'American English'}, 10 | {code: 'it', label: '🇮🇹', title: 'Italian'}, 11 | {code: 'es', label: '🇪🇸', title: 'Spanish'}, 12 | ] 13 | }) 14 | ``` 15 |

16 | Example: Slider with background UI option 17 |

-------------------------------------------------------------------------------- /src/components/Dropdown/Dropdown.Menu.tsx: -------------------------------------------------------------------------------- 1 | import {Menu} from '@sanity/ui' 2 | import DropdownMenuItem from './Dropdown.Menu.Item' 3 | import {InternalLocale} from '../../types/Locale' 4 | import {Dispatch, SetStateAction} from 'react' 5 | 6 | interface DropdownMenuProps { 7 | locales: InternalLocale[] 8 | name: string 9 | onClick: Dispatch> 10 | } 11 | 12 | const DropdownMenu = ({locales, name, onClick}: DropdownMenuProps) => { 13 | return ( 14 | 15 | {locales.map((locale) => ( 16 | 17 | ))} 18 | 19 | ) 20 | } 21 | 22 | export default DropdownMenu 23 | -------------------------------------------------------------------------------- /src/components/Dropdown/Dropdown.Wrapper.tsx: -------------------------------------------------------------------------------- 1 | import {MenuButton} from '@sanity/ui' 2 | import DropdownButton from './Dropdown.Button' 3 | import DropdownMenu from './Dropdown.Menu' 4 | import {InternalLocale} from '../../types/Locale' 5 | import {Dispatch, SetStateAction} from 'react' 6 | 7 | interface DropdownWrapperProps { 8 | name: string 9 | activeLocale: InternalLocale 10 | locales: InternalLocale[] 11 | onClick: Dispatch> 12 | } 13 | const DropdownWrapper = ({name, activeLocale, locales, onClick}: DropdownWrapperProps) => { 14 | return ( 15 | 20 | ) 21 | } 22 | 23 | export default DropdownWrapper 24 | -------------------------------------------------------------------------------- /docs/examples/alternative-locale-label-2.md: -------------------------------------------------------------------------------- 1 | ### Example: Alternative Locale Label 2 2 | ```ts 3 | import {FaPizzaSlice} from 'react-icons/fa' 4 | import {GiTeapot, GiBull, GiAmericanFootballBall} from 'react-icons/gi' 5 | 6 | I18nFields({ 7 | locales: [ 8 | {code: 'en', label: GiTeapot, title: 'English', default: true}, 9 | {code: 'en_us', label: GiAmericanFootballBall, title: 'American English'}, 10 | {code: 'it', label: FaPizzaSlice, title: 'Italian'}, 11 | {code: 'es', label: GiBull, title: 'Spanish'}, 12 | ] 13 | }) 14 | ``` 15 |

16 | Example: Alternative Locale Label 2 17 |

-------------------------------------------------------------------------------- /docs/examples/list-of-values.md: -------------------------------------------------------------------------------- 1 | ### Example: List of values 2 | ```ts 3 | const levelList = [ 4 | {value: undefined, title: 'No Value'}, 5 | {value: 1, title: 'Level 1'}, 6 | {value: 1, title: 'Level 2'}, 7 | {value: 3, title: 'Level 3'}, 8 | ] 9 | 10 | defineField({ 11 | type: 'i18n.number', 12 | name: 'field3', 13 | title: 'Field 3', 14 | options: { 15 | locales: [ 16 | { 17 | code: 'en', 18 | options: { 19 | list: levelList, 20 | layout: 'radio', 21 | }, 22 | }, 23 | ], 24 | }, 25 | }), 26 | ``` 27 |

28 | Example: List of values 29 |

-------------------------------------------------------------------------------- /docs/examples/custom-rows-for-i18ntext.md: -------------------------------------------------------------------------------- 1 | ### Example: Custom rows for i18n.text 2 | ```ts 3 | defineField({ 4 | type: 'i18n.text', 5 | name: 'title', 6 | title: 'Title', 7 | description: 'Title description', 8 | options: { 9 | rows: 10, 10 | locales: [ 11 | { 12 | code: 'it', 13 | options: { 14 | rows: 5, 15 | }, 16 | }, 17 | ], 18 | }, 19 | }), 20 | ``` 21 |

22 | Example: Custom rows for i18n.text 23 | Example: Custom rows for i18n.text 24 |

-------------------------------------------------------------------------------- /src/components/shared/ErrorWarningIcons.tsx: -------------------------------------------------------------------------------- 1 | import {useTheme} from '@sanity/ui' 2 | import {ErrorOutlineIcon, WarningOutlineIcon} from '@sanity/icons' 3 | import {InternalLocale} from '../../types/Locale' 4 | 5 | interface ErrorWarningIconsProps { 6 | locale: InternalLocale 7 | } 8 | const ErrorWarningIcons = ({locale}: ErrorWarningIconsProps) => { 9 | const sanityTheme = useTheme() 10 | return ( 11 | <> 12 | {locale.hasError && ( 13 | 17 | )} 18 | {locale.hasWarning && ( 19 | 23 | )} 24 | 25 | ) 26 | } 27 | 28 | export default ErrorWarningIcons 29 | -------------------------------------------------------------------------------- /src/schemas/I18nDefaultSchema.ts: -------------------------------------------------------------------------------- 1 | import {defineField, defineType} from 'sanity' 2 | import {I18nFieldType, I18nFieldsConfig} from '../types/I18nFields' 3 | import I18nDefaultField from '../components/I18nDefaultField' 4 | import getSchemaName from '../lib/getSchemaName' 5 | import {DEFAULT_PREFIX} from '../lib/constants' 6 | import getSchemaTitle from '../lib/getSchemaTitle' 7 | 8 | export const I18nDefaultSchema = (pluginConfig: I18nFieldsConfig, type: I18nFieldType): any => 9 | defineType({ 10 | name: getSchemaName(pluginConfig.prefix || DEFAULT_PREFIX, type), 11 | type: 'object', 12 | title: getSchemaTitle(pluginConfig.prefix || DEFAULT_PREFIX, type), 13 | fields: [...pluginConfig.locales.map((locale) => defineField({type, name: locale.code}))], 14 | components: { 15 | field: (props) => I18nDefaultField(props, pluginConfig, type), 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import {definePlugin} from 'sanity' 2 | import {I18nFieldsConfig, defaultI18nFieldsConfigUI} from './types/I18nFields' 3 | import {I18nDefaultSchema} from './schemas/I18nDefaultSchema' 4 | import {DEFAULT_PREFIX} from './lib/constants' 5 | import getSchemaName from './lib/getSchemaName' 6 | 7 | export const I18nFields = definePlugin((config: I18nFieldsConfig) => { 8 | const pluginConfig = { 9 | prefix: config.prefix || DEFAULT_PREFIX, 10 | locales: config.locales, 11 | ui: {...defaultI18nFieldsConfigUI, ...config.ui}, 12 | } 13 | return { 14 | name: `sanity-plugin-${getSchemaName(pluginConfig.prefix)}-fields`, 15 | schema: { 16 | types: [ 17 | I18nDefaultSchema(pluginConfig, 'string'), 18 | I18nDefaultSchema(pluginConfig, 'text'), 19 | I18nDefaultSchema(pluginConfig, 'number'), 20 | ], 21 | }, 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /src/hooks/useSetupCssVars.ts: -------------------------------------------------------------------------------- 1 | import {useTheme} from '@sanity/ui' 2 | import {useEffect} from 'react' 3 | 4 | // setting up css variables to use sanity/ui colors inside css style 5 | const useSetupCssVars = (): void => { 6 | const sanityTheme = useTheme() 7 | useEffect(() => { 8 | document.documentElement.style.setProperty('--i18n-base-bg', sanityTheme.sanity.color.base.bg) 9 | document.documentElement.style.setProperty( 10 | '--i18n-readOnly-warning', 11 | sanityTheme.sanity.color.solid.caution.hovered.bg 12 | ) 13 | document.documentElement.style.setProperty( 14 | '--i18n-bg-selected', 15 | sanityTheme.sanity.color.card.selected.bg 16 | ) 17 | document.documentElement.style.setProperty( 18 | '--i18n-bg-hover', 19 | sanityTheme.sanity.color.card.hovered.bg 20 | ) 21 | }, [sanityTheme.sanity.color.dark]) // update colors when switching dark/light theme 22 | } 23 | 24 | export default useSetupCssVars 25 | -------------------------------------------------------------------------------- /src/hooks/useValidationInfo.ts: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react' 2 | import {FieldMember, FormNodeValidation, ObjectMember} from 'sanity' 3 | 4 | interface useValidationInfoResponse { 5 | hasGlobalError: boolean 6 | mergedValidation: FormNodeValidation[] 7 | } 8 | 9 | const useValidationInfo = ( 10 | globalValidation: FormNodeValidation[], 11 | members: ObjectMember[] 12 | ): useValidationInfoResponse => { 13 | // get all validation erros from every single locale and create a single array of errors 14 | const allLocalesValidation = useMemo(() => { 15 | const output: FormNodeValidation[] = [] 16 | ;(members as FieldMember[]).forEach((member) => { 17 | output.push(...(member.field.validation || [])) 18 | }) 19 | return output 20 | }, [members]) 21 | 22 | return { 23 | hasGlobalError: globalValidation.length > 0, 24 | mergedValidation: [...globalValidation, ...allLocalesValidation], 25 | } 26 | } 27 | 28 | export default useValidationInfo 29 | -------------------------------------------------------------------------------- /src/types/I18nFields.ts: -------------------------------------------------------------------------------- 1 | import {I18nNumberLocale, I18nStringLocale, I18nTextLocale, Locale} from './Locale' 2 | 3 | export type I18nFieldType = 'string' | 'text' | 'number' 4 | 5 | export interface I18nFieldsConfigUI { 6 | type?: 'slider' | 'dropdown' 7 | position?: 'top' | 'bottom' 8 | selected?: 'background' | 'border' 9 | } 10 | 11 | export interface I18nStringOptions { 12 | locales?: I18nStringLocale[] 13 | ui?: I18nFieldsConfigUI 14 | } 15 | 16 | export interface I18nNumberOptions { 17 | locales?: I18nNumberLocale[] 18 | ui?: I18nFieldsConfigUI 19 | } 20 | 21 | export interface I18nTextOptions { 22 | rows?: number 23 | locales?: I18nTextLocale[] 24 | ui?: I18nFieldsConfigUI 25 | } 26 | 27 | export interface I18nFieldsConfig { 28 | prefix?: string 29 | locales: Locale[] 30 | ui?: I18nFieldsConfigUI 31 | } 32 | 33 | export const defaultI18nFieldsConfigUI: I18nFieldsConfigUI = { 34 | type: 'slider', 35 | position: 'bottom', 36 | selected: 'border', 37 | } 38 | -------------------------------------------------------------------------------- /.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 | 62 | sanity-install.sh -------------------------------------------------------------------------------- /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 | ## 1.0.0 (2023-07-30) 9 | 10 | ### Features 11 | 12 | - i18n-fields plugin ([0f97f57](https://github.com/williamiommi/sanity-plugin-i18n-fields/commit/0f97f573cce0afce039ee2c09dfcbf7f1add299b)) 13 | - **setup:** initial commit w/ @sanity/plugin-kit (semver preset) ([0552687](https://github.com/williamiommi/sanity-plugin-i18n-fields/commit/0552687a3f06d6c8c116609e6b4b501e835b9622)) 14 | 15 | ### Bug Fixes 16 | 17 | - fix issue about readonly field if user can't edit (not coming from plugin configuration) ([f6b9e08](https://github.com/williamiommi/sanity-plugin-i18n-fields/commit/f6b9e08cec1f09e4e1eef9ae6caa01d6645f9a2c)) 18 | - wrong dynamic wrapper height if field start as collapsed ([2ef0dc3](https://github.com/williamiommi/sanity-plugin-i18n-fields/commit/2ef0dc3052d5630e3dd7a21356bad22c26d08c98)) 19 | -------------------------------------------------------------------------------- /src/hooks/useSlider.ts: -------------------------------------------------------------------------------- 1 | import {useKeenSlider} from 'keen-slider/react' 2 | import 'keen-slider/keen-slider.min.css' 3 | 4 | type useSliderResponse = (node: HTMLDivElement | null) => void 5 | 6 | const useSlider = (): useSliderResponse => { 7 | const [keenRef] = useKeenSlider( 8 | { 9 | mode: 'free', 10 | slides: {perView: 'auto', spacing: 5}, 11 | created(keen) { 12 | const { 13 | container, 14 | track: { 15 | details: {progress}, 16 | }, 17 | } = keen 18 | container.dataset.begin = `true` 19 | container.dataset.end = `${!!isNaN(progress)}` 20 | }, 21 | dragged(keen) { 22 | const { 23 | container, 24 | track: { 25 | details: {progress}, 26 | }, 27 | } = keen 28 | container.dataset.begin = `${progress <= 0.05}` 29 | container.dataset.end = `${progress >= 0.95}` 30 | }, 31 | }, 32 | [] 33 | ) 34 | return keenRef 35 | } 36 | 37 | export default useSlider 38 | -------------------------------------------------------------------------------- /src/lib/userCan.ts: -------------------------------------------------------------------------------- 1 | import {ROLE_ADMIN} from './constants' 2 | 3 | const userCan = (userRoles: string[], restrictedRoles?: string[]): boolean => { 4 | if (!restrictedRoles || restrictedRoles.length === 0) return true 5 | let userCanFlag = false 6 | // split restrictedRoles in 2 Set 7 | const canRolesSet = new Set() 8 | const cannotRolesSet = new Set() 9 | // always adding admin to canRolesSet 10 | canRolesSet.add(ROLE_ADMIN) 11 | restrictedRoles.forEach((role) => { 12 | if (role.startsWith('!')) cannotRolesSet.add(role.substring(1, role.length)) 13 | else canRolesSet.add(role) 14 | }) 15 | 16 | if (canRolesSet.size !== 0) { 17 | const canRolesArray = Array.from(canRolesSet) 18 | userCanFlag = userRoles.some((role) => canRolesArray.includes(role)) 19 | } 20 | 21 | if (!userCanFlag && cannotRolesSet.size !== 0) { 22 | const cannotRolesArray = Array.from(cannotRolesSet) 23 | userCanFlag = userRoles.some((role) => !cannotRolesArray.includes(role)) 24 | } 25 | 26 | return userCanFlag 27 | } 28 | 29 | export default userCan 30 | -------------------------------------------------------------------------------- /docs/examples/multiple-ui-on-different-fields.md: -------------------------------------------------------------------------------- 1 | ### Example: Multiple UI on different fields 2 | ```ts 3 | export default defineType({ 4 | type: 'document', 5 | name: 'testDocument', 6 | title: 'Test Document', 7 | fields: [ 8 | defineField({ 9 | type: 'i18n.string', 10 | name: 'field1', 11 | title: 'Field 1', 12 | }), 13 | defineField({ 14 | type: 'i18n.string', 15 | name: 'field2', 16 | title: 'Field 2', 17 | options: { 18 | ui: { 19 | position: 'top', 20 | selected: 'background' 21 | } 22 | } 23 | }), 24 | defineField({ 25 | type: 'i18n.string', 26 | name: 'field3', 27 | title: 'Field 3', 28 | options: { 29 | ui: { 30 | type: 'dropdown' 31 | } 32 | } 33 | }) 34 | ] 35 | }) 36 | ``` 37 |

38 | Example: Multiple UI on different fields 39 |

-------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 William Iommi 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/components/Slider/Slider.Wrapper.tsx: -------------------------------------------------------------------------------- 1 | import {Dispatch, SetStateAction} from 'react' 2 | import {I18nFieldsConfigUI} from '../../types/I18nFields' 3 | import {InternalLocale} from '../../types/Locale' 4 | import Slide from './Slider.Slide' 5 | import useSlider from '../../hooks/useSlider' 6 | 7 | interface SliderWrapperProps { 8 | name: string | undefined 9 | pluginUi: I18nFieldsConfigUI 10 | locales: InternalLocale[] 11 | activeLocale: InternalLocale 12 | onClick: Dispatch> 13 | } 14 | const SliderWrapper = ({name, pluginUi, locales, activeLocale, onClick}: SliderWrapperProps) => { 15 | const keenRef = useSlider() 16 | return ( 17 |
22 | {locales.map((locale, index) => ( 23 | 32 | ))} 33 |
34 | ) 35 | } 36 | 37 | export default SliderWrapper 38 | -------------------------------------------------------------------------------- /src/lib/mergeLocaleConfiguration.ts: -------------------------------------------------------------------------------- 1 | import {ConditionalProperty} from 'sanity' 2 | import {I18nStringLocale, InternalLocale, Locale} from '../types/Locale' 3 | 4 | const mergeLocaleConfiguration = ( 5 | locale: Locale, 6 | fieldLocales: I18nStringLocale[] | undefined, 7 | fieldHidden: ConditionalProperty | undefined, 8 | fieldReadOnly: ConditionalProperty | undefined 9 | ): InternalLocale => { 10 | // if no extra configuration, return locale 11 | if (!fieldLocales && !fieldHidden && !fieldReadOnly) return locale 12 | let fieldLocale 13 | // find the fieldLocale with the same code 14 | if (fieldLocales) fieldLocale = fieldLocales.find((curr) => curr.code === locale.code) 15 | return { 16 | code: locale.code, 17 | label: locale.label, 18 | title: locale.title, 19 | default: locale.default, 20 | options: fieldLocale?.options, 21 | hidden: fieldHidden || fieldLocale?.hidden || undefined, 22 | readOnly: fieldReadOnly || fieldLocale?.readOnly || undefined, 23 | visibleFor: [...(locale.visibleFor || []), ...(fieldLocale?.visibleFor || [])], 24 | editableFor: [...(locale.editableFor || []), ...(fieldLocale?.editableFor || [])], 25 | } 26 | } 27 | 28 | export default mergeLocaleConfiguration 29 | -------------------------------------------------------------------------------- /src/types/Locale.ts: -------------------------------------------------------------------------------- 1 | import {ComponentType, ReactNode} from 'react' 2 | import {ConditionalProperty, NumberOptions, StringOptions} from 'sanity' 3 | 4 | export interface Locale { 5 | code: string 6 | label: ReactNode | ComponentType 7 | title: string 8 | default?: boolean 9 | visibleFor?: string[] 10 | editableFor?: string[] 11 | } 12 | 13 | export interface InternalLocale extends Locale { 14 | hasError?: boolean 15 | hasWarning?: boolean 16 | isChanged?: boolean 17 | isReadOnly?: boolean 18 | isHidden?: boolean 19 | readOnly?: ConditionalProperty 20 | hidden?: ConditionalProperty 21 | options?: any 22 | } 23 | 24 | interface FilteredInternalLocale 25 | extends Omit< 26 | InternalLocale, 27 | | 'label' 28 | | 'title' 29 | | 'default' 30 | | 'hasError' 31 | | 'hasWarning' 32 | | 'isReadOnly' 33 | | 'isHidden' 34 | | 'isChanged' 35 | > {} 36 | 37 | export interface I18nStringLocale extends FilteredInternalLocale { 38 | options?: StringOptions 39 | } 40 | 41 | export interface I18nNumberLocale extends FilteredInternalLocale { 42 | options?: NumberOptions 43 | } 44 | export interface I18nTextLocale extends FilteredInternalLocale { 45 | options?: {rows?: number} 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/validateLocaleRestrictions.ts: -------------------------------------------------------------------------------- 1 | import {CurrentUser, FieldMember, ObjectMember, SanityDocument} from 'sanity' 2 | import userCan from './userCan' 3 | import validateConditionalProperty from './validateConditionalProperty' 4 | import {InternalLocale} from '../types/Locale' 5 | 6 | interface validateLocaleRestrictionsProps { 7 | userRoles: string[] 8 | locale: InternalLocale 9 | currentUser: CurrentUser | null 10 | fieldValue: {[key: string]: unknown} 11 | document: SanityDocument | undefined 12 | members: ObjectMember[] 13 | } 14 | 15 | const validateLocaleRestrictions = ({ 16 | locale, 17 | userRoles, 18 | document, 19 | currentUser, 20 | fieldValue, 21 | members, 22 | }: validateLocaleRestrictionsProps): InternalLocale => { 23 | const currentMember = (members as FieldMember[]).find((member) => member.name === locale.code) 24 | return { 25 | ...locale, 26 | isHidden: 27 | !userCan(userRoles, locale.visibleFor) || 28 | validateConditionalProperty(locale.hidden, document, currentUser, fieldValue), 29 | isReadOnly: 30 | currentMember?.field.readOnly || 31 | !userCan(userRoles, locale.editableFor) || 32 | validateConditionalProperty(locale.readOnly, document, currentUser, fieldValue), 33 | } 34 | } 35 | 36 | export default validateLocaleRestrictions 37 | -------------------------------------------------------------------------------- /src/types/Sanity.ts: -------------------------------------------------------------------------------- 1 | import {ObjectDefinition, ObjectOptions} from 'sanity' 2 | import {I18nNumberOptions, I18nStringOptions, I18nTextOptions} from './I18nFields' 3 | 4 | export type I18nStringDefinition = Omit< 5 | ObjectDefinition, 6 | 'type' | 'fields' | 'components' | 'options' 7 | > & { 8 | type: 'i18n.string' 9 | options?: I18nStringOptions & Omit 10 | } 11 | 12 | export type I18nNumberDefinition = Omit< 13 | ObjectDefinition, 14 | 'type' | 'fields' | 'components' | 'options' 15 | > & { 16 | type: 'i18n.number' 17 | options?: I18nNumberOptions & Omit 18 | } 19 | 20 | export type I18nTextDefinition = Omit< 21 | ObjectDefinition, 22 | 'type' | 'fields' | 'components' | 'options' 23 | > & { 24 | type: 'i18n.text' 25 | options?: I18nTextOptions & Omit 26 | } 27 | 28 | // redeclares sanity module so we can add interfaces props to it 29 | declare module 'sanity' { 30 | // redeclares IntrinsicDefinitions and adds a named definition to it 31 | // it is important that the key is the same as the type in the definition ('magically-added-type') 32 | export interface IntrinsicDefinitions { 33 | 'i18n.string': I18nStringDefinition 34 | } 35 | export interface IntrinsicDefinitions { 36 | 'i18n.number': I18nNumberDefinition 37 | } 38 | export interface IntrinsicDefinitions { 39 | 'i18n.text': I18nTextDefinition 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/hooks/useMembersInfo.ts: -------------------------------------------------------------------------------- 1 | import {FieldMember, ObjectMember, TextSchemaType} from 'sanity' 2 | import {InternalLocale} from '../types/Locale' 3 | import {EMPTY_FORM_NODE_VALIDATION} from '../lib/constants' 4 | import {I18nFieldType} from '../types/I18nFields' 5 | 6 | interface useMembersInfoProps { 7 | members: ObjectMember[] 8 | availableLocales: InternalLocale[] 9 | hasGlobalError: boolean 10 | fieldOptions: any 11 | fieldType?: I18nFieldType 12 | } 13 | 14 | const useMembersInfo = ({ 15 | members, 16 | availableLocales, 17 | hasGlobalError, 18 | fieldOptions, 19 | fieldType = 'string', 20 | }: useMembersInfoProps): FieldMember[] => { 21 | return (members as FieldMember[]).map((member) => { 22 | const currentLocale = availableLocales.find((locale) => locale.code === member.name) 23 | if (currentLocale) { 24 | // setting field level readOnly from configuration 25 | member.field.readOnly = member.field.readOnly || currentLocale.isReadOnly 26 | // setting field options 27 | member.field.schemaType.options = currentLocale.options 28 | // if error is global, force to have the input in error 29 | if (hasGlobalError) member.field.validation = [EMPTY_FORM_NODE_VALIDATION] 30 | if (fieldType === 'text') { 31 | const schemaType = member.field.schemaType as TextSchemaType 32 | schemaType.rows = currentLocale.options?.rows || fieldOptions?.rows 33 | } 34 | } 35 | return member 36 | }) 37 | } 38 | 39 | export default useMembersInfo 40 | -------------------------------------------------------------------------------- /docs/examples/customized-prefix.md: -------------------------------------------------------------------------------- 1 | ### Example: Customized prefix 2 | ```ts 3 | I18nFields({ 4 | prefix: 'myAwesomePrefix', 5 | locales: [ 6 | {code: 'en', label: '🇬🇧', title: 'English', default: true}, 7 | {code: 'en_us', label: '🇺🇸🇬🇧', title: 'American English'}, 8 | {code: 'it', label: '🇮🇹', title: 'Italian'}, 9 | {code: 'es', label: '🇪🇸', title: 'Spanish'}, 10 | ] 11 | }) 12 | ``` 13 | As a result of this customization, instead of having the default types, you will have the following ones: 14 | 15 | - `myAwesomePrefix.string` 16 | - `myAwesomePrefix.text` 17 | - `myAwesomePrefix.number` 18 | 19 | You will lose TypeScript definitions since you are not using `i18n.string`, `i18n.text`, or `i18n.number` types. However, you can override them in your project by doing something like this: 20 | 21 | ```ts 22 | //custom.types.d.ts 23 | 24 | import { 25 | I18nNumberDefinition, 26 | I18nStringDefinition, 27 | I18nTextDefinition, 28 | } from 'sanity-plugin-i18n-fields' 29 | 30 | declare module 'sanity' { 31 | export interface IntrinsicDefinitions { 32 | 'myAwesomePrefix.string': Omit & {type: 'myAwesomePrefix.string'} 33 | } 34 | export interface IntrinsicDefinitions { 35 | 'myAwesomePrefix.text': Omit & {type: 'myAwesomePrefix.text'} 36 | } 37 | export interface IntrinsicDefinitions { 38 | 'myAwesomePrefix.number': Omit & {type: 'myAwesomePrefix.number'} 39 | } 40 | } 41 | ``` -------------------------------------------------------------------------------- /src/components/Dropdown/Dropdown.Button.tsx: -------------------------------------------------------------------------------- 1 | import {Box, Button, Text, Tooltip, useTheme} from '@sanity/ui' 2 | import {SelectIcon} from '@sanity/icons' 3 | import {InternalLocale} from '../../types/Locale' 4 | import ErrorWarningIcons from '../shared/ErrorWarningIcons' 5 | import {EditIcon} from '@sanity/icons' 6 | import IconLabel from '../shared/IconLabel' 7 | 8 | interface DropdownButtonProps { 9 | activeLocale: InternalLocale 10 | } 11 | 12 | const DropdownButton = ({activeLocale}: DropdownButtonProps) => { 13 | const sanityTheme = useTheme() 14 | return ( 15 | 39 | ) 40 | } 41 | 42 | export default DropdownButton 43 | -------------------------------------------------------------------------------- /src/components/Dropdown/Dropdown.Menu.Item.tsx: -------------------------------------------------------------------------------- 1 | import {Box, MenuItem, Text, Tooltip, useTheme} from '@sanity/ui' 2 | import {InternalLocale} from '../../types/Locale' 3 | import ErrorWarningIcons from '../shared/ErrorWarningIcons' 4 | import {EditIcon} from '@sanity/icons' 5 | import {Dispatch, SetStateAction, useCallback} from 'react' 6 | import IconLabel from '../shared/IconLabel' 7 | 8 | interface DropdownMenuItemProps { 9 | name: string 10 | locale: InternalLocale 11 | onClick: Dispatch> 12 | } 13 | 14 | const DropdownMenuItem = ({name = 'title', locale, onClick}: DropdownMenuItemProps) => { 15 | const sanityTheme = useTheme() 16 | const onClickHandler = useCallback(() => { 17 | onClick(locale.code) 18 | }, [locale, onClick]) 19 | return ( 20 | 21 | 24 | 25 | {locale.title || locale.code} 26 | 27 | 28 | } 29 | fallbackPlacements={['right', 'left']} 30 | placement="top" 31 | > 32 | 47 | 48 | 49 | ) 50 | } 51 | 52 | export default DropdownMenuItem 53 | -------------------------------------------------------------------------------- /src/components/Slider/Slider.Slide.tsx: -------------------------------------------------------------------------------- 1 | import {Box, Text, Tooltip, useTheme} from '@sanity/ui' 2 | import {InternalLocale} from '../../types/Locale' 3 | import {I18nFieldsConfigUI} from '../../types/I18nFields' 4 | import ErrorWarningIcons from '../shared/ErrorWarningIcons' 5 | import {EditIcon} from '@sanity/icons' 6 | import {Dispatch, SetStateAction, useCallback} from 'react' 7 | import IconLabel from '../shared/IconLabel' 8 | 9 | interface SlideProps { 10 | name: string | undefined 11 | locale: InternalLocale 12 | index: number 13 | isSelected: boolean 14 | pluginUi: I18nFieldsConfigUI 15 | onClick: Dispatch> 16 | } 17 | const Slide = ({name, locale, index, isSelected, pluginUi, onClick}: SlideProps) => { 18 | const sanityTheme = useTheme() 19 | const onClickHandler = useCallback(() => { 20 | onClick(locale.code) 21 | }, [locale, onClick]) 22 | return ( 23 | 27 | 28 | {locale.title || locale.code} 29 | 30 | 31 | } 32 | fallbackPlacements={['right', 'left']} 33 | placement="top" 34 | portal 35 | > 36 | 56 | 57 | ) 58 | } 59 | 60 | export default Slide 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-plugin-i18n-fields", 3 | "version": "1.0.0", 4 | "description": "A Sanity plugin to manage i18n at field level", 5 | "keywords": [ 6 | "sanity", 7 | "sanity-plugin" 8 | ], 9 | "homepage": "https://github.com/williamiommi/sanity-plugin-i18n-fields#readme", 10 | "bugs": { 11 | "url": "https://github.com/williamiommi/sanity-plugin-i18n-fields/issues" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+ssh://git@github.com/williamiommi/sanity-plugin-i18n-fields.git" 16 | }, 17 | "license": "MIT", 18 | "author": "William Iommi ", 19 | "exports": { 20 | ".": { 21 | "types": "./dist/index.d.ts", 22 | "source": "./src/index.ts", 23 | "require": "./dist/index.js", 24 | "import": "./dist/index.esm.js", 25 | "default": "./dist/index.esm.js" 26 | }, 27 | "./package.json": "./package.json" 28 | }, 29 | "main": "./dist/index.js", 30 | "module": "./dist/index.esm.js", 31 | "source": "./src/index.ts", 32 | "types": "./dist/index.d.ts", 33 | "files": [ 34 | "dist", 35 | "sanity.json", 36 | "src", 37 | "v2-incompatible.js" 38 | ], 39 | "scripts": { 40 | "build": "run-s clean && plugin-kit verify-package --silent && pkg-utils build --strict && pkg-utils --strict", 41 | "clean": "rimraf dist", 42 | "format": "prettier --write --cache --ignore-unknown .", 43 | "link-watch": "plugin-kit link-watch", 44 | "lint": "eslint .", 45 | "prepublishOnly": "run-s build", 46 | "watch": "pkg-utils watch --strict", 47 | "prepare": "husky install" 48 | }, 49 | "dependencies": { 50 | "@sanity/incompatible-plugin": "^1.0.4", 51 | "@sanity/ui": "^1.7.0", 52 | "keen-slider": "^6.8.5" 53 | }, 54 | "devDependencies": { 55 | "@commitlint/cli": "^17.6.5", 56 | "@commitlint/config-conventional": "^17.6.5", 57 | "@sanity/pkg-utils": "^2.3.3", 58 | "@sanity/plugin-kit": "^3.1.7", 59 | "@sanity/semantic-release-preset": "^4.1.1", 60 | "@types/react": "^18.2.13", 61 | "@types/react-dom": "^18.2.7", 62 | "@typescript-eslint/eslint-plugin": "^5.60.0", 63 | "@typescript-eslint/parser": "^5.60.0", 64 | "eslint": "^8.43.0", 65 | "eslint-config-prettier": "^8.8.0", 66 | "eslint-config-sanity": "^6.0.0", 67 | "eslint-plugin-prettier": "^4.2.1", 68 | "eslint-plugin-react": "^7.32.2", 69 | "eslint-plugin-react-hooks": "^4.6.0", 70 | "husky": "^8.0.3", 71 | "lint-staged": "^13.2.2", 72 | "node-sass": "^9.0.0", 73 | "npm-run-all": "^4.1.5", 74 | "postcss": "^8.4.25", 75 | "prettier": "^2.8.8", 76 | "prettier-plugin-packagejson": "^2.4.3", 77 | "react": "^18.2.0", 78 | "react-dom": "^18.2.0", 79 | "react-is": "^18.2.0", 80 | "rimraf": "^5.0.1", 81 | "rollup-plugin-postcss": "^4.0.2", 82 | "sanity": "^3.14.1", 83 | "styled-components": "^5.2", 84 | "typescript": "^5.0.4" 85 | }, 86 | "peerDependencies": { 87 | "react": "^18", 88 | "sanity": "^3" 89 | }, 90 | "engines": { 91 | "node": ">=14" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/components/I18nDefaultField.tsx: -------------------------------------------------------------------------------- 1 | import {MemberField, ObjectFieldProps} from 'sanity' 2 | import {I18nFieldType, I18nFieldsConfig} from '../types/I18nFields' 3 | import useSetupCssVars from '../hooks/useSetupCssVars' 4 | import useValidationInfo from '../hooks/useValidationInfo' 5 | import useLocalesInfo from '../hooks/useLocalesInfo' 6 | import useUiInfo from '../hooks/useUiInfo' 7 | import SliderWrapper from './Slider/Slider.Wrapper' 8 | import {Stack} from '@sanity/ui' 9 | import DropdownWrapper from './Dropdown/Dropdown.Wrapper' 10 | import useMembersInfo from '../hooks/useMembersInfo' 11 | 12 | const I18nDefaultField = ( 13 | props: ObjectFieldProps, 14 | pluginConfig: I18nFieldsConfig, 15 | fieldType: I18nFieldType 16 | ) => { 17 | const { 18 | name: currentPath, 19 | collapsed, 20 | validation, 21 | inputProps, 22 | inputProps: {members}, 23 | schemaType: {options: fieldOptions, hidden: fieldHidden, readOnly: fieldReadOnly}, 24 | renderDefault, 25 | } = props 26 | // setting up css variables 27 | useSetupCssVars() 28 | // get all validation errors, object errors and single field error 29 | const {hasGlobalError, mergedValidation} = useValidationInfo(validation, members) 30 | const {availableLocales, activeLocale, setCurrentLocaleCode} = useLocalesInfo({ 31 | locales: pluginConfig.locales, 32 | members, 33 | hasGlobalError, 34 | fieldLocales: fieldOptions.locales, 35 | fieldHidden, 36 | fieldReadOnly, 37 | currentPath, 38 | }) 39 | // merge global and field ui options 40 | const pluginUI = useUiInfo(pluginConfig.ui, fieldOptions.ui) 41 | // get object members with all necessary info 42 | const parsedMembers = useMembersInfo({ 43 | members, 44 | availableLocales, 45 | hasGlobalError, 46 | fieldType, 47 | fieldOptions, 48 | }) 49 | 50 | const DefaultRender = () => ( 51 | <>{renderDefault({...props, validation: mergedValidation, children: null})} 52 | ) 53 | 54 | if (!availableLocales || !activeLocale) return null 55 | 56 | if (collapsed) return 57 | 58 | return ( 59 | 60 |
61 | 62 | {pluginUI?.type === 'dropdown' && ( 63 | 69 | )} 70 |
71 | {pluginUI?.type === 'slider' && ( 72 | 79 | )} 80 |
81 | {parsedMembers.map((member) => { 82 | return ( 83 |
89 | 96 |
97 | ) 98 | })} 99 |
100 |
101 | ) 102 | } 103 | 104 | export default I18nDefaultField 105 | -------------------------------------------------------------------------------- /src/style/style.scss: -------------------------------------------------------------------------------- 1 | [data-read-only='true'] { 2 | .-label { 3 | filter: grayscale(1); 4 | color: gray; 5 | } 6 | } 7 | 8 | .i18n--field-wrapper-top { 9 | position: relative; 10 | padding-right: 0; 11 | &[data-ui='dropdown'] { 12 | padding-bottom: 10px; 13 | padding-right: 100px; 14 | } 15 | } 16 | 17 | .i18n--field-member { 18 | &-container { 19 | position: relative; 20 | } 21 | 22 | &.--hidden { 23 | position: absolute; 24 | width: 100%; 25 | top: 0; 26 | opacity: 0; 27 | user-select: none; 28 | z-index: -1; 29 | textarea { 30 | height: 1px; 31 | } 32 | } 33 | } 34 | 35 | .i18n--slider-language { 36 | position: relative; 37 | display: flex; 38 | margin: 5px 0 10px 0; 39 | transition: opacity 0.3s ease-in-out; 40 | order: 0; 41 | 42 | &::before, 43 | &::after { 44 | position: absolute; 45 | width: 30px; 46 | height: 100%; 47 | z-index: 2; 48 | } 49 | 50 | &[data-begin='false'] { 51 | &::before { 52 | content: ''; 53 | left: 0; 54 | background-image: linear-gradient(to right, var(--i18n-base-bg), transparent); 55 | } 56 | } 57 | 58 | &[data-end='false'] { 59 | &::after { 60 | content: ''; 61 | right: 0; 62 | background-image: linear-gradient(to left, var(--i18n-base-bg), transparent); 63 | } 64 | } 65 | 66 | &[data-position='bottom'] { 67 | order: 1; 68 | margin: 7px 0 0; 69 | } 70 | 71 | &-slide { 72 | display: flex; 73 | align-items: center; 74 | justify-content: center; 75 | flex: 0 0 auto; 76 | width: auto !important; 77 | padding: 0 5px; 78 | cursor: pointer; 79 | box-sizing: border-box; 80 | font-size: 0.875rem; 81 | 82 | &:not([data-ui='border']) { 83 | background-color: transparent; 84 | &[data-selected='true'] { 85 | background-color: var(--i18n-bg-selected); 86 | } 87 | &:hover { 88 | background-color: var(--i18n-bg-hover); 89 | &[data-selected='true'] { 90 | background-color: var(--i18n-bg-selected); 91 | } 92 | } 93 | } 94 | 95 | &[data-ui='border'] { 96 | border-bottom: 2px solid transparent; 97 | border-bottom-color: transparent; 98 | &[data-selected='true'] { 99 | border-bottom-color: var(--i18n-bg-selected); 100 | } 101 | &:hover { 102 | border-bottom-color: var(--i18n-bg-hover); 103 | &[data-selected='true'] { 104 | border-bottom-color: var(--i18n-bg-selected); 105 | } 106 | } 107 | } 108 | } 109 | } 110 | 111 | .i18n--dropdown-button { 112 | background: none !important; 113 | text-align: center; 114 | display: flex; 115 | align-items: center; 116 | justify-content: center; 117 | position: absolute !important; 118 | top: 0 !important; 119 | right: 0 !important; 120 | max-width: 100px; 121 | 122 | span { 123 | padding: 0 !important; 124 | } 125 | 126 | .-content { 127 | display: flex; 128 | align-items: center; 129 | justify-content: center; 130 | color: initial !important; 131 | overflow: hidden; 132 | padding: 0 2px; 133 | 134 | .-label { 135 | font-size: 0.875rem; 136 | margin-right: 20px; 137 | white-space: nowrap; 138 | overflow: hidden; 139 | text-overflow: ellipsis; 140 | padding: 3px 0 3px 5px !important; 141 | } 142 | 143 | .-icon { 144 | position: absolute; 145 | right: 0px; 146 | color: initial !important; 147 | } 148 | } 149 | } 150 | 151 | .i18n--dropdown-menu { 152 | max-width: 300px; 153 | max-height: 200px; 154 | text-align: center; 155 | 156 | button { 157 | background: none !important; 158 | } 159 | } 160 | 161 | .i18n--dropdown-menu-item { 162 | display: flex; 163 | align-items: center; 164 | justify-content: flex-end; 165 | cursor: pointer; 166 | font-size: 0.875rem; 167 | padding: 4px 7px; 168 | color: initial; 169 | &:hover { 170 | background-color: var(--i18n-bg-hover); 171 | color: initial; 172 | border-radius: 3px; 173 | } 174 | } 175 | 176 | .i18n--select-language { 177 | max-width: 100px; 178 | position: absolute; 179 | top: 50%; 180 | right: 0; 181 | transform: translateY(-50%); 182 | } 183 | -------------------------------------------------------------------------------- /src/hooks/useLocalesInfo.ts: -------------------------------------------------------------------------------- 1 | import {ConditionalProperty, FieldMember, ObjectMember, SanityDocument, useFormValue} from 'sanity' 2 | import {I18nStringLocale, InternalLocale, Locale} from '../types/Locale' 3 | import {Dispatch, SetStateAction, useMemo, useState} from 'react' 4 | import useUserInfo from './useUserInfo' 5 | import mergeLocaleConfiguration from '../lib/mergeLocaleConfiguration' 6 | import validateLocaleRestrictions from '../lib/validateLocaleRestrictions' 7 | import checkFieldError from '../lib/checkFieldError' 8 | import checkFieldChanged from '../lib/checkFieldChanged' 9 | 10 | interface useLocalesInfoProps { 11 | locales: Locale[] // locales coming from plugin configuration 12 | members: ObjectMember[] // members of the i18n object 13 | hasGlobalError: boolean // globar error at object level 14 | fieldLocales: I18nStringLocale[] | undefined // specific nested field locales override 15 | fieldHidden: ConditionalProperty | undefined // specific nested field hidden ConditionalProperty 16 | fieldReadOnly: ConditionalProperty | undefined // specific nested field readOnly ConditionalProperty 17 | currentPath: string //the 'name/path' of the object 18 | } 19 | 20 | interface useLocalesInfoResponse { 21 | availableLocales: InternalLocale[] 22 | activeLocale: InternalLocale | undefined 23 | setCurrentLocaleCode: Dispatch> 24 | } 25 | 26 | const useLocalesInfo = ({ 27 | locales, 28 | members, 29 | hasGlobalError, 30 | fieldLocales, 31 | fieldHidden, 32 | fieldReadOnly, 33 | currentPath, 34 | }: useLocalesInfoProps): useLocalesInfoResponse => { 35 | const document = useFormValue([]) as SanityDocument // get the current SanityDocument 36 | const fieldValue = useFormValue([currentPath]) as {[key: string]: unknown} // current field value (an object of locales) 37 | const {currentUser, userRoles} = useUserInfo() // get info about the current user 38 | 39 | // filter locales by configuration and sort, default first 40 | const availableLocales = useMemo( 41 | () => 42 | locales 43 | // merge global config w/ field level config 44 | .map((locale) => mergeLocaleConfiguration(locale, fieldLocales, fieldHidden, fieldReadOnly)) 45 | // validate restrictions by visibleFor, editableFor, readOnly and hidden attributes 46 | .map((locale) => 47 | validateLocaleRestrictions({ 48 | userRoles, 49 | locale, 50 | currentUser, 51 | fieldValue, 52 | document, 53 | members, 54 | }) 55 | ) 56 | // remove all possible hidden locales after restrictions 57 | .filter((locale) => !locale.isHidden) 58 | // check for field errors level, if global error present no need to show specific error 59 | .map((locale) => { 60 | if (hasGlobalError) return locale 61 | return checkFieldError(locale, members) 62 | }) 63 | // check for field changed 64 | .map((locale) => checkFieldChanged(locale, members)) 65 | // put at first position the default locale or the [0] if no default is present 66 | .sort((a, b) => Number(!!b.default) - Number(!!a.default)), 67 | [ 68 | locales, 69 | fieldLocales, 70 | fieldHidden, 71 | fieldReadOnly, 72 | userRoles, 73 | currentUser, 74 | fieldValue, 75 | document, 76 | hasGlobalError, 77 | members, 78 | ] 79 | ) 80 | 81 | const [currentLocaleCode, setCurrentLocaleCode] = useState(availableLocales[0].code) 82 | 83 | const focusedMember = useMemo( 84 | () => (members as FieldMember[]).find((member) => member.field.focused), 85 | [members] 86 | ) 87 | 88 | const activeLocale = useMemo(() => { 89 | let locale = availableLocales.find((curr) => curr.code === focusedMember?.name) 90 | if (!locale) { 91 | locale = availableLocales.find((curr) => curr.code === currentLocaleCode) 92 | } 93 | if (!locale) locale = availableLocales[0] 94 | if (locale.code !== currentLocaleCode) { 95 | setCurrentLocaleCode(locale.code) 96 | } 97 | return locale 98 | }, [availableLocales, focusedMember, currentLocaleCode]) 99 | 100 | return {availableLocales, activeLocale, setCurrentLocaleCode} 101 | } 102 | 103 | export default useLocalesInfo 104 | -------------------------------------------------------------------------------- /.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 && format('Build {0} ➤ Test ➤ Publish to NPM', github.ref_name) || 8 | inputs.release && !inputs.test && format('Build {0} ➤ Skip Tests ➤ Publish to NPM', github.ref_name) || 9 | github.event_name == 'workflow_dispatch' && inputs.test && format('Build {0} ➤ Test', github.ref_name) || 10 | github.event_name == 'workflow_dispatch' && !inputs.test && format('Build {0} ➤ Skip Tests', github.ref_name) || 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 | jobs: 41 | build: 42 | runs-on: ubuntu-latest 43 | name: Lint & Build 44 | steps: 45 | - uses: actions/checkout@v3 46 | - uses: actions/setup-node@v3 47 | with: 48 | cache: npm 49 | node-version: lts/* 50 | - run: npm ci 51 | # Linting can be skipped 52 | - run: npm run lint --if-present 53 | if: github.event.inputs.test != 'false' 54 | # But not the build script, as semantic-release will crash if this command fails so it makes sense to test it early 55 | - run: npm run prepublishOnly --if-present 56 | 57 | test: 58 | needs: build 59 | # The test matrix can be skipped, in case a new release needs to be fast-tracked and tests are already passing on main 60 | if: github.event.inputs.test != 'false' 61 | runs-on: ${{ matrix.os }} 62 | name: Node.js ${{ matrix.node }} / ${{ matrix.os }} 63 | strategy: 64 | # 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 65 | fail-fast: false 66 | matrix: 67 | # Run the testing suite on each major OS with the latest LTS release of Node.js 68 | os: [macos-latest, ubuntu-latest, windows-latest] 69 | node: [lts/*] 70 | # It makes sense to also test the oldest, and latest, versions of Node.js, on ubuntu-only since it's the fastest CI runner 71 | include: 72 | - os: ubuntu-latest 73 | # Test the oldest LTS release of Node that's still receiving bugfixes and security patches, versions older than that have reached End-of-Life 74 | node: lts/-2 75 | - os: ubuntu-latest 76 | # Test the actively developed version that will become the latest LTS release next October 77 | node: current 78 | steps: 79 | # It's only necessary to do this for windows, as mac and ubuntu are sane OS's that already use LF 80 | - name: Set git to use LF 81 | if: matrix.os == 'windows-latest' 82 | run: | 83 | git config --global core.autocrlf false 84 | git config --global core.eol lf 85 | - uses: actions/checkout@v3 86 | - uses: actions/setup-node@v3 87 | with: 88 | cache: npm 89 | node-version: ${{ matrix.node }} 90 | - run: npm i 91 | - run: npm test --if-present 92 | 93 | release: 94 | needs: [build, test] 95 | # only run if opt-in during workflow_dispatch 96 | if: always() && github.event.inputs.release == 'true' && needs.build.result != 'failure' && needs.test.result != 'failure' && needs.test.result != 'cancelled' 97 | runs-on: ubuntu-latest 98 | name: Semantic release 99 | steps: 100 | - uses: actions/checkout@v3 101 | with: 102 | # Need to fetch entire commit history to 103 | # analyze every commit since last release 104 | fetch-depth: 0 105 | - uses: actions/setup-node@v3 106 | with: 107 | cache: npm 108 | node-version: lts/* 109 | - run: npm ci 110 | # Branches that will release new versions are defined in .releaserc.json 111 | # @TODO remove --dry-run after verifying everything is good to go 112 | - run: npx semantic-release 113 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 114 | # e.g. git tags were pushed but it exited before `npm publish` 115 | if: always() 116 | env: 117 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 118 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 119 | # Re-run semantic release with rich logs if it failed to publish for easier debugging 120 | - run: npx semantic-release --debug 121 | if: failure() 122 | env: 123 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 124 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # I18n Fields Logo I18n fields 2 | 3 | An alternative way to manage localization at field level in your Sanity Studio. 4 | 5 |

6 | I18n String Field Sample 7 |

8 |

9 | I18n String Field Sample 10 |

11 |
12 | 13 | - [⚡️ Features](#%EF%B8%8F-features) 14 | - [🔌 Installation](#-installation) 15 | - [🧑‍💻 Usage](#-usage) 16 | - [⚙️ Plugin Configuration](#%EF%B8%8F-plugin-configuration) 17 | - [🔧 Field Configuration](#-field-configuration) 18 | - [🗃️ Data Model](#%EF%B8%8F-data-model) 19 | - [🚨 Validation](#-validation) 20 | - [🤩 Examples Examples Examples](#-examples-examples-examples) 21 | - [👀 Future features](#-future-features) 22 | - [📝 License](#-license) 23 | - [🧪 Develop & test](#-develop--test) 24 |
25 | 26 | ## ⚡️ Features 27 | 28 | - Sanity v3 plugin. 29 | - Field-level localization for the following Sanity types: `string`, `text`, and `number`. 30 | - Optional UI (slider or dropdown). 31 | - Locale visibility by user roles. 32 | - Locale read-only by user roles. 33 | - Object Validation. 34 | - Customization available also at field level. 35 | - Customizable types prefix. 36 |
37 | 38 | ## 🔌 Installation 39 | 40 | ```sh 41 | npm install sanity-plugin-i18n-fields 42 | ``` 43 |
44 | 45 | ## 🧑‍💻 Usage 46 | 47 | Add it as a plugin in `sanity.config.ts` (or .js): 48 | 49 | ```ts 50 | import {defineConfig} from 'sanity' 51 | import {I18nFields} from 'sanity-plugin-i18n-fields' 52 | 53 | export default defineConfig({ 54 | //... 55 | plugins: [I18nFields({ 56 | // your configuration here 57 | })], 58 | }) 59 | ``` 60 | The plugin will provide three new types: `i18n.string`, `i18n.text`, and `i18n.number`. All three types will be objects with a dynamic number of fields based on the localizations provided during configuration. 61 |

62 | 63 | ## ⚙️ Plugin Configuration 64 | This is the main configuration of the plugin, and the available options are as follows: 65 | ```ts 66 | { 67 | prefix?: string // You can configure the prefix of the types created by the plugin. If you are already using them or prefer a different name for any reason, you can change it. The default is 'i18n'. 68 | // The 'ui' option allows you to customize the appearance of the plugin's UI. By default, it is set to 'slider'. 69 | ui?: { 70 | type?: 'slider' | 'dropdown' // The UI of the plugin, Default is 'slider' 71 | position?: 'top' | 'bottom' // You can specify the position of the 'slider' above or below the input field, with the default being 'bottom'. 72 | selected?: 'border' | 'background' // For the 'slider' type, you can configure the UI of the selected locale, and the default setting is 'border'. 73 | }, 74 | // The 'locales' option is the core of the configuration, enabling you to set up all available locales for your project. 75 | locales: [ 76 | { 77 | code: string // the code of the locale 78 | label: ReactNode | ComponentType // the label of the locale 79 | title: string // the title of the locale 80 | default?: boolean // This is the flag to identify the default locale. If set to true, the locale is placed in the first position. 81 | visibleFor?: string[] // You can define a list of roles for which this locale is visible. By using the '!' operator, you can make it not visible. 82 | editableFor?: string[] // You can define a list of roles for which this locale is editable. The '!' operator allows you to specify the opposite condition. 83 | }, 84 | // other locales 85 | ] 86 | } 87 | ``` 88 | Sample configuration: 89 | ```ts 90 | import {defineConfig} from 'sanity' 91 | import {I18nFields} from 'sanity-plugin-i18n-fields' 92 | 93 | export default defineConfig({ 94 | //... 95 | plugins: [I18nFields({ 96 | ui: { 97 | position: 'bottom' 98 | }, 99 | locales: [ 100 | {code: 'en', label: '🇬🇧', title: 'English', default: true}, 101 | {code: 'en_us', label: '🇺🇸🇬🇧', title: 'American English'}, 102 | {code: 'it', label: '🇮🇹', title: 'Italian', visibleFor: ['it_editor']}, // country visible only for administrator and it_editor roles 103 | {code: 'es', label: '🇪🇸', title: 'Spanish'}, 104 | ] 105 | })], 106 | }) 107 | ``` 108 |
109 | 110 | ## 🔧 Field Configuration 111 | Other than global configuration, you can customize your configuration at field level. For example, for a specific field, you can have a dropdown layout or hide a particular locale. 112 | ```ts 113 | import {ConditionalProperty, NumberOptions, StringOptions} from 'sanity' 114 | 115 | export default defineType({ 116 | type: 'document', 117 | name: 'myDocument', 118 | title: 'My Document', 119 | fields: [ 120 | defineField({ 121 | type: 'i18n.string' | 'i18n.text' | 'i18n.number', 122 | // ... 123 | options: { 124 | ui?: { 125 | type?: 'slider' | 'dropdown' 126 | position?: 'top' | 'bottom' 127 | selected?: 'border' | 'background' 128 | }, 129 | locales?: [ 130 | { 131 | code: string // the code of the locale. MUST be the same as the one used in the global configuration. 132 | readOnly?: ConditionalProperty 133 | hidden?: ConditionalProperty 134 | options?: StringOptions | { rows?:number } | NumberOptions 135 | visibleFor?: string[] // same as global configuration 136 | editableFor?: string[] // same as global configuration 137 | }, 138 | // other locales 139 | ] 140 | } 141 | }) 142 | ] 143 | }) 144 | ``` 145 |
146 | 147 | ## 🗃️ Data model 148 | ```ts 149 | // sample with 'en', 'en_us', 'it' and 'es' locales 150 | 151 | { 152 | _type: 'i18n.string', 153 | en: string, 154 | en_us: string, 155 | it: string, 156 | es: string, 157 | } 158 | 159 | { 160 | _type: 'i18n.text', 161 | en: string, 162 | en_us: string, 163 | it: string, 164 | es: string, 165 | } 166 | 167 | { 168 | _type: 'i18n.number', 169 | en: number, 170 | en_us: number, 171 | it: number, 172 | es: number, 173 | } 174 | ``` 175 |
176 | 177 | ## 🚨 Validation 178 | Since the new types introduced by the plugin are objects, you can use [children validation](https://www.sanity.io/docs/validation#9e69d5db6f72) to apply specific validation to a particular locale. 179 | All error/warning messages are then collected and visible near the title of your field or in the right menu. 180 | 181 |

182 | Validation Sample 183 |

184 |

185 | Validation Sample 186 |

187 |
188 | 189 | ## 🤩 Examples Examples Examples 190 | 191 | - [Basic Configuration](docs/examples/basic-configuration.md) 192 | - [Global user roles visibility](docs/examples/global-user-roles-visibility.md) 193 | - [String field](docs/examples/string-field.md) 194 | - [Text field](docs/examples/text-field.md) 195 | - [Number field](docs/examples/number-field.md) 196 | - [Slider top position](docs/examples/slider-top-position.md) 197 | - [Slider with background option](docs/examples/slider-with-background-ui-option.md) 198 | - [Dropdown UI](docs/examples/dropdown-ui.md) 199 | - [Multiple UI on different fields](docs/examples/multiple-ui-on-different-fields.md) 200 | - [Hide specific locale for a single field](docs/examples/hide-specific-locale-for-a-single-field.md) 201 | - [Locale not editable for a specific field](docs/examples/locale-not-editable-for-a-specific-field.md) 202 | - [Conditionally set a locale visible or not editable](docs/examples/conditionally-set-a-locale-visible-or-not-editable.md) 203 | - [List of values](docs/examples/list-of-values.md) 204 | - [Custom rows for i18n.text](docs/examples//custom-rows-for-i18ntext.md) 205 | - [Global validation](docs/examples/global-validation.md) 206 | - [Children validation](docs/examples/children-validation.md) 207 | - [Alternative locale label](docs/examples/alternative-locale-label.md) 208 | - [Alternative locale label 2](docs/examples//alternative-locale-label-2.md) 209 | - [Customized prefix](docs/examples//customized-prefix.md) 210 |

211 | 212 | ## 👀 Future features 213 | - New Sanity default types (boolean, date...) 214 | - Filters 215 | - Show all locales without slider/dropdown 216 | - Show only fulfilled translations 217 | - Show only empty translations 218 | - AI integration? 🤔 219 | - ... 220 |

221 | 222 | > While writing this documentation, I realized that with the 'prefix' option, you can define the plugin multiple times with different prefixes. 223 | > 224 | > Codes and Labels are customizable, and this plugin could be used for other use cases, not only for internationalization. 225 | > 226 | > So, perhaps the name 'I18N Fields' is already outdated? 😅 Should I find a different name? Any suggestions? 😂 227 | 228 |

229 | 230 | ## 📝 License 231 | 232 | [MIT](LICENSE) © William Iommi 233 |

234 | 235 | ## 🧪 Develop & test 236 | 237 | This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit) 238 | with default configuration for build & watch scripts. 239 | 240 | See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio) 241 | on how to run this plugin with hotreload in the studio. 242 | 243 | 244 | ### Release new version 245 | 246 | Run ["CI & Release" workflow](https://github.com/williamiommi/sanity-plugin-i18n-fields/actions/workflows/main.yml). 247 | Make sure to select the main branch and check "Release new version". 248 | 249 | Semantic release will only release on configured branches, so it is safe to run release on any branch. 250 | --------------------------------------------------------------------------------