feat: add publish specific locale (#7669)

## Description

1. Adds ability to publish a specific individual locale (collections and
globals)
2. Shows published locale in versions list and version comparison
3. Adds new int tests to `versions` test suite

- [X] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [X] New feature (non-breaking change which adds functionality)
- [ ] This change requires a documentation update

## Checklist:

- [X] I have added tests that prove my fix is effective or that my
feature works
- [X] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
This commit is contained in:
Jessica Chowdhury
2024-09-16 16:15:29 -04:00
committed by GitHub
parent aee76cb793
commit b7a0b15786
87 changed files with 1324 additions and 141 deletions

View File

@@ -0,0 +1,24 @@
import type { CollectionConfig } from 'payload'
import { localizedCollectionSlug } from '../slugs.js'
const LocalizedPosts: CollectionConfig = {
slug: localizedCollectionSlug,
versions: {
drafts: true,
},
fields: [
{
name: 'text',
type: 'text',
localized: true,
},
{
name: 'description',
type: 'text',
localized: true,
},
],
}
export default LocalizedPosts

View File

@@ -8,12 +8,14 @@ import CustomIDs from './collections/CustomIDs.js'
import DisablePublish from './collections/DisablePublish.js'
import DraftPosts from './collections/Drafts.js'
import DraftWithMax from './collections/DraftsWithMax.js'
import LocalizedPosts from './collections/Localized.js'
import Posts from './collections/Posts.js'
import VersionPosts from './collections/Versions.js'
import AutosaveGlobal from './globals/Autosave.js'
import DisablePublishGlobal from './globals/DisablePublish.js'
import DraftGlobal from './globals/Draft.js'
import DraftWithMaxGlobal from './globals/DraftWithMax.js'
import LocalizedGlobal from './globals/LocalizedGlobal.js'
import { seed } from './seed.js'
export default buildConfigWithDefaults({
@@ -28,14 +30,36 @@ export default buildConfigWithDefaults({
AutosavePosts,
DraftPosts,
DraftWithMax,
LocalizedPosts,
VersionPosts,
CustomIDs,
],
globals: [AutosaveGlobal, DraftGlobal, DraftWithMaxGlobal, DisablePublishGlobal],
globals: [AutosaveGlobal, DraftGlobal, DraftWithMaxGlobal, DisablePublishGlobal, LocalizedGlobal],
indexSortableFields: true,
localization: {
defaultLocale: 'en',
locales: ['en', 'es'],
locales: [
{
code: 'en',
label: 'English',
},
{
code: 'es',
label: {
en: 'Spanish',
es: 'Español',
de: 'Spanisch',
},
},
{
code: 'de',
label: {
en: 'German',
es: 'Alemán',
de: 'Deutsch',
},
},
],
},
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {

View File

@@ -48,8 +48,8 @@ import { reInitializeDB } from '../helpers/reInitializeDB.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { titleToDelete } from './shared.js'
import {
autoSaveGlobalSlug,
autosaveCollectionSlug,
autoSaveGlobalSlug,
customIDSlug,
disablePublishGlobalSlug,
disablePublishSlug,
@@ -57,6 +57,8 @@ import {
draftGlobalSlug,
draftWithMaxCollectionSlug,
draftWithMaxGlobalSlug,
localizedCollectionSlug,
localizedGlobalSlug,
postCollectionSlug,
} from './slugs.js'
@@ -66,6 +68,8 @@ const dirname = path.dirname(filename)
const { beforeAll, beforeEach, describe } = test
let payload: PayloadTestSDK<Config>
let global: AdminUrlUtil
let id: string
const waitForAutoSaveToComplete = async (page: Page) => {
await expect(async () => {
@@ -97,6 +101,8 @@ describe('versions', () => {
let disablePublishURL: AdminUrlUtil
let customIDURL: AdminUrlUtil
let postURL: AdminUrlUtil
let global: AdminUrlUtil
let id: string
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
@@ -282,7 +288,7 @@ describe('versions', () => {
hasText: 'Versions',
})
await versionsTab.waitFor({ state: 'visible' })
const versionsPill = versionsTab.locator('.doc-tab__count--has-count')
const versionsPill = versionsTab.locator('.doc-tab__count')
await versionsPill.waitFor({ state: 'visible' })
const versionCount = versionsTab.locator('.doc-tab__count').first()
await expect(versionCount).toHaveText('11')
@@ -320,7 +326,6 @@ describe('versions', () => {
await saveDocAndAssert(page, '#action-save-draft')
const savedDocURL = page.url()
await page.goto(`${savedDocURL}/versions`)
await page.waitForURL(`${savedDocURL}/versions`)
const row2 = page.locator('tbody .row-2')
const versionID = await row2.locator('.cell-id').textContent()
await page.goto(`${savedDocURL}/versions/${versionID}`)
@@ -612,4 +617,90 @@ describe('versions', () => {
expect(versionsTabUpdated).toBeTruthy()
})
})
describe('Collections - publish specific locale', () => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, localizedCollectionSlug)
global = new AdminUrlUtil(serverURL, localizedGlobalSlug)
})
test('should show publish individual locale dropdown', async () => {
await page.goto(url.create)
const publishOptions = page.locator('.doc-controls__controls .popup')
await expect(publishOptions).toBeVisible()
})
test('should show option to publish current locale', async () => {
await page.goto(url.create)
const publishOptions = page.locator('.doc-controls__controls .popup')
await publishOptions.click()
const publishSpecificLocale = page.locator('.doc-controls__controls .popup__content')
await expect(publishSpecificLocale).toContainText('English')
})
test('should publish specific locale', async () => {
await page.goto(url.create)
await changeLocale(page, 'es')
const textField = page.locator('#field-text')
const status = page.locator('.status__value')
await textField.fill('spanish published')
await saveDocAndAssert(page)
await expect(status).toContainText('Changed')
await textField.fill('spanish draft')
await saveDocAndAssert(page, '#action-save-draft')
await expect(status).toContainText('Changed')
await changeLocale(page, 'en')
await textField.fill('english published')
const publishOptions = page.locator('.doc-controls__controls .popup')
await publishOptions.click()
const publishSpecificLocale = page.locator('.popup-button-list button').first()
await expect(publishSpecificLocale).toContainText('English')
await publishSpecificLocale.click()
id = await page.locator('.id-label').getAttribute('title')
const data = await payload.find({
collection: localizedCollectionSlug,
locale: '*',
where: {
id: { equals: id },
},
})
const publishedDoc = data.docs[0]
expect(publishedDoc.text).toStrictEqual({
en: 'english published',
es: 'spanish published',
})
})
})
describe('Globals - publish individual locale', () => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, localizedGlobalSlug)
})
test('should show publish individual locale dropdown', async () => {
await page.goto(url.global(localizedGlobalSlug))
const publishOptions = page.locator('.doc-controls__controls .popup')
await expect(publishOptions).toBeVisible()
})
test('should show option to publish current locale', async () => {
await page.goto(url.global(localizedGlobalSlug))
const publishOptions = page.locator('.doc-controls__controls .popup')
await publishOptions.click()
const publishSpecificLocale = page.locator('.doc-controls__controls .popup__content')
await expect(publishSpecificLocale).toContainText('English')
})
})
})

View File

@@ -0,0 +1,24 @@
import type { GlobalConfig } from 'payload'
import { localizedGlobalSlug } from '../slugs.js'
const LocalizedGlobal: GlobalConfig = {
slug: localizedGlobalSlug,
fields: [
{
name: 'title',
type: 'text',
localized: true,
},
{
name: 'content',
type: 'text',
localized: true,
},
],
versions: {
drafts: true,
},
}
export default LocalizedGlobal

View File

@@ -12,7 +12,12 @@ import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { clearAndSeedEverything } from './clearAndSeedEverything.js'
import AutosavePosts from './collections/Autosave.js'
import AutosaveGlobal from './globals/Autosave.js'
import { autosaveCollectionSlug, draftCollectionSlug } from './slugs.js'
import {
autosaveCollectionSlug,
draftCollectionSlug,
localizedCollectionSlug,
localizedGlobalSlug,
} from './slugs.js'
let payload: Payload
let restClient: NextRESTClient
@@ -1563,4 +1568,565 @@ describe('Versions', () => {
})
})
})
describe('Publish Individual Locale', () => {
const collection = localizedCollectionSlug
const global = localizedGlobalSlug
describe('Collections', () => {
let postID: string
beforeEach(async () => {
await payload.delete({
collection,
where: {},
})
})
it('should save correct doc data when publishing individual locale', async () => {
// save spanish draft
const draft1 = await payload.create({
collection,
data: {
text: 'Spanish draft',
},
draft: true,
locale: 'es',
})
postID = draft1.id as any
// save english draft
const draft2 = await payload.update({
id: postID,
collection,
data: {
text: 'English draft',
description: 'My English description',
},
draft: true,
locale: 'en',
})
// save german draft
const draft3 = await payload.update({
id: postID,
collection,
data: {
text: 'German draft',
},
draft: true,
locale: 'de',
})
// publish only english
const publishedEN1 = await payload.update({
id: postID,
collection,
data: {
text: 'English published 1',
_status: 'published',
},
draft: false,
locale: 'en',
publishSpecificLocale: 'en',
})
const docWithoutSpanishDraft = await payload.findByID({
collection,
id: postID,
locale: 'all',
})
// We're getting the published version,
// which should not leak any unpublished Spanish content
// and should retain the English fields that were not explicitly
// passed in from publishedEN1
expect(docWithoutSpanishDraft.text.es).toBeUndefined()
expect(docWithoutSpanishDraft.description.en).toStrictEqual('My English description')
const docWithSpanishDraft1 = await payload.findByID({
collection,
id: postID,
locale: 'all',
draft: true,
})
// After updating English via specific locale,
// We should expect to see that Spanish translations were maintained
expect(docWithSpanishDraft1.text.es).toStrictEqual('Spanish draft')
expect(docWithSpanishDraft1.text.en).toStrictEqual('English published 1')
expect(docWithSpanishDraft1.description.en).toStrictEqual('My English description')
const publishedEN2 = await payload.update({
id: postID,
collection,
data: {
text: 'English published 2',
_status: 'published',
},
draft: false,
locale: 'en',
publishSpecificLocale: 'en',
})
const docWithoutSpanishDraft2 = await payload.findByID({
collection,
id: postID,
locale: 'all',
})
// On the second consecutive publish of a specific locale,
// Make sure we maintain draft data that has never been published
// even after two + consecutive publish events
expect(docWithoutSpanishDraft2.text.es).toBeUndefined()
expect(docWithoutSpanishDraft2.text.en).toStrictEqual('English published 2')
expect(docWithoutSpanishDraft2.description.en).toStrictEqual('My English description')
await payload.update({
id: postID,
collection,
data: {
text: 'German draft 1',
_status: 'draft',
},
draft: true,
locale: 'de',
})
const docWithGermanDraft = await payload.findByID({
collection,
id: postID,
locale: 'all',
draft: true,
})
// Make sure we retain the Spanish draft,
// which may be lost when we create a new draft with German.
// Update operation should fetch both draft locales as well as published
// and merge them.
expect(docWithGermanDraft.text.de).toStrictEqual('German draft 1')
expect(docWithGermanDraft.text.es).toStrictEqual('Spanish draft')
expect(docWithGermanDraft.text.en).toStrictEqual('English published 2')
const publishedDE = await payload.update({
id: postID,
collection,
data: {
_status: 'published',
text: 'German published 1',
},
draft: false,
locale: 'de',
publishSpecificLocale: 'de',
})
const publishedENFinal = await payload.update({
id: postID,
collection,
data: {
text: 'English published 3',
_status: 'published',
},
draft: false,
locale: 'en',
publishSpecificLocale: 'en',
})
const finalPublishedNoES = await payload.findByID({
collection,
id: postID,
locale: 'all',
})
expect(finalPublishedNoES.text.de).toStrictEqual('German published 1')
expect(finalPublishedNoES.text.en).toStrictEqual('English published 3')
expect(finalPublishedNoES.text.es).toBeUndefined()
const finalDraft = await payload.findByID({
collection,
id: postID,
locale: 'all',
draft: true,
})
expect(finalDraft.text.de).toStrictEqual('German published 1')
expect(finalDraft.text.en).toStrictEqual('English published 3')
expect(finalDraft.text.es).toStrictEqual('Spanish draft')
const published = await payload.update({
collection,
id: postID,
data: {
_status: 'published',
},
})
const finalPublished = await payload.findByID({
collection,
id: postID,
locale: 'all',
draft: true,
})
expect(finalPublished.text.de).toStrictEqual('German published 1')
expect(finalPublished.text.en).toStrictEqual('English published 3')
expect(finalPublished.text.es).toStrictEqual('Spanish draft')
})
it('should not leak draft data', async () => {
const draft = await payload.create({
collection,
data: {
text: 'Spanish draft',
},
draft: true,
locale: 'es',
})
const published = await payload.update({
id: draft.id,
collection,
data: {
text: 'English publish',
_status: 'published',
},
draft: false,
publishSpecificLocale: 'en',
})
const publishedOnlyEN = await payload.findByID({
collection,
id: draft.id,
locale: 'all',
})
expect(publishedOnlyEN.text.es).toBeUndefined()
expect(publishedOnlyEN.text.en).toStrictEqual('English publish')
})
it('should merge draft data from other locales when publishing all', async () => {
const draft = await payload.create({
collection,
data: {
text: 'Spanish draft',
},
draft: true,
locale: 'es',
})
const published = await payload.update({
id: draft.id,
collection,
data: {
text: 'English publish',
_status: 'published',
},
draft: false,
publishSpecificLocale: 'en',
})
const publishedOnlyEN = await payload.findByID({
collection,
id: draft.id,
locale: 'all',
})
expect(publishedOnlyEN.text.es).toBeUndefined()
expect(publishedOnlyEN.text.en).toStrictEqual('English publish')
const published2 = await payload.update({
id: draft.id,
collection,
data: {
_status: 'published',
},
draft: false,
})
const publishedAll = await payload.findByID({
collection,
id: published2.id,
locale: 'all',
})
expect(publishedAll.text.es).toStrictEqual('Spanish draft')
expect(publishedAll.text.en).toStrictEqual('English publish')
})
it('should publish non-default individual locale', async () => {
const draft = await payload.create({
collection,
data: {
text: 'Spanish draft',
},
draft: true,
locale: 'es',
})
const published = await payload.update({
id: draft.id,
collection,
data: {
text: 'German publish',
_status: 'published',
},
draft: false,
publishSpecificLocale: 'de',
})
const publishedOnlyDE = await payload.findByID({
collection,
id: published.id,
locale: 'all',
})
expect(publishedOnlyDE.text.es).toBeUndefined()
expect(publishedOnlyDE.text.en).toBeUndefined()
expect(publishedOnlyDE.text.de).toStrictEqual('German publish')
})
it('should show correct data in latest version', async () => {
const draft = await payload.create({
collection,
data: {
text: 'Spanish draft',
},
draft: true,
locale: 'es',
})
const published = await payload.update({
id: draft.id,
collection,
data: {
text: 'English publish',
_status: 'published',
},
draft: false,
publishSpecificLocale: 'en',
})
const publishedOnlyEN = await payload.findByID({
collection,
id: published.id,
locale: 'all',
})
expect(publishedOnlyEN.text.es).toBeUndefined()
expect(publishedOnlyEN.text.en).toStrictEqual('English publish')
const allVersions = await payload.findVersions({
collection,
locale: 'all',
})
const versions = allVersions.docs.filter(
(version) => version.parent === published.id && version.snapshot !== true,
)
const latestVersion = versions[0].version
expect(latestVersion.text.es).toBeUndefined()
expect(latestVersion.text.en).toStrictEqual('English publish')
})
})
describe('Globals', () => {
it('should save correct global data when publishing individual locale', async () => {
// publish german
await payload.updateGlobal({
slug: global,
data: {
title: 'German published',
_status: 'published',
},
locale: 'de',
})
// save spanish draft
await payload.updateGlobal({
slug: global,
data: {
title: 'Spanish draft',
content: 'Spanish draft content',
},
draft: true,
locale: 'es',
})
// publish only english
await payload.updateGlobal({
slug: global,
data: {
title: 'Eng published',
_status: 'published',
},
locale: 'en',
publishSpecificLocale: 'en',
})
const globalData = await payload.findGlobal({
slug: global,
locale: 'all',
})
// Expect only previously published data to be present
expect(globalData.title.es).toBeUndefined()
expect(globalData.title.en).toStrictEqual('Eng published')
expect(globalData.title.de).toStrictEqual('German published')
})
it('should not leak draft data', async () => {
// save spanish draft
await payload.updateGlobal({
slug: global,
data: {
title: 'Another spanish draft',
},
draft: true,
locale: 'es',
})
// publish only english
await payload.updateGlobal({
slug: global,
data: {
title: 'Eng published',
_status: 'published',
},
draft: false,
locale: 'en',
publishSpecificLocale: 'en',
})
const globalData = await payload.findGlobal({
slug: global,
locale: 'all',
})
// Expect no draft data to be present
expect(globalData.title.es).toBeUndefined()
expect(globalData.title.en).toStrictEqual('Eng published')
})
it('should merge draft data from other locales when publishing all', async () => {
// save spanish draft
await payload.updateGlobal({
slug: global,
data: {
title: 'Spanish draft',
content: 'Spanish draft content',
},
draft: true,
locale: 'es',
})
// publish only english
await payload.updateGlobal({
slug: global,
data: {
title: 'Eng published',
_status: 'published',
},
locale: 'en',
publishSpecificLocale: 'en',
})
const publishedOnlyEN = await payload.findGlobal({
slug: global,
locale: 'all',
})
expect(publishedOnlyEN.title.es).toBeUndefined()
expect(publishedOnlyEN.title.en).toStrictEqual('Eng published')
await payload.updateGlobal({
slug: global,
data: {
_status: 'published',
},
})
const publishedAll = await payload.findGlobal({
slug: global,
locale: 'all',
})
expect(publishedAll.title.es).toStrictEqual('Spanish draft')
expect(publishedAll.title.en).toStrictEqual('Eng published')
})
it('should publish non-default individual locale', async () => {
// save spanish draft
await payload.updateGlobal({
slug: global,
data: {
title: 'Test span draft',
content: 'Test span draft content',
},
draft: true,
locale: 'es',
})
// publish only german
await payload.updateGlobal({
slug: global,
data: {
title: 'German published',
_status: 'published',
},
locale: 'de',
publishSpecificLocale: 'de',
})
const globalData = await payload.findGlobal({
slug: global,
locale: 'all',
})
// Expect only published data to be present
expect(globalData.title.es).toBeFalsy()
expect(globalData.title.de).toStrictEqual('German published')
})
it('should show correct data in latest version', async () => {
// save spanish draft
await payload.updateGlobal({
slug: global,
data: {
title: 'New spanish draft',
content: 'New spanish draft content',
},
draft: true,
locale: 'es',
})
// publish only english
await payload.updateGlobal({
slug: global,
data: {
title: 'New eng',
_status: 'published',
},
draft: false,
publishSpecificLocale: 'en',
})
const allVersions = await payload.findGlobalVersions({
slug: global,
locale: 'all',
where: {
'version._status': {
equals: 'published',
},
},
})
const versions = allVersions.docs
const latestVersion = versions[0].version
expect(latestVersion.title.es).toBeFalsy()
expect(latestVersion.title.en).toStrictEqual('New eng')
})
})
})
})

