From 6512282fe0c4ec37bbe28cc650cac3a16fa87696 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:38:27 -0400 Subject: [PATCH 1/2] fix(plugin-search): reindex documents published in non-default locales --- .../src/utilities/generateReindexHandler.ts | 49 +++++++++++++-- .../collections/LocalizedStatusPosts.ts | 37 +++++++++++ test/plugin-search/config.ts | 14 ++++- test/plugin-search/int.spec.ts | 63 ++++++++++++++++++- test/plugin-search/payload-types.ts | 48 ++++++++++++++ test/plugin-search/shared.ts | 2 + 6 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 test/plugin-search/collections/LocalizedStatusPosts.ts diff --git a/packages/plugin-search/src/utilities/generateReindexHandler.ts b/packages/plugin-search/src/utilities/generateReindexHandler.ts index e873aee262e..7f508f79fa4 100644 --- a/packages/plugin-search/src/utilities/generateReindexHandler.ts +++ b/packages/plugin-search/src/utilities/generateReindexHandler.ts @@ -8,6 +8,7 @@ import { initTransaction, killTransaction, } from 'payload' +import { hasLocalizeStatusEnabled } from 'payload/shared' import type { SanitizedSearchPluginConfig } from '../types.js' @@ -89,12 +90,29 @@ export const generateReindexHandler = equals: 'published', }, } + // Localized `_status` tracks published state per-locale; match docs published in any locale. + function buildPublishedWhere(collection: string): Where { + const collectionConfig = payload.collections[collection]?.config + const localeCodes = payload.config.localization ? payload.config.localization.localeCodes : [] + + if (collectionConfig && hasLocalizeStatusEnabled(collectionConfig) && localeCodes.length) { + return { + or: localeCodes.map((localeCode) => ({ + [`_status.${localeCode}`]: { + equals: 'published', + }, + })), + } + } + + return whereStatusPublished + } async function countDocuments(collection: string, drafts?: boolean): Promise { const { totalDocs } = await payload.count({ collection, ...defaultLocalApiProps, req: undefined, - where: drafts ? undefined : whereStatusPublished, + where: drafts ? undefined : buildPublishedWhere(collection), }) return totalDocs } @@ -112,7 +130,11 @@ export const generateReindexHandler = async function reindexCollection( collection: string, ): Promise<{ docs: number; docsWithDrafts: number; errors: number }> { - const draftsEnabled = Boolean(payload.collections[collection]?.config.versions?.drafts) + const collectionConfig = payload.collections[collection]?.config + const draftsEnabled = Boolean(collectionConfig?.versions?.drafts) + const localizeStatusEnabled = collectionConfig + ? hasLocalizeStatusEnabled(collectionConfig) + : false const totalDocsWithDrafts = await countDocuments(collection, true) const totalDocs = @@ -133,9 +155,10 @@ export const generateReindexHandler = collection, depth: 0, limit: batchSize, - locale: defaultLocale, + // Fetch all locales so each locale's `_status` is available for gating below + locale: localizeStatusEnabled ? 'all' : defaultLocale, page: i + 1, - where: syncDrafts || !draftsEnabled ? undefined : whereStatusPublished, + where: syncDrafts || !draftsEnabled ? undefined : buildPublishedWhere(collection), ...defaultLocalApiProps, }) @@ -171,14 +194,28 @@ export const generateReindexHandler = continue // Skip this locale } + // Skip locales that aren't published (unless syncing drafts) + let docToSync = doc + if (localizeStatusEnabled) { + const localeStatus = (doc as { _status?: Record })._status?.[ + localeToSync as string + ] + + if (!syncDrafts && localeStatus !== 'published') { + continue + } + + docToSync = { ...doc, _status: localeStatus } + } + // Sync this locale (create first index, then update with other locales accordingly) const operation = firstAllowedLocale ? 'create' : 'update' firstAllowedLocale = false await syncDocAsSearchIndex({ collection, - data: doc, - doc, + data: docToSync, + doc: docToSync, locale: localeToSync, onSyncError: () => operation === 'create' && localErrors++, operation, diff --git a/test/plugin-search/collections/LocalizedStatusPosts.ts b/test/plugin-search/collections/LocalizedStatusPosts.ts new file mode 100644 index 00000000000..b3ea3218633 --- /dev/null +++ b/test/plugin-search/collections/LocalizedStatusPosts.ts @@ -0,0 +1,37 @@ +import type { CollectionConfig } from 'payload' + +import { localizedStatusPostsSlug } from '../shared.js' + +export const LocalizedStatusPosts: CollectionConfig = { + slug: localizedStatusPostsSlug, + labels: { + singular: 'Localized Status Post', + plural: 'Localized Status Posts', + }, + admin: { + useAsTitle: 'title', + }, + versions: { + drafts: { + localizeStatus: true, + }, + }, + fields: [ + { + name: 'title', + label: 'Title', + type: 'text', + required: true, + }, + { + name: 'excerpt', + label: 'Excerpt', + type: 'text', + }, + { + type: 'text', + name: 'slug', + localized: true, + }, + ], +} diff --git a/test/plugin-search/config.ts b/test/plugin-search/config.ts index 45d6394cc94..2f289abdcb3 100644 --- a/test/plugin-search/config.ts +++ b/test/plugin-search/config.ts @@ -9,16 +9,21 @@ import type { Config } from './payload-types.js' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { devUser } from '../credentials.js' +import { LocalizedStatusPosts } from './collections/LocalizedStatusPosts.js' import { Pages } from './collections/Pages.js' import { Posts } from './collections/Posts.js' import { Users } from './collections/Users.js' import { seed } from './seed/index.js' export default buildConfigWithDefaults({ + experimental: { + localizeStatus: true, + }, collections: [ Users, Pages, Posts, + LocalizedStatusPosts, { slug: 'custom-ids-1', fields: [{ type: 'text', name: 'id' }], @@ -72,7 +77,14 @@ export default buildConfigWithDefaults({ slug: originalDoc.slug, } }, - collections: ['pages', 'posts', 'custom-ids-1', 'custom-ids-2', 'filtered-locales'], + collections: [ + 'pages', + 'posts', + 'custom-ids-1', + 'custom-ids-2', + 'filtered-locales', + 'localized-status-posts', + ], skipSync: ({ locale, doc, collectionSlug }) => { if (collectionSlug === 'filtered-locales' && doc.syncEnglishOnly) { return locale !== 'en' diff --git a/test/plugin-search/int.spec.ts b/test/plugin-search/int.spec.ts index 2f614517db0..402b54c2911 100644 --- a/test/plugin-search/int.spec.ts +++ b/test/plugin-search/int.spec.ts @@ -9,7 +9,7 @@ import type { NextRESTClient } from '../__helpers/shared/NextRESTClient.js' import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js' import { devUser } from '../credentials.js' -import { pagesSlug, postsSlug } from './shared.js' +import { localizedStatusPostsSlug, pagesSlug, postsSlug } from './shared.js' let payload: Payload let restClient: NextRESTClient @@ -63,6 +63,15 @@ describe('@payloadcms/plugin-search', () => { }, }, }), + payload.delete({ + collection: localizedStatusPostsSlug, + depth: 0, + where: { + id: { + exists: true, + }, + }, + }), ]) }) @@ -762,6 +771,58 @@ describe('@payloadcms/plugin-search', () => { expect(postAfterReindex?.slug).toStrictEqual(postBeforeReindex?.slug) }) + it('should reindex documents published only in a non-default locale when status is localized', async () => { + // Draft in the default locale ('en'), published only in a non-default locale ('de') + const post = await payload.create({ + collection: localizedStatusPostsSlug, + locale: 'en', + data: { + title: 'Published in DE only', + _status: 'draft', + slug: 'de-only-en', + }, + }) + + await payload.update({ + collection: localizedStatusPostsSlug, + id: post.id, + locale: 'de', + data: { + _status: 'published', + slug: 'de-only-de', + }, + }) + + const endpointRes = await restClient.POST(`/search/reindex`, { + body: JSON.stringify({ + collections: [localizedStatusPostsSlug], + }), + headers: { + Authorization: `JWT ${token}`, + }, + }) + + expect(endpointRes.status).toBe(200) + + const { docs } = await payload.find({ + collection: 'search', + depth: 0, + pagination: false, + locale: 'all', + where: { + doc: { + equals: { + relationTo: localizedStatusPostsSlug, + value: post.id, + }, + }, + }, + }) + + // The document is published in 'de', so it must be indexed even though 'en' is a draft + expect(docs).toHaveLength(1) + }) + it('should sync trashed documents correctly with search plugin', async () => { // Create a published post const publishedPost = await payload.create({ diff --git a/test/plugin-search/payload-types.ts b/test/plugin-search/payload-types.ts index 8b710d80a8f..2152fc14189 100644 --- a/test/plugin-search/payload-types.ts +++ b/test/plugin-search/payload-types.ts @@ -70,6 +70,7 @@ export interface Config { users: User; pages: Page; posts: Post; + 'localized-status-posts': LocalizedStatusPost; 'custom-ids-1': CustomIds1; 'custom-ids-2': CustomIds2; 'filtered-locales': FilteredLocale; @@ -84,6 +85,7 @@ export interface Config { users: UsersSelect | UsersSelect; pages: PagesSelect | PagesSelect; posts: PostsSelect | PostsSelect; + 'localized-status-posts': LocalizedStatusPostsSelect | LocalizedStatusPostsSelect; 'custom-ids-1': CustomIds1Select | CustomIds1Select; 'custom-ids-2': CustomIds2Select | CustomIds2Select; 'filtered-locales': FilteredLocalesSelect | FilteredLocalesSelect; @@ -100,6 +102,9 @@ export interface Config { globals: {}; globalsSelect: {}; locale: 'en' | 'es' | 'de'; + widgets: { + collections: CollectionsWidget; + }; user: User; jobs: { tasks: unknown; @@ -175,6 +180,19 @@ export interface Post { deletedAt?: string | null; _status?: ('draft' | 'published') | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "localized-status-posts". + */ +export interface LocalizedStatusPost { + id: string; + title: string; + excerpt?: string | null; + slug?: string | null; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "custom-ids-1". @@ -233,6 +251,10 @@ export interface Search { | { relationTo: 'filtered-locales'; value: string | FilteredLocale; + } + | { + relationTo: 'localized-status-posts'; + value: string | LocalizedStatusPost; }; id: string; excerpt?: string | null; @@ -276,6 +298,10 @@ export interface PayloadLockedDocument { relationTo: 'posts'; value: string | Post; } | null) + | ({ + relationTo: 'localized-status-posts'; + value: string | LocalizedStatusPost; + } | null) | ({ relationTo: 'custom-ids-1'; value: string | CustomIds1; @@ -380,6 +406,18 @@ export interface PostsSelect { deletedAt?: T; _status?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "localized-status-posts_select". + */ +export interface LocalizedStatusPostsSelect { + title?: T; + excerpt?: T; + slug?: T; + updatedAt?: T; + createdAt?: T; + _status?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "custom-ids-1_select". @@ -462,6 +500,16 @@ export interface PayloadMigrationsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collections_widget". + */ +export interface CollectionsWidget { + data?: { + [k: string]: unknown; + }; + width: 'full'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". diff --git a/test/plugin-search/shared.ts b/test/plugin-search/shared.ts index 3b4805dfc56..2b5d98ab42e 100644 --- a/test/plugin-search/shared.ts +++ b/test/plugin-search/shared.ts @@ -1,3 +1,5 @@ export const pagesSlug = 'pages' export const postsSlug = 'posts' + +export const localizedStatusPostsSlug = 'localized-status-posts' From 94bc615cc5d290b8f261a49ea33439b8a4f41105 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:40:49 -0400 Subject: [PATCH 2/2] test: assert the indexed value for the published locale --- test/plugin-search/int.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/plugin-search/int.spec.ts b/test/plugin-search/int.spec.ts index 402b54c2911..e4f0e08ae83 100644 --- a/test/plugin-search/int.spec.ts +++ b/test/plugin-search/int.spec.ts @@ -821,6 +821,16 @@ describe('@payloadcms/plugin-search', () => { // The document is published in 'de', so it must be indexed even though 'en' is a draft expect(docs).toHaveLength(1) + + // The indexed 'de' locale must hold that locale's scalar value, not the whole `locale: 'all'` map + const deSearchDoc = await payload.findByID({ + collection: 'search', + id: docs[0]!.id, + depth: 0, + locale: 'de', + }) + + expect(deSearchDoc.slug).toBe('de-only-de') }) it('should sync trashed documents correctly with search plugin', async () => {