Skip to content

Instantly share code, notes, and snippets.

@elliott-w
Last active October 28, 2025 21:19
Show Gist options
  • Select an option

  • Save elliott-w/9e9ed3df05e12f8626e33c88477252b5 to your computer and use it in GitHub Desktop.

Select an option

Save elliott-w/9e9ed3df05e12f8626e33c88477252b5 to your computer and use it in GitHub Desktop.
Payload CMS Require Translations Plugin - Prevents publishing until all translations have been added through draft autosaves
import {
traverseFields,
type CollectionConfig,
type CollectionSlug,
type GlobalConfig,
type GlobalSlug,
type Plugin,
type TraverseFieldsCallback,
type Validate,
} from 'payload'
import { hasText } from '@payloadcms/richtext-lexical/shared'
const getValueByPath = (obj: any, path: (string | number)[]) => {
return path.reduce((current, key) => current?.[key], obj)
}
export const validateAllLocales =
(originalValidate?: Validate): Validate =>
async (_, options) => {
if (originalValidate) {
const result = await originalValidate(_, options)
if (typeof result === 'string') {
return result
}
}
const { req, data, path, collectionSlug, required } = options
const type = 'type' in options ? (options.type as string) : undefined
const localization = req.payload.config.localization
if (data && localization && collectionSlug && required) {
if (data._status === 'published') {
if (!req.context.draftDocPromise) {
req.context.draftDocPromise = req.payload.findByID({
id: data.id,
collection: collectionSlug as CollectionSlug,
draft: true,
locale: 'all',
})
}
const doc = (await req.context.draftDocPromise) as any
const draftValue = getValueByPath(doc, path)
if (typeof draftValue === 'object' && draftValue !== null) {
const missingLocales = localization.locales.filter(locale => {
if (type === 'richText') {
return !(locale.code in draftValue && hasText(draftValue[locale.code]))
}
if (!(locale.code in draftValue)) {
return true
}
const value = draftValue[locale.code]
return value === null || value === '' || value === undefined
})
if (missingLocales.length > 0) {
return `This field is required in ${missingLocales.map(locale => locale.label).join(', ')}`
}
} else if (!draftValue) {
return 'This field is required'
}
}
}
return true
}
interface RequireTranslationsPluginArgs {
/**
* Whether to enable the plugin.
* @default true
*/
enable?: boolean
collectionSlugs?: CollectionSlug[]
globalSlugs?: GlobalSlug[]
}
// Main plugin function
export const requireTranslationsPlugin = ({
enable = true,
collectionSlugs = [],
globalSlugs = [],
}: RequireTranslationsPluginArgs = {}): Plugin => {
return config => {
if (!enable) {
return config
}
const addValidateFunctionsCallback: TraverseFieldsCallback = ({ field }) => {
switch (field.type) {
case 'richText':
case 'text':
case 'textarea':
case 'number':
case 'select':
if (field.localized) {
const originalValidate = field.validate
field.validate = validateAllLocales(originalValidate as Validate)
}
break
default:
break
}
}
// Process collections to add field-level hooks
;(config.collections || []).forEach((collection: CollectionConfig) => {
if (collectionSlugs.includes(collection.slug as CollectionSlug)) {
traverseFields({
fields: collection.fields,
callback: addValidateFunctionsCallback,
})
}
})
// Process globals to add field-level hooks
config.globals = (config.globals || []).map((global: GlobalConfig) => {
if (globalSlugs.includes(global.slug as GlobalSlug)) {
traverseFields({
fields: global.fields,
callback: addValidateFunctionsCallback,
})
}
return global
})
return config
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment