diff --git a/docs/versions/autosave.mdx b/docs/versions/autosave.mdx index 077c473259..5d1b9d6a73 100644 --- a/docs/versions/autosave.mdx +++ b/docs/versions/autosave.mdx @@ -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, // }, }, }, diff --git a/packages/payload/src/versions/types.ts b/packages/payload/src/versions/types.ts index 43c0d21854..efbc4e3c95 100644 --- a/packages/payload/src/versions/types.ts +++ b/packages/payload/src/versions/types.ts @@ -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 = { diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx index 0e99c0dcd5..2c0a6ed3e5 100644 --- a/packages/ui/src/elements/DocumentControls/index.tsx +++ b/packages/ui/src/elements/DocumentControls/index.tsx @@ -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<{ {collectionConfig?.versions?.drafts || globalConfig?.versions?.drafts ? ( - {(unsavedDraftWithValidations || !autosaveEnabled) && ( + {(unsavedDraftWithValidations || + !autosaveEnabled || + (autosaveEnabled && showSaveDraftButton)) && ( } diff --git a/test/versions/collections/AutosaveWithDraftButton.ts b/test/versions/collections/AutosaveWithDraftButton.ts new file mode 100644 index 0000000000..3fc719a78d --- /dev/null +++ b/test/versions/collections/AutosaveWithDraftButton.ts @@ -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 diff --git a/test/versions/config.ts b/test/versions/config.ts index 1b23833611..36fdb76728 100644 --- a/test/versions/config.ts +++ b/test/versions/config.ts @@ -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', diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index e5d9e7cd1a..2798fb52cd 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -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) diff --git a/test/versions/globals/AutosaveWithDraftButton.ts b/test/versions/globals/AutosaveWithDraftButton.ts new file mode 100644 index 0000000000..5d8f1372a8 --- /dev/null +++ b/test/versions/globals/AutosaveWithDraftButton.ts @@ -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 diff --git a/test/versions/payload-types.ts b/test/versions/payload-types.ts index 6e5e4fc8c6..c016d16822 100644 --- a/test/versions/payload-types.ts +++ b/test/versions/payload-types.ts @@ -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 | DisablePublishSelect; posts: PostsSelect | PostsSelect; 'autosave-posts': AutosavePostsSelect | AutosavePostsSelect; + 'autosave-with-draft-button-posts': AutosaveWithDraftButtonPostsSelect | AutosaveWithDraftButtonPostsSelect; 'autosave-with-validate-posts': AutosaveWithValidatePostsSelect | AutosaveWithValidatePostsSelect; 'draft-posts': DraftPostsSelect | DraftPostsSelect; 'draft-with-max-posts': DraftWithMaxPostsSelect | DraftWithMaxPostsSelect; @@ -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 | AutosaveGlobalSelect; + 'autosave-with-draft-button-global': AutosaveWithDraftButtonGlobalSelect | AutosaveWithDraftButtonGlobalSelect; 'draft-global': DraftGlobalSelect | DraftGlobalSelect; 'draft-with-max-global': DraftWithMaxGlobalSelect | DraftWithMaxGlobalSelect; 'disable-publish-global': DisablePublishGlobalSelect | DisablePublishGlobalSelect; @@ -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 { 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 { + 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 { 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 { + 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; }; diff --git a/test/versions/slugs.ts b/test/versions/slugs.ts index 2dccdb8b65..5807c381b5 100644 --- a/test/versions/slugs.ts +++ b/test/versions/slugs.ts @@ -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]