View File

@@ -16,6 +16,7 @@ export interface Config {
'autosave-posts': AutosavePost;
'draft-posts': DraftPost;
'draft-with-max-posts': DraftWithMaxPost;
'localized-posts': LocalizedPost;
'version-posts': VersionPost;
'custom-ids': CustomId;
users: User;
@@ -30,8 +31,9 @@ export interface Config {
'draft-global': DraftGlobal;
'draft-with-max-global': DraftWithMaxGlobal;
'disable-publish-global': DisablePublishGlobal;
'localized-global': LocalizedGlobal;
};
locale: 'en' | 'es';
locale: 'en' | 'es' | 'de';
user: User & {
collection: 'users';
};
@@ -148,6 +150,22 @@ export interface DraftWithMaxPost {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localized-posts".
*/
type LocalizedString = {
[k: string]: string;
};
export interface LocalizedPost {
id: string;
text?: string | LocalizedString | null;
description?: string | LocalizedString | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-ids".
@@ -253,6 +271,18 @@ export interface DisablePublishGlobal {
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localized-global".
*/
export interface LocalizedGlobal {
id: string;
title?: string | null;
content?: string | null;
_status?: ('draft' | 'published') | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
@@ -263,6 +293,6 @@ export interface Auth {
declare module 'payload' {
// @ts-ignore
// @ts-ignore
export interface GeneratedTypes extends Config {}
}
}

View File

@@ -25,3 +25,7 @@ export const draftGlobalSlug = 'draft-global'
export const draftWithMaxGlobalSlug = 'draft-with-max-global'
export const globalSlugs = [autoSaveGlobalSlug, draftGlobalSlug]
export const localizedCollectionSlug = 'localized-posts'
export const localizedGlobalSlug = 'localized-global'