From 09202536dbac52df5b42767c552ffc0b5f404768 Mon Sep 17 00:00:00 2001
From: Sarah Norris <1645628+mikachan@users.noreply.github.com>
Date: Fri, 13 Dec 2024 17:29:42 +0000
Subject: [PATCH] Pages: Add "Set as posts page" action (#67650)

* Move getItemTitle to its own file

* Add unset homepage action

* Add unset as posts page action

* Add set as posts page action

* Update homepage action tests

* Rename unset options to reset

* Reword posts page reset notice

* Ensure Move to trash is always at end of list

* Update packages/editor/src/components/post-actions/actions.js

Co-authored-by: Aki Hamano <54422211+t-hamano@users.noreply.github.com>

* Update packages/editor/src/components/post-actions/reset-homepage.js

Co-authored-by: Aki Hamano <54422211+t-hamano@users.noreply.github.com>

* Remove getItemTitle from utils index.js

* Remove reset actions

* Slight refactor to modal warning in set as posts page action

* Remove use of saveEditedEntityRecord

* Check for currentPostsPage before setting modalwarning

* Add full stop to action success notices

---------

Co-authored-by: Aki Hamano <54422211+t-hamano@users.noreply.github.com>
Co-authored-by: mikachan <mikachan@git.wordpress.org>
Co-authored-by: t-hamano <wildworks@git.wordpress.org>
Co-authored-by: oandregal <oandregal@git.wordpress.org>
Co-authored-by: jameskoster <jameskoster@git.wordpress.org>
Co-authored-by: jasmussen <joen@git.wordpress.org>
Co-authored-by: paaljoachim <paaljoachim@git.wordpress.org>
Co-authored-by: youknowriad <youknowriad@git.wordpress.org>
---
 .../src/components/post-actions/actions.js    |  16 +-
 .../post-actions/set-as-homepage.js           |  38 +----
 .../post-actions/set-as-posts-page.js         | 158 ++++++++++++++++++
 packages/editor/src/utils/get-item-title.js   |  25 +++
 .../site-editor/homepage-settings.spec.js     |  56 ++++++-
 5 files changed, 251 insertions(+), 42 deletions(-)
 create mode 100644 packages/editor/src/components/post-actions/set-as-posts-page.js
 create mode 100644 packages/editor/src/utils/get-item-title.js

diff --git a/packages/editor/src/components/post-actions/actions.js b/packages/editor/src/components/post-actions/actions.js
index 808134ea969a11..023b93d31bb511 100644
--- a/packages/editor/src/components/post-actions/actions.js
+++ b/packages/editor/src/components/post-actions/actions.js
@@ -11,6 +11,7 @@ import { store as coreStore } from '@wordpress/core-data';
 import { store as editorStore } from '../../store';
 import { unlock } from '../../lock-unlock';
 import { useSetAsHomepageAction } from './set-as-homepage';
+import { useSetAsPostsPageAction } from './set-as-posts-page';
 
 export function usePostActions( { postType, onActionPerformed, context } ) {
 	const { defaultActions } = useSelect(
@@ -43,7 +44,8 @@ export function usePostActions( { postType, onActionPerformed, context } ) {
 	);
 
 	const setAsHomepageAction = useSetAsHomepageAction();
-	const shouldShowSetAsHomepageAction =
+	const setAsPostsPageAction = useSetAsPostsPageAction();
+	const shouldShowHomepageActions =
 		canManageOptions && ! hasFrontPageTemplate;
 
 	const { registerPostTypeSchema } = unlock( useDispatch( editorStore ) );
@@ -53,10 +55,15 @@ export function usePostActions( { postType, onActionPerformed, context } ) {
 
 	return useMemo( () => {
 		let actions = [ ...defaultActions ];
-		if ( shouldShowSetAsHomepageAction ) {
-			actions.push( setAsHomepageAction );
+		if ( shouldShowHomepageActions ) {
+			actions.push( setAsHomepageAction, setAsPostsPageAction );
 		}
 
+		// Ensure "Move to trash" is always the last action.
+		actions = actions.sort( ( a, b ) =>
+			b.id === 'move-to-trash' ? -1 : 0
+		);
+
 		// Filter actions based on provided context. If not provided
 		// all actions are returned. We'll have a single entry for getting the actions
 		// and the consumer should provide the context to filter the actions, if needed.
@@ -123,6 +130,7 @@ export function usePostActions( { postType, onActionPerformed, context } ) {
 		defaultActions,
 		onActionPerformed,
 		setAsHomepageAction,
-		shouldShowSetAsHomepageAction,
+		setAsPostsPageAction,
+		shouldShowHomepageActions,
 	] );
 }
diff --git a/packages/editor/src/components/post-actions/set-as-homepage.js b/packages/editor/src/components/post-actions/set-as-homepage.js
index 0252c84e3ab3ff..671906575b4123 100644
--- a/packages/editor/src/components/post-actions/set-as-homepage.js
+++ b/packages/editor/src/components/post-actions/set-as-homepage.js
@@ -12,20 +12,11 @@ import {
 import { useDispatch, useSelect } from '@wordpress/data';
 import { store as coreStore } from '@wordpress/core-data';
 import { store as noticesStore } from '@wordpress/notices';
-import { decodeEntities } from '@wordpress/html-entities';
 
-const getItemTitle = ( item ) => {
-	if ( typeof item.title === 'string' ) {
-		return decodeEntities( item.title );
-	}
-	if ( item.title && 'rendered' in item.title ) {
-		return decodeEntities( item.title.rendered );
-	}
-	if ( item.title && 'raw' in item.title ) {
-		return decodeEntities( item.title.raw );
-	}
-	return '';
-};
+/**
+ * Internal dependencies
+ */
+import { getItemTitle } from '../../utils/get-item-title';
 
 const SetAsHomepageModal = ( { items, closeModal } ) => {
 	const [ item ] = items;
@@ -48,8 +39,7 @@ const SetAsHomepageModal = ( { items, closeModal } ) => {
 		}
 	);
 
-	const { saveEditedEntityRecord, saveEntityRecord } =
-		useDispatch( coreStore );
+	const { saveEntityRecord } = useDispatch( coreStore );
 	const { createSuccessNotice, createErrorNotice } =
 		useDispatch( noticesStore );
 
@@ -57,29 +47,19 @@ const SetAsHomepageModal = ( { items, closeModal } ) => {
 		event.preventDefault();
 
 		try {
-			// Save new home page settings.
-			await saveEditedEntityRecord( 'root', 'site', undefined, {
-				page_on_front: item.id,
-				show_on_front: 'page',
-			} );
-
-			// This second call to a save function is a workaround for a bug in
-			// `saveEditedEntityRecord`. This forces the root site settings to be updated.
-			// See https://github.com/WordPress/gutenberg/issues/67161.
 			await saveEntityRecord( 'root', 'site', {
 				page_on_front: item.id,
 				show_on_front: 'page',
 			} );
 
-			createSuccessNotice( __( 'Homepage updated' ), {
+			createSuccessNotice( __( 'Homepage updated.' ), {
 				type: 'snackbar',
 			} );
 		} catch ( error ) {
-			const typedError = error;
 			const errorMessage =
-				typedError.message && typedError.code !== 'unknown_error'
-					? typedError.message
-					: __( 'An error occurred while setting the homepage' );
+				error.message && error.code !== 'unknown_error'
+					? error.message
+					: __( 'An error occurred while setting the homepage.' );
 			createErrorNotice( errorMessage, { type: 'snackbar' } );
 		} finally {
 			closeModal?.();
diff --git a/packages/editor/src/components/post-actions/set-as-posts-page.js b/packages/editor/src/components/post-actions/set-as-posts-page.js
new file mode 100644
index 00000000000000..67c42a7991fe45
--- /dev/null
+++ b/packages/editor/src/components/post-actions/set-as-posts-page.js
@@ -0,0 +1,158 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { useMemo } from '@wordpress/element';
+import {
+	Button,
+	__experimentalText as Text,
+	__experimentalHStack as HStack,
+	__experimentalVStack as VStack,
+} from '@wordpress/components';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { store as noticesStore } from '@wordpress/notices';
+
+/**
+ * Internal dependencies
+ */
+import { getItemTitle } from '../../utils/get-item-title';
+
+const SetAsPostsPageModal = ( { items, closeModal } ) => {
+	const [ item ] = items;
+	const pageTitle = getItemTitle( item );
+	const { currentPostsPage, isPageForPostsSet, isSaving } = useSelect(
+		( select ) => {
+			const { getEntityRecord, isSavingEntityRecord } =
+				select( coreStore );
+			const siteSettings = getEntityRecord( 'root', 'site' );
+			const currentPostsPageItem = getEntityRecord(
+				'postType',
+				'page',
+				siteSettings?.page_for_posts
+			);
+			return {
+				currentPostsPage: currentPostsPageItem,
+				isPageForPostsSet: siteSettings?.page_for_posts !== 0,
+				isSaving: isSavingEntityRecord( 'root', 'site' ),
+			};
+		}
+	);
+
+	const { saveEntityRecord } = useDispatch( coreStore );
+	const { createSuccessNotice, createErrorNotice } =
+		useDispatch( noticesStore );
+
+	async function onSetPageAsPostsPage( event ) {
+		event.preventDefault();
+
+		try {
+			await saveEntityRecord( 'root', 'site', {
+				page_for_posts: item.id,
+				show_on_front: 'page',
+			} );
+
+			createSuccessNotice( __( 'Posts page updated.' ), {
+				type: 'snackbar',
+			} );
+		} catch ( error ) {
+			const errorMessage =
+				error.message && error.code !== 'unknown_error'
+					? error.message
+					: __( 'An error occurred while setting the posts page.' );
+			createErrorNotice( errorMessage, { type: 'snackbar' } );
+		} finally {
+			closeModal?.();
+		}
+	}
+
+	const modalWarning =
+		isPageForPostsSet && currentPostsPage
+			? sprintf(
+					// translators: %s: title of the current posts page.
+					__( 'This will replace the current posts page: "%s"' ),
+					getItemTitle( currentPostsPage )
+			  )
+			: __( 'This page will show the latest posts.' );
+
+	const modalText = sprintf(
+		// translators: %1$s: title of the page to be set as the posts page, %2$s: posts page replacement warning message.
+		__( 'Set "%1$s" as the posts page? %2$s' ),
+		pageTitle,
+		modalWarning
+	);
+
+	// translators: Button label to confirm setting the specified page as the posts page.
+	const modalButtonLabel = __( 'Set posts page' );
+
+	return (
+		<form onSubmit={ onSetPageAsPostsPage }>
+			<VStack spacing="5">
+				<Text>{ modalText }</Text>
+				<HStack justify="right">
+					<Button
+						__next40pxDefaultSize
+						variant="tertiary"
+						onClick={ () => {
+							closeModal?.();
+						} }
+						disabled={ isSaving }
+						accessibleWhenDisabled
+					>
+						{ __( 'Cancel' ) }
+					</Button>
+					<Button
+						__next40pxDefaultSize
+						variant="primary"
+						type="submit"
+						disabled={ isSaving }
+						accessibleWhenDisabled
+					>
+						{ modalButtonLabel }
+					</Button>
+				</HStack>
+			</VStack>
+		</form>
+	);
+};
+
+export const useSetAsPostsPageAction = () => {
+	const { pageOnFront, pageForPosts } = useSelect( ( select ) => {
+		const { getEntityRecord } = select( coreStore );
+		const siteSettings = getEntityRecord( 'root', 'site' );
+		return {
+			pageOnFront: siteSettings?.page_on_front,
+			pageForPosts: siteSettings?.page_for_posts,
+		};
+	} );
+
+	return useMemo(
+		() => ( {
+			id: 'set-as-posts-page',
+			label: __( 'Set as posts page' ),
+			isEligible( post ) {
+				if ( post.status !== 'publish' ) {
+					return false;
+				}
+
+				if ( post.type !== 'page' ) {
+					return false;
+				}
+
+				// Don't show the action if the page is already set as the homepage.
+				if ( pageOnFront === post.id ) {
+					return false;
+				}
+
+				// Don't show the action if the page is already set as the page for posts.
+				if ( pageForPosts === post.id ) {
+					return false;
+				}
+
+				return true;
+			},
+			RenderModal: SetAsPostsPageModal,
+		} ),
+		[ pageForPosts, pageOnFront ]
+	);
+};
diff --git a/packages/editor/src/utils/get-item-title.js b/packages/editor/src/utils/get-item-title.js
new file mode 100644
index 00000000000000..86929c27408a81
--- /dev/null
+++ b/packages/editor/src/utils/get-item-title.js
@@ -0,0 +1,25 @@
+/**
+ * WordPress dependencies
+ */
+import { decodeEntities } from '@wordpress/html-entities';
+
+/**
+ * Helper function to get the title of a post item.
+ * This is duplicated from the `@wordpress/fields` package.
+ * `packages/fields/src/actions/utils.ts`
+ *
+ * @param {Object} item The post item.
+ * @return {string} The title of the item, or an empty string if the title is not found.
+ */
+export function getItemTitle( item ) {
+	if ( typeof item.title === 'string' ) {
+		return decodeEntities( item.title );
+	}
+	if ( item.title && 'rendered' in item.title ) {
+		return decodeEntities( item.title.rendered );
+	}
+	if ( item.title && 'raw' in item.title ) {
+		return decodeEntities( item.title.raw );
+	}
+	return '';
+}
diff --git a/test/e2e/specs/site-editor/homepage-settings.spec.js b/test/e2e/specs/site-editor/homepage-settings.spec.js
index d53130af23ac8b..e80e14830364ce 100644
--- a/test/e2e/specs/site-editor/homepage-settings.spec.js
+++ b/test/e2e/specs/site-editor/homepage-settings.spec.js
@@ -10,6 +10,14 @@ test.describe( 'Homepage Settings via Editor', () => {
 			title: 'Homepage',
 			status: 'publish',
 		} );
+		await requestUtils.createPage( {
+			title: 'Sample page',
+			status: 'publish',
+		} );
+		await requestUtils.createPage( {
+			title: 'Draft page',
+			status: 'draft',
+		} );
 	} );
 
 	test.beforeEach( async ( { admin, page } ) => {
@@ -28,27 +36,30 @@ test.describe( 'Homepage Settings via Editor', () => {
 		] );
 	} );
 
-	test( 'should show "Set as homepage" action on pages with `publish` status', async ( {
+	test( 'should not show "Set as homepage" and "Set as posts page" action on pages with `draft` status', async ( {
 		page,
 	} ) => {
-		const samplePage = page
+		const draftPage = page
 			.getByRole( 'gridcell' )
-			.getByLabel( 'Homepage' );
-		const samplePageRow = page
+			.getByLabel( 'Draft page' );
+		const draftPageRow = page
 			.getByRole( 'row' )
-			.filter( { has: samplePage } );
-		await samplePageRow.hover();
-		await samplePageRow
+			.filter( { has: draftPage } );
+		await draftPageRow.hover();
+		await draftPageRow
 			.getByRole( 'button', {
 				name: 'Actions',
 			} )
 			.click();
 		await expect(
 			page.getByRole( 'menuitem', { name: 'Set as homepage' } )
-		).toBeVisible();
+		).toBeHidden();
+		await expect(
+			page.getByRole( 'menuitem', { name: 'Set as posts page' } )
+		).toBeHidden();
 	} );
 
-	test( 'should not show "Set as homepage" action on current homepage', async ( {
+	test( 'should show correct homepage actions based on current homepage or posts page', async ( {
 		page,
 	} ) => {
 		const samplePage = page
@@ -68,5 +79,32 @@ test.describe( 'Homepage Settings via Editor', () => {
 		await expect(
 			page.getByRole( 'menuitem', { name: 'Set as homepage' } )
 		).toBeHidden();
+		await expect(
+			page.getByRole( 'menuitem', { name: 'Set as posts page' } )
+		).toBeHidden();
+
+		const samplePageTwo = page
+			.getByRole( 'gridcell' )
+			.getByLabel( 'Sample page' );
+		const samplePageTwoRow = page
+			.getByRole( 'row' )
+			.filter( { has: samplePageTwo } );
+		// eslint-disable-next-line playwright/no-force-option
+		await samplePageTwoRow.click( { force: true } );
+		await samplePageTwoRow
+			.getByRole( 'button', {
+				name: 'Actions',
+			} )
+			.click();
+		await page
+			.getByRole( 'menuitem', { name: 'Set as posts page' } )
+			.click();
+		await page.getByRole( 'button', { name: 'Set posts page' } ).click();
+		await expect(
+			page.getByRole( 'menuitem', { name: 'Set as homepage' } )
+		).toBeHidden();
+		await expect(
+			page.getByRole( 'menuitem', { name: 'Set as posts page' } )
+		).toBeHidden();
 	} );
 } );