Skip to content

Commit d77af00

Browse files
JarrodMFleschJessRynkarr1tsuu
authored
feat: adds experimental option localizeStatus and allows unpublish per-locale functionality (#14667)
## Localized Status (Experimental) This PR introduces a new **experimental** option that allows each locale to track and manage its own publication status independently, for collection docs and globals. ### Configuration To enable this feature, you need **two** configurations: 1. Enable the experimental flag in your `payload.config`: ```ts experimental: { localizeStatus: true, // default: `false` } ``` 2. Enable it on specific collections/globals: ```ts export const Posts: CollectionConfig = { slug: 'posts', versions: { drafts: { localizeStatus: true, // default: false }, }, // ... } ``` ## Key Changes When enabled: - **Per-locale status tracking** - Each locale maintains its own published/draft status - **Independent publishing** - Publish or unpublish individual locales without affecting others - **Locale-aware UI** - Admin panel shows status for the currently active locale - **Localized version history** - Versions list reflects the current locale's status ## Improved Behavior - Creating a document in one locale sets that locale to the specified status and other locales default to draft - You can publish/unpublish specific locales independently - Collection list views show status for the currently active locale - Document edit view displays the current locale's status ## Migration Required (If you already have version data) > If this is a new project then you only need to enable the flags. If you have existing data you will want to continue with the migration guide below. ⚠️ Breaking Change: When `localizeStatus` is enabled, existing `_status` fields will need to be migrated from strings to locale objects. ### ➡️ Step 1 Before doing anything, **take a backup of your current database**. ### ➡️ Step 2 Stop your dev server if it is running ### ➡️ Step 3 **Create migration file** ```ts // run the following to create a blank // migration file named `localize_status` payload migrate:create localize_status ``` **Add migration code** 🔵 **PostgreSQL / SQLite**: ```ts import type { MigrateDownArgs, MigrateUpArgs } from '@payloadcms/db-postgres' import { sql } from '@payloadcms/db-postgres' import { localizeStatus } from 'payload/migrations' export async function up({ db, payload }: MigrateUpArgs): Promise<void> { await localizeStatus.up({ collectionSlug: 'posts', // 👈 Change to your collection db, payload, sql, }) } export async function down({ db, payload }: MigrateDownArgs): Promise<void> { await localizeStatus.down({ collectionSlug: 'posts', // 👈 Change to your collection db, payload, sql, }) } ``` 🟢 **MongoDB**: ```ts import type { MigrateDownArgs, MigrateUpArgs } from '@payloadcms/db-mongodb' import { localizeStatus } from 'payload/migrations' export async function up({ payload }: MigrateUpArgs): Promise<void> { await localizeStatus.up({ collectionSlug: 'posts', // 👈 Change to your collection payload, }) } export async function down({ payload }: MigrateDownArgs): Promise<void> { await localizeStatus.down({ collectionSlug: 'posts', // 👈 Change to your collection payload, }) } ``` ### ➡️ Step 4 Run the migration ```ts payload migrate ``` ### ➡️ Step 5 Set `localizeStatus: true` **Payload Config** ```ts // payload config file experimental: { localizeStatus: true, // <-- add this } ``` **Collection Config** ```ts // collection you want to migrate export const Posts: CollectionConfig = { slug: 'posts', versions: { drafts: { localizeStatus: true, // <-- add this }, }, // ... } ``` ### ➡️ Step 6 Run `pnpm dev` and test out the feature. ## Related PRs in this feature - [feat: adds versions.drafts.localizeStatus and allows unpublish per‑locale #14667](#14667) - [feat(ui): experimental localize metadata UI #14699](#14699) - [chore: localize status migration work #14862](#14862) --------- Co-authored-by: Jessica Chowdhury <jessica@trbl.design> Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com> Co-authored-by: Jessica Rynkar <67977755+jessrynkar@users.noreply.github.com>
1 parent 4b6529f commit d77af00

119 files changed

Lines changed: 5923 additions & 396 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/configuration/localization.mdx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,69 @@ All field types with a `name` property support the `localized` property—even t
167167
strategy.
168168
</Banner>
169169

170+
## Status Localization
171+
172+
Payload allows you to localize the `status` field for **draft enabled** collections and globals. This lets you manage publication status independently for each locale, ensures the admin UI always shows the status for the selected locale, and unpublish content in a single locale.
173+
174+
<Banner type="warning">
175+
**Important:** This feature is **experimental** and currently in beta, you may
176+
encounter some limitations or bugs. Please test thoroughly before using in
177+
production.
178+
</Banner>
179+
180+
**Two-Step Setup Required:** To enable localized status, you need to set **two** configuration options:
181+
182+
1. Enable the experimental flag in your main config
183+
2. Enable it for specific collections or globals
184+
185+
### Step 1: Enable Experimental Flag
186+
187+
First, enable the experimental flag in your main Payload config:
188+
189+
```ts
190+
import { buildConfig } from 'payload'
191+
192+
export default buildConfig({
193+
// highlight-start
194+
experimental: {
195+
localizeStatus: true, // Required to enable the feature globally
196+
},
197+
// highlight-end
198+
// ... rest of your config
199+
})
200+
```
201+
202+
### Step 2: Enable for Collections/Globals
203+
204+
Then, enable it for specific collections or globals:
205+
206+
```ts
207+
import type { CollectionConfig } from 'payload'
208+
209+
export const Posts: CollectionConfig = {
210+
// ...
211+
versions: {
212+
drafts: {
213+
// highlight-start
214+
localizeStatus: true, // Enable for this specific collection
215+
// highlight-end
216+
},
217+
},
218+
}
219+
```
220+
221+
When enabled, the `status` field will be stored as an object keyed by locales:
222+
223+
```ts
224+
status: {
225+
en: 'published',
226+
es: 'draft',
227+
de: 'published',
228+
}
229+
```
230+
231+
`localizeStatus` is disabled by default, in which case the `status` field returns a single string (`'draft'` or `'published'`) representing the latest document status across all locales.
232+
170233
## Retrieving Localized Docs
171234

172235
When retrieving documents, you can specify which locale you'd like to receive as well as which fallback locale should be

docs/versions/drafts.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Collections and Globals both support the same options for configuring drafts. Yo
2424
| Draft Option | Description |
2525
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2626
| `autosave` | Enable `autosave` to automatically save progress while documents are edited. To enable, set to `true` or pass an object with [options](/docs/versions/autosave). |
27+
| `localizeStatus` | **Beta**. Localizes the `_status` field when using [Localization](/docs/configuration/localization). Default is `false`. |
2728
| `schedulePublish` | Allow for editors to schedule publish / unpublish events in the future. [More](#scheduled-publish) |
2829
| `validate` | Set `validate` to `true` to validate draft documents when saved. Default is `false`. |
2930

packages/drizzle/src/queries/getTableColumnFromPath.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export const getTableColumnFromPath = ({
117117
}
118118
}
119119

120+
let localizedPathQuery = false
120121
if (field) {
121122
const pathSegments = [...incomingSegments]
122123

@@ -131,6 +132,7 @@ export const getTableColumnFromPath = ({
131132

132133
if (matchedLocale) {
133134
locale = matchedLocale
135+
localizedPathQuery = true
134136
pathSegments.splice(1, 1)
135137
}
136138
}
@@ -967,7 +969,13 @@ export const getTableColumnFromPath = ({
967969
const parentTable = aliasTable || adapter.tables[tableName]
968970
newTableName = `${tableName}${adapter.localesSuffix}`
969971

970-
newTable = adapter.tables[newTableName]
972+
// use an alias because the same query may contain constraints with different locale value
973+
if (localizedPathQuery) {
974+
const { newAliasTable } = getTableAlias({ adapter, tableName: newTableName })
975+
newTable = newAliasTable
976+
} else {
977+
newTable = adapter.tables[newTableName]
978+
}
971979

972980
let condition = eq(parentTable.id, newTable._parentID)
973981

packages/next/src/elements/DocumentHeader/Tabs/tabs/VersionsPill/index.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
text-align: center;
66
background-color: var(--theme-elevation-100);
77
border-radius: var(--style-radius-s);
8+
padding: 2px 4px;
89
}
910
}

packages/next/src/views/Document/getVersions.ts

Lines changed: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { sanitizeID } from '@payloadcms/ui/shared'
1+
import { sanitizeID, traverseForLocalizedFields } from '@payloadcms/ui/shared'
22
import {
33
combineQueries,
44
extractAccessFromPermission,
@@ -56,12 +56,19 @@ export const getVersions = async ({
5656

5757
const entityConfig = collectionConfig || globalConfig
5858
const versionsConfig = entityConfig?.versions
59+
const hasLocalizedFields = traverseForLocalizedFields(entityConfig.fields)
60+
const localizedDraftsEnabled =
61+
hasDraftsEnabled(entityConfig) &&
62+
typeof payload.config.localization === 'object' &&
63+
hasLocalizedFields
5964

6065
const shouldFetchVersions = Boolean(versionsConfig && docPermissions?.readVersions)
6166

6267
if (!shouldFetchVersions) {
6368
// Without readVersions permission, determine published status from the _status field
64-
const hasPublishedDoc = doc?._status !== 'draft'
69+
const hasPublishedDoc = localizedDraftsEnabled
70+
? doc?._status === 'published'
71+
: doc?._status !== 'draft'
6572

6673
return {
6774
hasPublishedDoc,
@@ -100,18 +107,9 @@ export const getVersions = async ({
100107
where: {
101108
and: [
102109
{
103-
or: [
104-
{
105-
_status: {
106-
equals: 'published',
107-
},
108-
},
109-
{
110-
_status: {
111-
exists: false,
112-
},
113-
},
114-
],
110+
_status: {
111+
equals: 'published',
112+
},
115113
},
116114
{
117115
id: {
@@ -129,26 +127,34 @@ export const getVersions = async ({
129127
}
130128

131129
if (hasAutosaveEnabled(collectionConfig)) {
130+
const where: Record<string, any> = {
131+
and: [
132+
{
133+
parent: {
134+
equals: id,
135+
},
136+
},
137+
],
138+
}
139+
140+
if (localizedDraftsEnabled) {
141+
where.and.push({
142+
snapshot: {
143+
not_equals: true,
144+
},
145+
})
146+
}
147+
132148
const mostRecentVersion = await payload.findVersions({
133149
collection: collectionConfig.slug,
134150
depth: 0,
135151
limit: 1,
152+
locale,
136153
select: {
137154
autosave: true,
138155
},
139156
user,
140-
where: combineQueries(
141-
{
142-
and: [
143-
{
144-
parent: {
145-
equals: id,
146-
},
147-
},
148-
],
149-
},
150-
extractAccessFromPermission(docPermissions.readVersions),
151-
),
157+
where: combineQueries(where, extractAccessFromPermission(docPermissions.readVersions)),
152158
})
153159

154160
if (
@@ -163,6 +169,7 @@ export const getVersions = async ({
163169
if (publishedDoc?.updatedAt) {
164170
;({ totalDocs: unpublishedVersionCount } = await payload.countVersions({
165171
collection: collectionConfig.slug,
172+
locale,
166173
user,
167174
where: combineQueries(
168175
{
@@ -190,20 +197,31 @@ export const getVersions = async ({
190197
}
191198
}
192199

200+
const countVersionsWhere: Record<string, any> = {
201+
and: [
202+
{
203+
parent: {
204+
equals: id,
205+
},
206+
},
207+
],
208+
}
209+
210+
if (localizedDraftsEnabled) {
211+
countVersionsWhere.and.push({
212+
snapshot: {
213+
not_equals: true,
214+
},
215+
})
216+
}
217+
193218
;({ totalDocs: versionCount } = await payload.countVersions({
194219
collection: collectionConfig.slug,
195220
depth: 0,
221+
locale,
196222
user,
197223
where: combineQueries(
198-
{
199-
and: [
200-
{
201-
parent: {
202-
equals: id,
203-
},
204-
},
205-
],
206-
},
224+
countVersionsWhere,
207225
extractAccessFromPermission(docPermissions.readVersions),
208226
),
209227
}))
@@ -234,6 +252,7 @@ export const getVersions = async ({
234252
const mostRecentVersion = await payload.findGlobalVersions({
235253
slug: globalConfig.slug,
236254
limit: 1,
255+
locale,
237256
select: {
238257
autosave: true,
239258
},
@@ -253,6 +272,7 @@ export const getVersions = async ({
253272
;({ totalDocs: unpublishedVersionCount } = await payload.countGlobalVersions({
254273
depth: 0,
255274
global: globalConfig.slug,
275+
locale,
256276
user,
257277
where: combineQueries(
258278
{
@@ -278,7 +298,15 @@ export const getVersions = async ({
278298
;({ totalDocs: versionCount } = await payload.countGlobalVersions({
279299
depth: 0,
280300
global: globalConfig.slug,
301+
locale,
281302
user,
303+
where: localizedDraftsEnabled
304+
? {
305+
snapshot: {
306+
not_equals: true,
307+
},
308+
}
309+
: undefined,
282310
}))
283311
}
284312

packages/next/src/views/Version/VersionPillLabel/VersionPillLabel.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use client'
22

3+
import type { TypeWithVersion } from 'payload'
4+
35
import { Pill, useConfig, useTranslation } from '@payloadcms/ui'
46
import { formatDate } from '@payloadcms/ui/shared'
57
import React from 'react'
@@ -18,10 +20,7 @@ const renderPill = (label: React.ReactNode, pillStyle: Parameters<typeof Pill>[0
1820
}
1921

2022
export const VersionPillLabel: React.FC<{
21-
currentlyPublishedVersion?: {
22-
id: number | string
23-
updatedAt: string
24-
}
23+
currentlyPublishedVersion?: TypeWithVersion<any>
2524
disableDate?: boolean
2625

2726
doc: {
@@ -31,7 +30,8 @@ export const VersionPillLabel: React.FC<{
3130
updatedAt?: string
3231
version: {
3332
[key: string]: unknown
34-
_status: string
33+
_status: 'draft' | 'published'
34+
updatedAt: string
3535
}
3636
}
3737
/**
@@ -45,10 +45,7 @@ export const VersionPillLabel: React.FC<{
4545
*/
4646
labelStyle?: 'pill' | 'text'
4747
labelSuffix?: React.ReactNode
48-
latestDraftVersion?: {
49-
id: number | string
50-
updatedAt: string
51-
}
48+
latestDraftVersion?: TypeWithVersion<any>
5249
}> = ({
5350
currentlyPublishedVersion,
5451
disableDate = false,

0 commit comments

Comments
 (0)