diff --git a/docs/configuration/localization.mdx b/docs/configuration/localization.mdx index 8a01635958b..56fedcebb88 100644 --- a/docs/configuration/localization.mdx +++ b/docs/configuration/localization.mdx @@ -144,15 +144,17 @@ export default buildConfig({ // ... experimental: { localizeStatus: true, + localizeUpdatedAt: true, }, }) ``` The following experimental options are available related to localization: -| Option | Description | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`localizeStatus`** | **Boolean.** When `true`, shows document status per locale in the admin panel instead of always showing the latest overall status. Opt-in for backwards compatibility. Defaults to `false`. | +| Option | Description | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`localizeStatus`** | **Boolean.** When `true`, shows document status per locale in the admin panel instead of always showing the latest overall status. Opt-in for backwards compatibility. Defaults to `false`. | +| **`localizeUpdatedAt`** | **Boolean.** When `true`, shows `updatedAt` timestamp per locale in the admin panel instead of showing the latest overall timestamp. Opt-in for backwards compatibility. Defaults to `false`. | ## Field Localization diff --git a/docs/experimental/overview.mdx b/docs/experimental/overview.mdx index ec423ce335f..61fa98ca3d0 100644 --- a/docs/experimental/overview.mdx +++ b/docs/experimental/overview.mdx @@ -19,6 +19,7 @@ const config = buildConfig({ // ... experimental: { localizeStatus: true, // highlight-line + localizeUpdatedAt: true, // highlight-line }, }) ``` @@ -27,9 +28,10 @@ const config = buildConfig({ The following options are available: -| Option | Description | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`localizeStatus`** | **Boolean.** When `true`, shows document status per locale in the admin panel instead of always showing the latest overall status. Opt-in for backwards compatibility. Defaults to `false`. | +| Option | Description | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`localizeStatus`** | **Boolean.** When `true`, shows document status per locale in the admin panel instead of always showing the latest overall status. Opt-in for backwards compatibility. Defaults to `false`. | +| **`localizeUpdatedAt`** | **Boolean.** When `true`, shows `updatedAt` timestamp per locale in the admin panel instead of showing the latest overall timestamp. Opt-in for backwards compatibility. Defaults to `false`. | This list may change without notice. diff --git a/packages/db-mongodb/src/createGlobalVersion.ts b/packages/db-mongodb/src/createGlobalVersion.ts index 0541ada01bc..c04d9513753 100644 --- a/packages/db-mongodb/src/createGlobalVersion.ts +++ b/packages/db-mongodb/src/createGlobalVersion.ts @@ -13,6 +13,7 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo createdAt, globalSlug, localeStatus, + localeUpdatedAt, parent, publishedLocale, req, @@ -35,6 +36,7 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo createdAt, latest: true, localeStatus, + localeUpdatedAt, parent, publishedLocale, snapshot, diff --git a/packages/db-mongodb/src/createVersion.ts b/packages/db-mongodb/src/createVersion.ts index a0af56ac57b..d0b92fe85a0 100644 --- a/packages/db-mongodb/src/createVersion.ts +++ b/packages/db-mongodb/src/createVersion.ts @@ -13,6 +13,7 @@ export const createVersion: CreateVersion = async function createVersion( collectionSlug, createdAt, localeStatus, + localeUpdatedAt, parent, publishedLocale, req, @@ -39,6 +40,7 @@ export const createVersion: CreateVersion = async function createVersion( createdAt, latest: true, localeStatus, + localeUpdatedAt, parent, publishedLocale, snapshot, diff --git a/packages/db-mongodb/src/queryDrafts.ts b/packages/db-mongodb/src/queryDrafts.ts index 62c6f91146b..b32499167d1 100644 --- a/packages/db-mongodb/src/queryDrafts.ts +++ b/packages/db-mongodb/src/queryDrafts.ts @@ -181,9 +181,16 @@ export const queryDrafts: QueryDrafts = async function queryDrafts( const id = result.docs[i].parent const localeStatus = result.docs[i].localeStatus || {} - if (locale && localeStatus[locale]) { - result.docs[i].status = localeStatus[locale] - result.docs[i].version._status = localeStatus[locale] + const localeUpdatedAt = result.docs[i].localeUpdatedAt || {} + + if (locale) { + if (localeStatus[locale]) { + result.docs[i].status = localeStatus[locale] + result.docs[i].version._status = localeStatus[locale] + } + if (localeUpdatedAt[locale]) { + result.docs[i].version.updatedAt = localeUpdatedAt[locale] + } } result.docs[i] = result.docs[i].version ?? {} diff --git a/packages/drizzle/src/createGlobalVersion.ts b/packages/drizzle/src/createGlobalVersion.ts index bb33fbe6d09..aa01df89f1c 100644 --- a/packages/drizzle/src/createGlobalVersion.ts +++ b/packages/drizzle/src/createGlobalVersion.ts @@ -16,6 +16,7 @@ export async function createGlobalVersion( createdAt, globalSlug, localeStatus, + localeUpdatedAt, publishedLocale, req, returning, @@ -37,6 +38,7 @@ export async function createGlobalVersion( createdAt, latest: true, localeStatus, + localeUpdatedAt, publishedLocale, snapshot, updatedAt, diff --git a/packages/drizzle/src/createVersion.ts b/packages/drizzle/src/createVersion.ts index 12c41dd0c73..a27a89f94ed 100644 --- a/packages/drizzle/src/createVersion.ts +++ b/packages/drizzle/src/createVersion.ts @@ -16,6 +16,7 @@ export async function createVersion( collectionSlug, createdAt, localeStatus, + localeUpdatedAt, parent, publishedLocale, req, @@ -42,6 +43,7 @@ export async function createVersion( createdAt, latest: true, localeStatus, + localeUpdatedAt, parent, publishedLocale, snapshot, diff --git a/packages/drizzle/src/queryDrafts.ts b/packages/drizzle/src/queryDrafts.ts index 4e066e670b1..14a0f44a8a2 100644 --- a/packages/drizzle/src/queryDrafts.ts +++ b/packages/drizzle/src/queryDrafts.ts @@ -39,9 +39,16 @@ export const queryDrafts: QueryDrafts = async function queryDrafts( for (let i = 0; i < result.docs.length; i++) { const id = result.docs[i].parent const localeStatus = result.docs[i].localeStatus || {} - if (locale && localeStatus[locale]) { - result.docs[i].status = localeStatus[locale] - result.docs[i].version._status = localeStatus[locale] + const localeUpdatedAt = result.docs[i].localeUpdatedAt || {} + + if (locale) { + if (localeStatus[locale]) { + result.docs[i].status = localeStatus[locale] + result.docs[i].version._status = localeStatus[locale] + } + if (localeUpdatedAt[locale]) { + result.docs[i].version.updatedAt = localeUpdatedAt[locale] + } } result.docs[i] = result.docs[i].version ?? {} diff --git a/packages/next/src/views/Versions/cells/AutosaveCell/index.tsx b/packages/next/src/views/Versions/cells/AutosaveCell/index.tsx index 8d17f30f497..e62da9dbbd8 100644 --- a/packages/next/src/views/Versions/cells/AutosaveCell/index.tsx +++ b/packages/next/src/views/Versions/cells/AutosaveCell/index.tsx @@ -20,6 +20,7 @@ type AutosaveCellProps = { autosave?: boolean id: number | string localeStatus?: Record + localeUpdatedAt?: Record publishedLocale?: string version: { _status: string diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index 3d9ee250867..1f957b9b102 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -162,9 +162,13 @@ export const createClientConfig = ({ case 'experimental': if (config.experimental) { clientConfig.experimental = {} - if (config.experimental?.localizeStatus) { + if (config.experimental.localizeStatus) { clientConfig.experimental.localizeStatus = config.experimental.localizeStatus } + + if (config.experimental.localizeUpdatedAt) { + clientConfig.experimental.localizeUpdatedAt = config.experimental.localizeUpdatedAt + } } break diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 106f3edc5c3..42b48464471 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -727,6 +727,10 @@ export type ImportMapGenerators = Array< */ export type ExperimentalConfig = { localizeStatus?: boolean + /** + * Boolean. Returns the `updatedAt` timestamp of the localized document. + **/ + localizeUpdatedAt?: boolean } export type AfterErrorHook = ( diff --git a/packages/payload/src/database/types.ts b/packages/payload/src/database/types.ts index df55f947981..4d12ba16ab9 100644 --- a/packages/payload/src/database/types.ts +++ b/packages/payload/src/database/types.ts @@ -391,6 +391,7 @@ export type CreateVersionArgs = { collectionSlug: CollectionSlug createdAt: string localeStatus?: Record + localeUpdatedAt?: Record /** ID of the parent document for which the version should be created for */ parent: number | string publishedLocale?: string @@ -416,6 +417,7 @@ export type CreateGlobalVersionArgs = { createdAt: string globalSlug: GlobalSlug localeStatus?: Record + localeUpdatedAt?: Record /** ID of the parent document for which the version should be created for */ parent: number | string publishedLocale?: string diff --git a/packages/payload/src/versions/baseFields.ts b/packages/payload/src/versions/baseFields.ts index a0e542204a6..e74c58f9e63 100644 --- a/packages/payload/src/versions/baseFields.ts +++ b/packages/payload/src/versions/baseFields.ts @@ -64,3 +64,19 @@ export function buildLocaleStatusField(config: SanitizedConfig): Field[] { } }) } + +export function buildLocaleUpdatedAtFields(config: SanitizedConfig): Field[] { + if (!config.localization || !config.localization.locales) { + return [] + } + + return config.localization.locales.map((locale) => { + const code = typeof locale === 'string' ? locale : locale.code + + return { + name: code, + type: 'date', + index: true, + } + }) +} diff --git a/packages/payload/src/versions/buildCollectionFields.ts b/packages/payload/src/versions/buildCollectionFields.ts index f047373f857..688cc7b2a99 100644 --- a/packages/payload/src/versions/buildCollectionFields.ts +++ b/packages/payload/src/versions/buildCollectionFields.ts @@ -2,7 +2,11 @@ import type { SanitizedCollectionConfig } from '../collections/config/types.js' import type { SanitizedConfig } from '../config/types.js' import type { Field, FlattenedField } from '../fields/config/types.js' -import { buildLocaleStatusField, versionSnapshotField } from './baseFields.js' +import { + buildLocaleStatusField, + buildLocaleUpdatedAtFields, + versionSnapshotField, +} from './baseFields.js' export const buildVersionCollectionFields = ( config: SanitizedConfig, @@ -79,6 +83,23 @@ export const buildVersionCollectionFields = ( })!, }) } + + if (config.experimental?.localizeUpdatedAt) { + const localeUpdatedAtFields = buildLocaleUpdatedAtFields(config) + + fields.push({ + name: 'localeUpdatedAt', + type: 'group', + admin: { + disableBulkEdit: true, + disabled: true, + }, + fields: localeUpdatedAtFields, + ...(flatten && { + flattenedFields: localeUpdatedAtFields as FlattenedField[], + })!, + }) + } } fields.push({ diff --git a/packages/payload/src/versions/buildGlobalFields.ts b/packages/payload/src/versions/buildGlobalFields.ts index b9675ddbd06..6b74af61987 100644 --- a/packages/payload/src/versions/buildGlobalFields.ts +++ b/packages/payload/src/versions/buildGlobalFields.ts @@ -2,7 +2,11 @@ import type { SanitizedConfig } from '../config/types.js' import type { Field, FlattenedField } from '../fields/config/types.js' import type { SanitizedGlobalConfig } from '../globals/config/types.js' -import { buildLocaleStatusField, versionSnapshotField } from './baseFields.js' +import { + buildLocaleStatusField, + buildLocaleUpdatedAtFields, + versionSnapshotField, +} from './baseFields.js' export const buildVersionGlobalFields = ( config: SanitizedConfig, @@ -73,6 +77,23 @@ export const buildVersionGlobalFields = ( })!, }) } + + if (config.experimental.localizeUpdatedAt) { + const localeUpdatedAtFields = buildLocaleUpdatedAtFields(config) + + fields.push({ + name: 'localeUpdatedAt', + type: 'group', + admin: { + disableBulkEdit: true, + disabled: true, + }, + fields: localeUpdatedAtFields, + ...(flatten && { + flattenedFields: localeUpdatedAtFields as FlattenedField[], + })!, + }) + } } fields.push({ diff --git a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts index f4d0ac25e37..3c50acef3bd 100644 --- a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts +++ b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts @@ -117,6 +117,12 @@ export const replaceWithDraftIfAvailable = async ({ ;(draft.version as { _status?: string })['_status'] = localeStatus[locale] } + // Lift locale updatedAt from version data if available + const localeUpdatedAt = draft.localeUpdatedAt || {} + if (locale && localeUpdatedAt[locale]) { + ;(draft.version as { updatedAt?: string })['updatedAt'] = localeUpdatedAt[locale] + } + // Disregard all other draft content at this point, // Only interested in the version itself. // Operations will handle firing hooks, etc. diff --git a/packages/payload/src/versions/saveVersion.ts b/packages/payload/src/versions/saveVersion.ts index d101aa05ca8..29d90809d5a 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -131,41 +131,63 @@ export const saveVersion = async ({ if (createNewVersion) { let localeStatus = {} + let localeUpdatedAt = {} const localizationEnabled = payload.config.localization && payload.config.localization.locales.length > 0 - if ( - localizationEnabled && - payload.config.localization !== false && - payload.config.experimental?.localizeStatus - ) { + if (localizationEnabled && payload.config.localization !== false) { const allLocales = ( (payload.config.localization && payload.config.localization?.locales) || [] ).map((locale) => (typeof locale === 'string' ? locale : locale.code)) - // If `publish all`, set all locales to published - if (versionData._status === 'published' && !publishSpecificLocale) { - localeStatus = Object.fromEntries(allLocales.map((code) => [code, 'published'])) - } else if (publishSpecificLocale || (locale && versionData._status === 'draft')) { - const status: 'draft' | 'published' = publishSpecificLocale ? 'published' : 'draft' - const incomingLocale = String(publishSpecificLocale || locale) - const existing = latestVersion?.localeStatus + if (payload.config.experimental?.localizeStatus) { + // If `publish all`, set all locales to published + if (versionData._status === 'published' && !publishSpecificLocale) { + localeStatus = Object.fromEntries(allLocales.map((code) => [code, 'published'])) + } else if (publishSpecificLocale || (locale && versionData._status === 'draft')) { + const status: 'draft' | 'published' = publishSpecificLocale ? 'published' : 'draft' + const incomingLocale = String(publishSpecificLocale || locale) + const existing = latestVersion?.localeStatus + + // If no locale statuses are set, set it and set all others to draft + if (!existing) { + localeStatus = { + ...Object.fromEntries( + allLocales + .filter((code) => code !== incomingLocale) + .map((code) => [code, 'draft']), + ), + [incomingLocale]: status, + } + } else { + // If locales already exist, update the status for the incoming locale + const { [incomingLocale]: _, ...rest } = existing + localeStatus = { + ...rest, + [incomingLocale]: status, + } + } + } + } + + if (payload.config.experimental?.localizeUpdatedAt) { + const incomingLocale = String(locale) + const existing = latestVersion?.localeUpdatedAt - // If no locale statuses are set, set it and set all others to draft if (!existing) { - localeStatus = { + localeUpdatedAt = { ...Object.fromEntries( - allLocales.filter((code) => code !== incomingLocale).map((code) => [code, 'draft']), + allLocales + .filter((code) => code !== incomingLocale) + .map((code) => [code, latestVersion?.createdAt || now]), ), - [incomingLocale]: status, + [incomingLocale]: now, } } else { - // If locales already exist, update the status for the incoming locale - const { [incomingLocale]: _, ...rest } = existing - localeStatus = { - ...rest, - [incomingLocale]: status, + localeUpdatedAt = { + ...existing, + [incomingLocale]: now, } } } @@ -177,6 +199,7 @@ export const saveVersion = async ({ createdAt: operation === 'restoreVersion' ? versionData.createdAt : now, globalSlug: undefined as string | undefined, localeStatus, + localeUpdatedAt, parent: collection ? id : undefined, publishedLocale: publishSpecificLocale || undefined, req, diff --git a/packages/payload/src/versions/types.ts b/packages/payload/src/versions/types.ts index d79c59d5338..45bb8f669f6 100644 --- a/packages/payload/src/versions/types.ts +++ b/packages/payload/src/versions/types.ts @@ -123,6 +123,7 @@ export type TypeWithVersion = { createdAt: string id: string localeStatus: Record + localeUpdatedAt?: Record parent: number | string publishedLocale?: string snapshot?: boolean diff --git a/test/localization/config.ts b/test/localization/config.ts index 8389526a374..4415d80bd84 100644 --- a/test/localization/config.ts +++ b/test/localization/config.ts @@ -430,6 +430,7 @@ export default buildConfigWithDefaults({ ], experimental: { localizeStatus: true, + localizeUpdatedAt: true, }, localization: { filterAvailableLocales: ({ locales }) => { diff --git a/test/localization/e2e.spec.ts b/test/localization/e2e.spec.ts index 816112bc951..9d98c4e696a 100644 --- a/test/localization/e2e.spec.ts +++ b/test/localization/e2e.spec.ts @@ -75,6 +75,7 @@ let client: RESTClient let serverURL: string let richTextURL: AdminUrlUtil let context: BrowserContext +let existingDraftURL: string describe('Localization', () => { beforeAll(async ({ browser }, testInfo) => { @@ -320,6 +321,7 @@ describe('Localization', () => { await page.locator('#field-title').fill(englishTitle) await page.locator('#field-nav__layout .blocks-field__drawer-toggler').click() await page.locator('button[title="Text"]').click() + await saveDocAndAssert(page) await page.fill('#field-nav__layout__0__text', 'test') await expect(page.locator('#field-nav__layout__0__text')).toHaveValue('test') await saveDocAndAssert(page) @@ -365,7 +367,6 @@ describe('Localization', () => { await expect(localeLabel).not.toHaveText('English') }) }) - describe('localized relationships', () => { test('ensure relationship field fetches are localized as well', async () => { await changeLocale(page, spanishLocale) @@ -708,6 +709,31 @@ describe('Localization', () => { await page.goto(noLocalizedFieldsURL.create) await expect(page.locator('#publish-locale')).toHaveCount(0) }) + + describe('localized timestamps', () => { + beforeAll(async () => { + await page.goto(urlPostsWithDrafts.create) + await fillValues({ title: 'created in english' }) + await saveDocAndAssert(page) + existingDraftURL = page.url() + await new Promise((resolve) => setTimeout(resolve, 30000)) + }) + test('should show localized updatedAt timestamp', async () => { + await page.goto(existingDraftURL) + const lastModifiedEnglish = await getDocControlValue(page, 'Last Modified') + await new Promise((resolve) => setTimeout(resolve, 30000)) + await changeLocale(page, spanishLocale) + await fillValues({ title: 'updated in spanish' }) + await saveDocAndAssert(page, '#action-save-draft') + + const lastModifiedSpanish = await getDocControlValue(page, 'Last Modified') + expect(lastModifiedSpanish).not.toBe(lastModifiedEnglish) + + await changeLocale(page, defaultLocale) + const lastModified = await getDocControlValue(page, 'Last Modified') + expect(lastModified).toBe(lastModifiedEnglish) + }) + }) }) async function createLocalizedArrayItem(page: Page, url: AdminUrlUtil) { @@ -754,3 +780,11 @@ async function setToLocale(page, locale) { const options = page.locator('.rs__option') await options.locator(`text=${locale}`).click() } + +async function getDocControlValue(page: Page, label: string): Promise { + return page + .locator( + `li.doc-controls__list-item.doc-controls__value-wrap:has(p.doc-controls__label:has-text("${label}")) >> p.doc-controls__value`, + ) + .innerText() +}