feat: adds showSaveDraftButton option to show draft button with autosave enabled (#12150)

This adds a new `showSaveDraftButton` option to the
`versions.drafts.autosave` config for collections and globals.

By default, the "Save as draft" button is hidden when autosave is
enabled. This new option allows the button to remain visible for manual
saves while autosave is active.

Also updates the admin UI logic to conditionally render the button when
this flag is set, and updates the documentation with an example usage.
This commit is contained in:
Patrik
2025-04-17 14:45:10 -04:00
committed by GitHub
parent 17d5168728
commit 34ea6ec14f
9 changed files with 282 additions and 98 deletions

View File

@@ -22,6 +22,7 @@ Collections and Globals both support the same options for configuring autosave.
| Drafts Autosave Options | Description |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `interval` | Define an `interval` in milliseconds to automatically save progress while documents are edited. Document updates are "debounced" at this interval. Defaults to `800`. |
| `showSaveDraftButton` | Set this to `true` to show the "Save as draft" button even while autosave is enabled. Defaults to `false`. |
**Example config with versions, drafts, and autosave enabled:**
@@ -50,9 +51,13 @@ export const Pages: CollectionConfig = {
drafts: {
autosave: true,
// Alternatively, you can specify an `interval`:
// Alternatively, you can specify an object to customize autosave:
// autosave: {
// Define how often the document should be autosaved (in milliseconds)
// interval: 1500,
//
// Show the "Save as draft" button even while autosave is enabled
// showSaveDraftButton: true,
// },
},
},

View File

@@ -6,6 +6,13 @@ export type Autosave = {
* @default 800
*/
interval?: number
/**
* When set to `true`, the "Save as draft" button will be displayed even while autosave is enabled.
* By default, this button is hidden to avoid redundancy with autosave behavior.
*
* @default false
*/
showSaveDraftButton?: boolean
}
export type SchedulePublish = {

View File

@@ -133,9 +133,23 @@ export const DocumentControls: React.FC<{
const unsavedDraftWithValidations =
!id && collectionConfig?.versions?.drafts && collectionConfig.versions?.drafts.validate
const collectionConfigDrafts = collectionConfig?.versions?.drafts
const globalConfigDrafts = globalConfig?.versions?.drafts
const autosaveEnabled =
(collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) ||
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave)
(collectionConfigDrafts && collectionConfigDrafts?.autosave) ||
(globalConfigDrafts && globalConfigDrafts?.autosave)
const collectionAutosaveEnabled = collectionConfigDrafts && collectionConfigDrafts?.autosave
const globalAutosaveEnabled = globalConfigDrafts && globalConfigDrafts?.autosave
const showSaveDraftButton =
(collectionAutosaveEnabled &&
collectionConfigDrafts.autosave !== false &&
collectionConfigDrafts.autosave.showSaveDraftButton === true) ||
(globalAutosaveEnabled &&
globalConfigDrafts.autosave !== false &&
globalConfigDrafts.autosave.showSaveDraftButton === true)
const showCopyToLocale = localization && !collectionConfig?.admin?.disableCopyToLocale
@@ -218,7 +232,9 @@ export const DocumentControls: React.FC<{
<Fragment>
{collectionConfig?.versions?.drafts || globalConfig?.versions?.drafts ? (
<Fragment>
{(unsavedDraftWithValidations || !autosaveEnabled) && (
{(unsavedDraftWithValidations ||
!autosaveEnabled ||
(autosaveEnabled && showSaveDraftButton)) && (
<RenderCustomComponent
CustomComponent={CustomSaveDraftButton}
Fallback={<SaveDraftButton />}

View File

@@ -0,0 +1,32 @@
import type { CollectionConfig } from 'payload'
import { autosaveWithDraftButtonSlug } from '../slugs.js'
const AutosaveWithDraftButtonPosts: CollectionConfig = {
slug: autosaveWithDraftButtonSlug,
labels: {
singular: 'Autosave with Draft Button Post',
plural: 'Autosave with Draft Button Posts',
},
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'subtitle', 'createdAt', '_status'],
},
versions: {
drafts: {
autosave: {
showSaveDraftButton: true,
interval: 1000,
},
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
}
export default AutosaveWithDraftButtonPosts

View File

@@ -4,6 +4,7 @@ const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import AutosavePosts from './collections/Autosave.js'
import AutosaveWithDraftButtonPosts from './collections/AutosaveWithDraftButton.js'
import AutosaveWithValidate from './collections/AutosaveWithValidate.js'
import CustomIDs from './collections/CustomIDs.js'
import { Diff } from './collections/Diff/index.js'
@@ -17,6 +18,7 @@ import Posts from './collections/Posts.js'
import { TextCollection } from './collections/Text.js'
import VersionPosts from './collections/Versions.js'
import AutosaveGlobal from './globals/Autosave.js'
import AutosaveWithDraftButtonGlobal from './globals/AutosaveWithDraftButton.js'
import DisablePublishGlobal from './globals/DisablePublish.js'
import DraftGlobal from './globals/Draft.js'
import DraftWithMaxGlobal from './globals/DraftWithMax.js'
@@ -35,6 +37,7 @@ export default buildConfigWithDefaults({
DisablePublish,
Posts,
AutosavePosts,
AutosaveWithDraftButtonPosts,
AutosaveWithValidate,
DraftPosts,
DraftWithMax,
@@ -46,7 +49,14 @@ export default buildConfigWithDefaults({
TextCollection,
Media,
],
globals: [AutosaveGlobal, DraftGlobal, DraftWithMaxGlobal, DisablePublishGlobal, LocalizedGlobal],
globals: [
AutosaveGlobal,
AutosaveWithDraftButtonGlobal,
DraftGlobal,
DraftWithMaxGlobal,
DisablePublishGlobal,
LocalizedGlobal,
],
indexSortableFields: true,
localization: {
defaultLocale: 'en',

View File

@@ -48,6 +48,8 @@ import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import {
autosaveCollectionSlug,
autoSaveGlobalSlug,
autosaveWithDraftButtonGlobal,
autosaveWithDraftButtonSlug,
autosaveWithValidateCollectionSlug,
customIDSlug,
diffCollectionSlug,
@@ -78,6 +80,7 @@ describe('Versions', () => {
let url: AdminUrlUtil
let serverURL: string
let autosaveURL: AdminUrlUtil
let autosaveWithDraftButtonURL: AdminUrlUtil
let autosaveWithValidateURL: AdminUrlUtil
let draftWithValidateURL: AdminUrlUtil
let disablePublishURL: AdminUrlUtil
@@ -116,6 +119,7 @@ describe('Versions', () => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, draftCollectionSlug)
autosaveURL = new AdminUrlUtil(serverURL, autosaveCollectionSlug)
autosaveWithDraftButtonURL = new AdminUrlUtil(serverURL, autosaveWithDraftButtonSlug)
autosaveWithValidateURL = new AdminUrlUtil(serverURL, autosaveWithValidateCollectionSlug)
disablePublishURL = new AdminUrlUtil(serverURL, disablePublishSlug)
customIDURL = new AdminUrlUtil(serverURL, customIDSlug)
@@ -201,78 +205,6 @@ describe('Versions', () => {
await expect(page.locator('#field-title')).toHaveValue('v1')
})
test('should show global versions view level action in globals versions view', async () => {
const global = new AdminUrlUtil(serverURL, draftGlobalSlug)
await page.goto(`${global.global(draftGlobalSlug)}/versions`)
await expect(page.locator('.app-header .global-versions-button')).toHaveCount(1)
})
// TODO: Check versions/:version-id view for collections / globals
test('global — has versions tab', async () => {
const global = new AdminUrlUtil(serverURL, draftGlobalSlug)
await page.goto(global.global(draftGlobalSlug))
const docURL = page.url()
const pathname = new URL(docURL).pathname
const versionsTab = page.locator('.doc-tab', {
hasText: 'Versions',
})
await versionsTab.waitFor({ state: 'visible' })
expect(versionsTab).toBeTruthy()
const href = versionsTab.locator('a').first()
await expect(href).toHaveAttribute('href', `${pathname}/versions`)
})
test('global — respects max number of versions', async () => {
await payload.updateGlobal({
slug: draftWithMaxGlobalSlug,
data: {
title: 'initial title',
},
})
const global = new AdminUrlUtil(serverURL, draftWithMaxGlobalSlug)
await page.goto(global.global(draftWithMaxGlobalSlug))
const titleFieldInitial = page.locator('#field-title')
await titleFieldInitial.fill('updated title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldInitial).toHaveValue('updated title')
const versionsTab = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTab.waitFor({ state: 'visible' })
expect(versionsTab).toBeTruthy()
const titleFieldUpdated = page.locator('#field-title')
await titleFieldUpdated.fill('latest title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldUpdated).toHaveValue('latest title')
const versionsTabUpdated = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTabUpdated.waitFor({ state: 'visible' })
expect(versionsTabUpdated).toBeTruthy()
})
test('global — has versions route', async () => {
const global = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
const versionsURL = `${global.global(autoSaveGlobalSlug)}/versions`
await page.goto(versionsURL)
await expect(() => {
expect(page.url()).toMatch(/\/versions/)
}).toPass({ timeout: 10000, intervals: [100] })
})
test('collection - should autosave', async () => {
await page.goto(autosaveURL.create)
await page.locator('#field-title').fill('autosave title')
@@ -309,6 +241,16 @@ describe('Versions', () => {
await expect(drawer.locator('.id-label')).toBeVisible()
})
test('collection - should show "save as draft" button when showSaveDraftButton is true', async () => {
await page.goto(autosaveWithDraftButtonURL.create)
await expect(page.locator('#action-save-draft')).toBeVisible()
})
test('collection - should not show "save as draft" button when showSaveDraftButton is false', async () => {
await page.goto(autosaveURL.create)
await expect(page.locator('#action-save-draft')).toBeHidden()
})
test('collection - autosave - should not create duplicates when clicking Create new', async () => {
// This test checks that when we click "Create new" in the list view, it only creates 1 extra document and not more
const { totalDocs: initialDocsCount } = await payload.find({
@@ -402,17 +344,6 @@ describe('Versions', () => {
await expect(newUpdatedAt).not.toHaveText(initialUpdatedAt)
})
test('global - should autosave', async () => {
const url = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
await page.goto(url.global(autoSaveGlobalSlug))
const titleField = page.locator('#field-title')
await titleField.fill('global title')
await waitForAutoSaveToRunAndComplete(page)
await expect(titleField).toHaveValue('global title')
await page.goto(url.global(autoSaveGlobalSlug))
await expect(page.locator('#field-title')).toHaveValue('global title')
})
test('should retain localized data during autosave', async () => {
const en = 'en'
const es = 'es'
@@ -519,12 +450,6 @@ describe('Versions', () => {
await expect(page.locator('#field-title')).toHaveValue('title')
})
test('globals — should hide publish button when access control prevents update', async () => {
const url = new AdminUrlUtil(serverURL, disablePublishGlobalSlug)
await page.goto(url.global(disablePublishGlobalSlug))
await expect(page.locator('#action-save')).not.toBeAttached()
})
test('collections — should hide publish button when access control prevents create', async () => {
await page.goto(disablePublishURL.create)
await expect(page.locator('#action-save')).not.toBeAttached()
@@ -652,6 +577,107 @@ describe('Versions', () => {
})
})
describe('draft globals', () => {
test('should show global versions view level action in globals versions view', async () => {
const global = new AdminUrlUtil(serverURL, draftGlobalSlug)
await page.goto(`${global.global(draftGlobalSlug)}/versions`)
await expect(page.locator('.app-header .global-versions-button')).toHaveCount(1)
})
test('global — has versions tab', async () => {
const global = new AdminUrlUtil(serverURL, draftGlobalSlug)
await page.goto(global.global(draftGlobalSlug))
const docURL = page.url()
const pathname = new URL(docURL).pathname
const versionsTab = page.locator('.doc-tab', {
hasText: 'Versions',
})
await versionsTab.waitFor({ state: 'visible' })
expect(versionsTab).toBeTruthy()
const href = versionsTab.locator('a').first()
await expect(href).toHaveAttribute('href', `${pathname}/versions`)
})
test('global — respects max number of versions', async () => {
await payload.updateGlobal({
slug: draftWithMaxGlobalSlug,
data: {
title: 'initial title',
},
})
const global = new AdminUrlUtil(serverURL, draftWithMaxGlobalSlug)
await page.goto(global.global(draftWithMaxGlobalSlug))
const titleFieldInitial = page.locator('#field-title')
await titleFieldInitial.fill('updated title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldInitial).toHaveValue('updated title')
const versionsTab = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTab.waitFor({ state: 'visible' })
expect(versionsTab).toBeTruthy()
const titleFieldUpdated = page.locator('#field-title')
await titleFieldUpdated.fill('latest title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldUpdated).toHaveValue('latest title')
const versionsTabUpdated = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTabUpdated.waitFor({ state: 'visible' })
expect(versionsTabUpdated).toBeTruthy()
})
test('global — has versions route', async () => {
const global = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
const versionsURL = `${global.global(autoSaveGlobalSlug)}/versions`
await page.goto(versionsURL)
await expect(() => {
expect(page.url()).toMatch(/\/versions/)
}).toPass({ timeout: 10000, intervals: [100] })
})
test('global - should show "save as draft" button when showSaveDraftButton is true', async () => {
const url = new AdminUrlUtil(serverURL, autosaveWithDraftButtonGlobal)
await page.goto(url.global(autosaveWithDraftButtonGlobal))
await expect(page.locator('#action-save-draft')).toBeVisible()
})
test('global - should not show "save as draft" button when showSaveDraftButton is false', async () => {
const url = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
await page.goto(url.global(autoSaveGlobalSlug))
await expect(page.locator('#action-save-draft')).toBeHidden()
})
test('global - should autosave', async () => {
const url = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
await page.goto(url.global(autoSaveGlobalSlug))
const titleField = page.locator('#field-title')
await titleField.fill('global title')
await waitForAutoSaveToRunAndComplete(page)
await expect(titleField).toHaveValue('global title')
await page.goto(url.global(autoSaveGlobalSlug))
await expect(page.locator('#field-title')).toHaveValue('global title')
})
test('globals — should hide publish button when access control prevents update', async () => {
const url = new AdminUrlUtil(serverURL, disablePublishGlobalSlug)
await page.goto(url.global(disablePublishGlobalSlug))
await expect(page.locator('#action-save')).not.toBeAttached()
})
})
describe('Scheduled publish', () => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, draftCollectionSlug)

View File

@@ -0,0 +1,26 @@
import type { GlobalConfig } from 'payload'
import { autosaveWithDraftButtonGlobal } from '../slugs.js'
const AutosaveWithDraftButtonGlobal: GlobalConfig = {
slug: autosaveWithDraftButtonGlobal,
fields: [
{
name: 'title',
type: 'text',
localized: true,
required: true,
},
],
label: 'Autosave with Draft Button Global',
versions: {
drafts: {
autosave: {
showSaveDraftButton: true,
interval: 1000,
},
},
},
}
export default AutosaveWithDraftButtonGlobal

View File

@@ -70,6 +70,7 @@ export interface Config {
'disable-publish': DisablePublish;
posts: Post;
'autosave-posts': AutosavePost;
'autosave-with-draft-button-posts': AutosaveWithDraftButtonPost;
'autosave-with-validate-posts': AutosaveWithValidatePost;
'draft-posts': DraftPost;
'draft-with-max-posts': DraftWithMaxPost;
@@ -91,6 +92,7 @@ export interface Config {
'disable-publish': DisablePublishSelect<false> | DisablePublishSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
'autosave-posts': AutosavePostsSelect<false> | AutosavePostsSelect<true>;
'autosave-with-draft-button-posts': AutosaveWithDraftButtonPostsSelect<false> | AutosaveWithDraftButtonPostsSelect<true>;
'autosave-with-validate-posts': AutosaveWithValidatePostsSelect<false> | AutosaveWithValidatePostsSelect<true>;
'draft-posts': DraftPostsSelect<false> | DraftPostsSelect<true>;
'draft-with-max-posts': DraftWithMaxPostsSelect<false> | DraftWithMaxPostsSelect<true>;
@@ -112,6 +114,7 @@ export interface Config {
};
globals: {
'autosave-global': AutosaveGlobal;
'autosave-with-draft-button-global': AutosaveWithDraftButtonGlobal;
'draft-global': DraftGlobal;
'draft-with-max-global': DraftWithMaxGlobal;
'disable-publish-global': DisablePublishGlobal;
@@ -119,6 +122,7 @@ export interface Config {
};
globalsSelect: {
'autosave-global': AutosaveGlobalSelect<false> | AutosaveGlobalSelect<true>;
'autosave-with-draft-button-global': AutosaveWithDraftButtonGlobalSelect<false> | AutosaveWithDraftButtonGlobalSelect<true>;
'draft-global': DraftGlobalSelect<false> | DraftGlobalSelect<true>;
'draft-with-max-global': DraftWithMaxGlobalSelect<false> | DraftWithMaxGlobalSelect<true>;
'disable-publish-global': DisablePublishGlobalSelect<false> | DisablePublishGlobalSelect<true>;
@@ -228,6 +232,17 @@ export interface DraftPost {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-draft-button-posts".
*/
export interface AutosaveWithDraftButtonPost {
id: string;
title: string;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-validate-posts".
@@ -554,6 +569,10 @@ export interface PayloadLockedDocument {
relationTo: 'autosave-posts';
value: string | AutosavePost;
} | null)
| ({
relationTo: 'autosave-with-draft-button-posts';
value: string | AutosaveWithDraftButtonPost;
} | null)
| ({
relationTo: 'autosave-with-validate-posts';
value: string | AutosaveWithValidatePost;
@@ -676,6 +695,16 @@ export interface AutosavePostsSelect<T extends boolean = true> {
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-draft-button-posts_select".
*/
export interface AutosaveWithDraftButtonPostsSelect<T extends boolean = true> {
title?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-validate-posts_select".
@@ -973,6 +1002,17 @@ export interface AutosaveGlobal {
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-draft-button-global".
*/
export interface AutosaveWithDraftButtonGlobal {
id: string;
title: string;
_status?: ('draft' | 'published') | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "draft-global".
@@ -1029,6 +1069,17 @@ export interface AutosaveGlobalSelect<T extends boolean = true> {
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-with-draft-button-global_select".
*/
export interface AutosaveWithDraftButtonGlobalSelect<T extends boolean = true> {
title?: T;
_status?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "draft-global_select".
@@ -1082,10 +1133,15 @@ export interface TaskSchedulePublish {
input: {
type?: ('publish' | 'unpublish') | null;
locale?: string | null;
doc?: {
relationTo: 'draft-posts';
value: string | DraftPost;
} | null;
doc?:
| ({
relationTo: 'autosave-posts';
value: string | AutosavePost;
} | null)
| ({
relationTo: 'draft-posts';
value: string | DraftPost;
} | null);
global?: 'draft-global' | null;
user?: (string | null) | User;
};

View File

@@ -1,5 +1,7 @@
export const autosaveCollectionSlug = 'autosave-posts'
export const autosaveWithDraftButtonSlug = 'autosave-with-draft-button-posts'
export const autosaveWithValidateCollectionSlug = 'autosave-with-validate-posts'
export const customIDSlug = 'custom-ids'
@@ -33,7 +35,11 @@ export const collectionSlugs = [
]
export const autoSaveGlobalSlug = 'autosave-global'
export const autosaveWithDraftButtonGlobal = 'autosave-with-draft-button-global'
export const draftGlobalSlug = 'draft-global'
export const draftWithMaxGlobalSlug = 'draft-with-max-global'
export const globalSlugs = [autoSaveGlobalSlug, draftGlobalSlug]