fix(plugin-multi-tenant): autosave global documents not rendering (#13552)

Fixes https://github.com/payloadcms/payload/issues/13507

When enabling autosave on global multi-tenant documents, the page would
not always render - more noticeably with smaller autosave intervals.
This commit is contained in:
Jarrod Flesch
2025-08-22 12:47:13 -04:00
committed by GitHub
parent 5c16443431
commit 409dd56f90
8 changed files with 238 additions and 55 deletions

View File

@@ -25,7 +25,7 @@ type Args = {
view: ViewTypes
}
export async function getGlobalViewRedirect({
slug,
slug: collectionSlug,
basePath,
docID,
headers,
@@ -64,45 +64,67 @@ export async function getGlobalViewRedirect({
tenant = tenantOptions[0]?.value || null
}
try {
const { docs } = await payload.find({
collection: slug,
depth: 0,
limit: 1,
overrideAccess: false,
pagination: false,
user,
where: {
[tenantFieldName]: {
equals: tenant,
if (tenant) {
try {
const globalTenantDocQuery = await payload.find({
collection: collectionSlug,
depth: 0,
limit: 1,
pagination: false,
select: {
id: true,
},
},
})
where: {
[tenantFieldName]: {
equals: tenant,
},
},
})
const tenantDocID = docs?.[0]?.id
const globalTenantDocID = globalTenantDocQuery?.docs?.[0]?.id
if (view === 'document') {
if (docID && !tenantDocID) {
// viewing a document with an id but does not match the selected tenant, redirect to create route
redirectRoute = `/collections/${slug}/create`
} else if (tenantDocID && docID !== tenantDocID) {
// tenant document already exists but does not match current route doc ID, redirect to matching tenant doc
redirectRoute = `/collections/${slug}/${tenantDocID}`
}
} else if (view === 'list') {
if (tenantDocID) {
// tenant document exists, redirect to edit view
redirectRoute = `/collections/${slug}/${tenantDocID}`
} else {
// tenant document does not exist, redirect to create route
redirectRoute = `/collections/${slug}/create`
if (view === 'document') {
// global tenant document edit view
if (globalTenantDocID && docID !== globalTenantDocID) {
// tenant document already exists but does not match current route docID
// redirect to matching tenant docID from query
redirectRoute = `/collections/${collectionSlug}/${globalTenantDocID}`
} else if (docID && !globalTenantDocID) {
// a docID was found in the route but no global document with this tenant exists
// so we need to generate a redirect to the create route
redirectRoute = await generateCreateRedirect({
collectionSlug,
payload,
tenantID: tenant,
})
}
} else if (view === 'list') {
// global tenant document list view
if (globalTenantDocID) {
// tenant document exists, redirect from list view to the document edit view
redirectRoute = `/collections/${collectionSlug}/${globalTenantDocID}`
} else {
// no matching document was found for the current tenant
// so we need to generate a redirect to the create route
redirectRoute = await generateCreateRedirect({
collectionSlug,
payload,
tenantID: tenant,
})
}
}
} catch (e: unknown) {
const prefix = `${e && typeof e === 'object' && 'message' in e && typeof e.message === 'string' ? `${e.message} - ` : ''}`
payload.logger.error(e, `${prefix}Multi Tenant Redirect Error`)
}
} catch (e: unknown) {
payload.logger.error(
e,
`${typeof e === 'object' && e && 'message' in e ? `e?.message - ` : ''}Multi Tenant Redirect Error`,
)
} else {
// no tenants were found, redirect to the admin view
return formatAdminURL({
adminRoute: payload.config.routes.admin,
basePath,
path: '',
serverURL: payload.config.serverURL,
})
}
if (redirectRoute) {
@@ -114,5 +136,57 @@ export async function getGlobalViewRedirect({
})
}
// no redirect is needed
// the current route is valid
return undefined
}
type GenerateCreateArgs = {
collectionSlug: string
payload: Payload
tenantID: number | string
}
/**
* Generate a redirect URL for creating a new document in a multi-tenant collection.
*
* If autosave is enabled on the collection, we need to create the document and then redirect to it.
* Otherwise we can redirect to the default create route.
*/
async function generateCreateRedirect({
collectionSlug,
payload,
tenantID,
}: GenerateCreateArgs): Promise<`/${string}` | undefined> {
const collection = payload.collections[collectionSlug]
if (
collection?.config.versions?.drafts &&
typeof collection.config.versions.drafts === 'object' &&
collection.config.versions.drafts.autosave
) {
// Autosave is enabled, create a document first
try {
const doc = await payload.create({
collection: collectionSlug,
data: {
tenant: tenantID,
},
depth: 0,
draft: true,
select: {
id: true,
},
})
return `/collections/${collectionSlug}/${doc.id}`
} catch (error) {
payload.logger.error(
error,
`Error creating autosave global multi tenant document for ${collectionSlug}`,
)
}
return '/'
}
// Autosave is not enabled, redirect to default create route
return `/collections/${collectionSlug}/create`
}

View File

@@ -35,7 +35,7 @@ export const getTenantOptions = async ({
overrideAccess: false,
select: {
[useAsTitle]: true,
...(isOrderable ? { _order: true } : {}),
...(isOrderable && { _order: true }),
},
sort: isOrderable ? '_order' : useAsTitle,
user,

View File

@@ -96,22 +96,26 @@ async function selectOption({
type GetSelectInputValueFunction = <TMultiSelect = true>(args: {
multiSelect: TMultiSelect
selectLocator: Locator
valueLabelClass?: string
}) => Promise<TMultiSelect extends true ? string[] : string | undefined>
export const getSelectInputValue: GetSelectInputValueFunction = async ({
selectLocator,
multiSelect = false,
valueLabelClass,
}) => {
if (multiSelect) {
// For multi-select, get all selected options
const selectedOptions = await selectLocator
.locator('.multi-value-label__text')
.locator(valueLabelClass || '.multi-value-label__text')
.allTextContents()
return selectedOptions || []
}
// For single-select, get the selected value
const singleValue = await selectLocator.locator('.react-select--single-value').textContent()
const singleValue = await selectLocator
.locator(valueLabelClass || '.react-select--single-value')
.textContent()
return (singleValue ?? undefined) as any
}

View File

@@ -0,0 +1,30 @@
import type { CollectionConfig } from 'payload'
import { autosaveGlobalSlug } from '../shared.js'
export const AutosaveGlobal: CollectionConfig = {
slug: autosaveGlobalSlug,
admin: {
useAsTitle: 'title',
group: 'Tenant Globals',
},
fields: [
{
name: 'title',
label: 'Title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
},
],
versions: {
drafts: {
autosave: {
interval: 100,
},
},
},
}

View File

@@ -7,15 +7,16 @@ const dirname = path.dirname(filename)
import type { Config as ConfigType } from './payload-types.js'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { AutosaveGlobal } from './collections/AutosaveGlobal.js'
import { Menu } from './collections/Menu.js'
import { MenuItems } from './collections/MenuItems.js'
import { Tenants } from './collections/Tenants.js'
import { Users } from './collections/Users/index.js'
import { seed } from './seed/index.js'
import { menuItemsSlug, menuSlug } from './shared.js'
import { autosaveGlobalSlug, menuItemsSlug, menuSlug } from './shared.js'
export default buildConfigWithDefaults({
collections: [Tenants, Users, MenuItems, Menu],
collections: [Tenants, Users, MenuItems, Menu, AutosaveGlobal],
admin: {
autoLogin: false,
importMap: {
@@ -44,6 +45,9 @@ export default buildConfigWithDefaults({
[menuSlug]: {
isGlobal: true,
},
[autosaveGlobalSlug]: {
isGlobal: true,
},
},
i18n: {
translations: {

View File

@@ -28,7 +28,7 @@ import { reInitializeDB } from '../helpers/reInitializeDB.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { credentials } from './credentials.js'
import { seed } from './seed/index.js'
import { menuItemsSlug, menuSlug, tenantsSlug, usersSlug } from './shared.js'
import { autosaveGlobalSlug, menuItemsSlug, menuSlug, tenantsSlug, usersSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -37,6 +37,7 @@ test.describe('Multi Tenant', () => {
let page: Page
let serverURL: string
let globalMenuURL: AdminUrlUtil
let autosaveGlobalURL: AdminUrlUtil
let menuItemsURL: AdminUrlUtil
let usersURL: AdminUrlUtil
let tenantsURL: AdminUrlUtil
@@ -50,6 +51,7 @@ test.describe('Multi Tenant', () => {
menuItemsURL = new AdminUrlUtil(serverURL, menuItemsSlug)
usersURL = new AdminUrlUtil(serverURL, usersSlug)
tenantsURL = new AdminUrlUtil(serverURL, tenantsSlug)
autosaveGlobalURL = new AdminUrlUtil(serverURL, autosaveGlobalSlug)
const context = await browser.newContext()
page = await context.newPage()
@@ -74,7 +76,7 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await clearTenant({ page })
await clearGlobalTenant({ page })
await page.goto(tenantsURL.list)
@@ -129,7 +131,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(menuItemsURL.list)
await clearTenant({ page })
await clearGlobalTenant({ page })
await expect(
page.locator('.collection-list .table .cell-name', {
@@ -174,7 +176,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(menuItemsURL.list)
await clearTenant({ page })
await clearGlobalTenant({ page })
await expect(
page.locator('.collection-list .table .cell-name', {
@@ -190,7 +192,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(menuItemsURL.list)
await clearTenant({ page })
await clearGlobalTenant({ page })
await expect(
page.locator('.collection-list .table .cell-name', {
@@ -209,7 +211,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(usersURL.list)
await clearTenant({ page })
await clearGlobalTenant({ page })
await expect(
page.locator('.collection-list .table .cell-email', {
@@ -269,7 +271,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(menuItemsURL.list)
await clearTenant({ page })
await clearGlobalTenant({ page })
await goToListDoc({
page,
@@ -297,7 +299,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(menuItemsURL.list)
await clearTenant({ page })
await clearGlobalTenant({ page })
await goToListDoc({
page,
@@ -306,7 +308,7 @@ test.describe('Multi Tenant', () => {
urlUtil: menuItemsURL,
})
await selecteDocumentTenant({
await selectDocumentTenant({
page,
tenant: 'Steel Cat',
})
@@ -324,7 +326,7 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(menuItemsURL.create)
await selecteDocumentTenant({
await selectDocumentTenant({
page,
tenant: 'Blue Dog',
})
@@ -418,6 +420,24 @@ test.describe('Multi Tenant', () => {
})
.toBe('Steel Cat')
})
test('should navigate to globals with autosave enabled', async () => {
await loginClientSide({
page,
serverURL,
data: credentials.admin,
})
await page.goto(tenantsURL.list)
await clearGlobalTenant({ page })
await page.goto(autosaveGlobalURL.list)
await expect(page.locator('.doc-header__title')).toBeVisible()
const globalTenant = await getGlobalTenant({ page })
await expect
.poll(async () => {
return await getDocumentTenant({ page })
})
.toBe(globalTenant)
})
})
test.describe('Tenant Selector', () => {
@@ -499,7 +519,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(tenantsURL.list)
await clearTenant({ page })
await clearGlobalTenant({ page })
await expect(
page.locator('.collection-list .table .cell-name', {
@@ -586,6 +606,24 @@ test.describe('Multi Tenant', () => {
/**
* Helper Functions
*/
async function getGlobalTenant({ page }: { page: Page }): Promise<string | undefined> {
await openNav(page)
return await getSelectInputValue<false>({
selectLocator: page.locator('.tenant-selector'),
multiSelect: false,
})
}
async function getDocumentTenant({ page }: { page: Page }): Promise<string | undefined> {
await openNav(page)
return await getSelectInputValue<false>({
selectLocator: page.locator('#field-tenant'),
multiSelect: false,
valueLabelClass: '.relationship--single-value',
})
}
async function getTenantOptions({ page }: { page: Page }): Promise<string[]> {
await openNav(page)
return await getSelectInputOptions({
@@ -593,7 +631,7 @@ async function getTenantOptions({ page }: { page: Page }): Promise<string[]> {
})
}
async function selecteDocumentTenant({
async function selectDocumentTenant({
page,
tenant,
}: {
@@ -617,7 +655,7 @@ async function selectTenant({ page, tenant }: { page: Page; tenant: string }): P
})
}
async function clearTenant({ page }: { page: Page }): Promise<void> {
async function clearGlobalTenant({ page }: { page: Page }): Promise<void> {
await openNav(page)
return clearSelectInput({
selectLocator: page.locator('.tenant-selector'),

View File

@@ -71,6 +71,7 @@ export interface Config {
users: User;
'food-items': FoodItem;
'food-menu': FoodMenu;
'autosave-global': AutosaveGlobal;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -85,6 +86,7 @@ export interface Config {
users: UsersSelect<false> | UsersSelect<true>;
'food-items': FoodItemsSelect<false> | FoodItemsSelect<true>;
'food-menu': FoodMenuSelect<false> | FoodMenuSelect<true>;
'autosave-global': AutosaveGlobalSelect<false> | AutosaveGlobalSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -175,7 +177,7 @@ export interface User {
*/
export interface FoodItem {
id: string;
tenant: string | Tenant;
tenant?: (string | null) | Tenant;
name: string;
content?: {
root: {
@@ -201,7 +203,7 @@ export interface FoodItem {
*/
export interface FoodMenu {
id: string;
tenant: string | Tenant;
tenant?: (string | null) | Tenant;
title: string;
description?: string | null;
menuItems?:
@@ -217,6 +219,19 @@ export interface FoodMenu {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-global".
*/
export interface AutosaveGlobal {
id: string;
tenant?: (string | null) | Tenant;
title: string;
description?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -239,6 +254,10 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'food-menu';
value: string | FoodMenu;
} | null)
| ({
relationTo: 'autosave-global';
value: string | AutosaveGlobal;
} | null);
globalSlug?: string | null;
user: {
@@ -352,6 +371,18 @@ export interface FoodMenuSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-global_select".
*/
export interface AutosaveGlobalSelect<T extends boolean = true> {
tenant?: T;
title?: T;
description?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -5,3 +5,5 @@ export const usersSlug = 'users'
export const menuItemsSlug = 'food-items'
export const menuSlug = 'food-menu'
export const autosaveGlobalSlug = 'autosave-global'