diff --git a/client/dashboard/sites/settings-wordpress/beta-program-notice.tsx b/client/dashboard/sites/settings-wordpress/beta-program-notice.tsx new file mode 100644 index 000000000000..ac22ce033f8a --- /dev/null +++ b/client/dashboard/sites/settings-wordpress/beta-program-notice.tsx @@ -0,0 +1,47 @@ +import { Button } from '@wordpress/components'; +import { createInterpolateElement } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { useHelpCenter } from '../../app/help-center'; +import { Notice } from '../../components/notice'; +import { getBackupUrl } from '../../utils/site-backup'; +import type { Site } from '@automattic/api-core'; + +interface BetaProgramNoticeProps { + site: Site; + wpVersion: string; +} + +export function BetaProgramNotice( { site, wpVersion }: BetaProgramNoticeProps ) { + const backupUrl = getBackupUrl( site ); + const { setShowHelpCenter } = useHelpCenter(); + + return ( + + { site.is_wpcom_staging_site + ? createInterpolateElement( + __( + 'If you notice anything unexpected, let us know. Your feedback helps shape WordPress. You can switch back to the stable release anytime.' + ), + { + support: - - - - - + + + + { sprintf( + // translators: %s: WordPress version, e.g. 6.8 + __( 'Every WordPress.com site runs the latest WordPress version (%s).' ), + getFormattedWordPressVersion( site ) + ) } + + { site.is_wpcom_atomic && ( + + { createInterpolateElement( + __( + 'Switch to a staging site to test a beta version of the next WordPress release. ' + ), + { + learnMoreLink: , + } + ) } + + ) } + + ); } diff --git a/client/dashboard/sites/settings-wordpress/latest-version-notice.tsx b/client/dashboard/sites/settings-wordpress/latest-version-notice.tsx new file mode 100644 index 000000000000..8ea82a0b1155 --- /dev/null +++ b/client/dashboard/sites/settings-wordpress/latest-version-notice.tsx @@ -0,0 +1,18 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { Notice } from '../../components/notice'; + +interface LatestVersionNoticeProps { + wpVersion: string; +} + +export function LatestVersionNotice( { wpVersion }: LatestVersionNoticeProps ) { + return ( + + { sprintf( + // translators: %s: WordPress version, e.g. "7.0-RC2" + __( 'Your site is now running WordPress %s.' ), + wpVersion + ) } + + ); +} diff --git a/client/dashboard/sites/settings-wordpress/test/index.test.tsx b/client/dashboard/sites/settings-wordpress/test/index.test.tsx index b78eb62c4569..cdb303a0e8fa 100644 --- a/client/dashboard/sites/settings-wordpress/test/index.test.tsx +++ b/client/dashboard/sites/settings-wordpress/test/index.test.tsx @@ -27,24 +27,34 @@ const site = { }, } as Site; -function mockSite( mockedSite: Site ) { +function mockApi() { nock( 'https://public-api.wordpress.com' ) - .get( `/rest/v1.1/sites/${ mockedSite.slug }` ) + .get( `/rest/v1.1/sites/${ site.slug }` ) .query( true ) - .reply( 200, mockedSite ); -} + .reply( 200, site ); -function mockWordPressVersion( version: string ) { nock( 'https://public-api.wordpress.com' ) .get( `/wpcom/v2/sites/${ site.ID }/hosting/wp-version` ) .query( true ) - .reply( 200, version ); + .reply( 200, 'latest' ); + + nock( 'https://public-api.wordpress.com' ) + .persist() + .get( `/wpcom/v2/sites/${ site.ID }/hosting/wp-version/pending` ) + .query( true ) + .reply( 200, null as unknown as nock.Body ); + + nock( 'https://public-api.wordpress.com' ) + .persist() + .get( `/wpcom/v2/sites/${ site.ID }/rewind/backups` ) + .query( true ) + .reply( 200, [] ); } function mockWordPressVersionSaved( expectedVersion: string ) { return nock( 'https://public-api.wordpress.com' ) .post( `/wpcom/v2/sites/${ site.ID }/hosting/wp-version`, ( body ) => { - expect( body ).toEqual( { version: expectedVersion } ); + expect( body ).toMatchObject( { version: expectedVersion } ); return true; } ) .reply( 200 ); @@ -54,8 +64,7 @@ describe( '', () => { test( 'renders and saves the form for a Business+ site', async () => { const user = userEvent.setup(); - mockSite( site ); - mockWordPressVersion( 'latest' ); + mockApi(); render( ); await screen.findByRole( 'heading', { name: 'WordPress' } ); diff --git a/client/dashboard/sites/settings-wordpress/use-version-switch.ts b/client/dashboard/sites/settings-wordpress/use-version-switch.ts new file mode 100644 index 000000000000..74faef16b413 --- /dev/null +++ b/client/dashboard/sites/settings-wordpress/use-version-switch.ts @@ -0,0 +1,137 @@ +import { + queryClient, + siteBackupsQuery, + siteBySlugQuery, + sitePendingWordPressVersionQuery, + siteWordPressVersionQuery, + siteWordPressVersionMutation, +} from '@automattic/api-queries'; +import { isEnabled } from '@automattic/calypso-config'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { usePrevious } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; +import { useEffect, useReducer } from 'react'; +import { useBackupState } from '../backups/use-backup-state'; +import type { BackupState } from '../backups/use-backup-state'; +import type { Site } from '@automattic/api-core'; + +export type Phase = + | { status: 'idle' } + | { status: 'submitting'; targetVersion: string } + | { status: 'switching'; targetVersion: string } + | { status: 'switched'; targetVersion: string }; + +type Action = + | { type: 'VERSION_CHANGE_REQUESTED'; targetVersion: string } + | { type: 'SWITCH_STARTED'; targetVersion: string } + | { type: 'SWITCH_COMPLETED' }; + +export function reducer( state: Phase, action: Action ): Phase { + switch ( action.type ) { + case 'VERSION_CHANGE_REQUESTED': + return { status: 'submitting', targetVersion: action.targetVersion }; + case 'SWITCH_STARTED': + return { status: 'switching', targetVersion: action.targetVersion }; + case 'SWITCH_COMPLETED': + if ( state.status !== 'switching' ) { + return state; + } + return { status: 'switched', targetVersion: state.targetVersion }; + default: + return state; + } +} + +export interface VersionSwitchState { + backupState: BackupState; + targetVersion: string; + pendingVersion: string | null | undefined; + isSwitching: boolean; + isSwitched: boolean; + switchedToBeta: boolean; + switchedToLatest: boolean; + switchVersion: ( version: string ) => void; + isSaving: boolean; +} + +export function useVersionSwitch( site: Site ): VersionSwitchState { + const deferUntilBackupComplete = + isEnabled( 'dashboard/wp-beta-program' ) && ! site.is_wpcom_staging_site; + + const backupState = useBackupState( site.ID ); + const [ phase, dispatch ] = useReducer( reducer, { status: 'idle' } ); + + // Check if there's a pending version switch. + const { data: pendingVersion } = useQuery( { + ...sitePendingWordPressVersionQuery( site.ID ), + enabled: deferUntilBackupComplete, + } ); + const hasPendingVersion = deferUntilBackupComplete && !! pendingVersion; + const hadPendingVersion = usePrevious( hasPendingVersion ); + + // Pending version appeared → switching. Also start backup tracking. + useEffect( () => { + if ( pendingVersion ) { + backupState.setEnqueued( true ); + dispatch( { type: 'SWITCH_STARTED', targetVersion: pendingVersion } ); + } + }, [ pendingVersion ] ); // eslint-disable-line react-hooks/exhaustive-deps + + // Pending version cleared → switched. + useEffect( () => { + if ( hadPendingVersion && ! hasPendingVersion ) { + dispatch( { type: 'SWITCH_COMPLETED' } ); + queryClient.invalidateQueries( siteWordPressVersionQuery( site.ID ) ); + queryClient.invalidateQueries( siteBySlugQuery( site.slug ) ); + queryClient.invalidateQueries( { + queryKey: [ 'site', site.ID, 'backup-activity-log' ], + } ); + } + }, [ hadPendingVersion, hasPendingVersion, site.ID, site.slug ] ); + + // Poll backups while a version switch is in progress (including right after mutation fires). + const shouldPollBackups = phase.status === 'submitting' || hasPendingVersion; + useQuery( { + ...siteBackupsQuery( site.ID ), + refetchInterval: shouldPollBackups ? 3000 : false, + enabled: shouldPollBackups, + } ); + + // After backup completes, poll pending version until it clears. + useQuery( { + ...sitePendingWordPressVersionQuery( site.ID ), + refetchInterval: hasPendingVersion && backupState.hasRecentlyCompleted ? 5000 : false, + } ); + + const mutation = useMutation( { + ...siteWordPressVersionMutation( site.ID, { deferUntilBackupComplete } ), + meta: { + snackbar: { + ...( ! deferUntilBackupComplete && { success: __( 'WordPress version saved.' ) } ), + error: __( 'Failed to save WordPress version.' ), + }, + }, + } ); + + const switchVersion = ( version: string ) => { + if ( deferUntilBackupComplete ) { + dispatch( { type: 'VERSION_CHANGE_REQUESTED', targetVersion: version } ); + } + mutation.mutate( version ); + }; + + const targetVersion = phase.status !== 'idle' ? phase.targetVersion : ''; + const isSwitched = phase.status === 'switched'; + + return { + backupState, + targetVersion, + pendingVersion, + isSwitching: phase.status === 'submitting' || phase.status === 'switching', + isSwitched, + switchedToBeta: isSwitched && phase.targetVersion === 'beta', + switchedToLatest: isSwitched && phase.targetVersion === 'latest', + switchVersion, + isSaving: mutation.isPending, + }; +} diff --git a/client/dashboard/sites/settings-wordpress/version-form.tsx b/client/dashboard/sites/settings-wordpress/version-form.tsx new file mode 100644 index 000000000000..3d375edf30ab --- /dev/null +++ b/client/dashboard/sites/settings-wordpress/version-form.tsx @@ -0,0 +1,98 @@ +import { wpOrgCoreVersionQuery } from '@automattic/api-queries'; +import { useQuery } from '@tanstack/react-query'; +import { __experimentalVStack as VStack, Button } from '@wordpress/components'; +import { DataForm } from '@wordpress/dataviews'; +import { __ } from '@wordpress/i18n'; +import { useState } from 'react'; +import { NavigationBlocker } from '../../app/navigation-blocker'; +import { ButtonStack } from '../../components/button-stack'; +import { Card, CardBody } from '../../components/card'; +import { formatWordPressVersion } from '../../utils/wp-version'; +import type { VersionSwitchState } from './use-version-switch'; +import type { Site } from '@automattic/api-core'; +import type { Field } from '@wordpress/dataviews'; + +interface VersionFormProps { + site: Site; + currentVersion: string | undefined; + versionSwitch: VersionSwitchState; +} + +export function VersionForm( { site, currentVersion, versionSwitch }: VersionFormProps ) { + const { data: latestVersion } = useQuery( wpOrgCoreVersionQuery() ); + const { data: betaVersion } = useQuery( wpOrgCoreVersionQuery( 'beta' ) ); + + const { isSwitching, pendingVersion, switchVersion, isSaving } = versionSwitch; + + const [ formEdits, setFormEdits ] = useState< { version: string } >( { + version: '', + } ); + + const formData = { + version: formEdits.version || pendingVersion || currentVersion || '', + }; + + const currentWpVersion = site.options?.software_version ?? ''; + + const fields: Field< { version: string } >[] = [ + { + id: 'version', + label: __( 'WordPress version' ), + Edit: 'select', + elements: [ + { + value: 'latest', + label: formatWordPressVersion( latestVersion ?? currentWpVersion, 'latest', true ), + }, + { + value: 'beta', + label: formatWordPressVersion( betaVersion ?? currentWpVersion, 'beta', true ), + }, + ], + }, + ]; + + const form = { + layout: { type: 'regular' as const }, + fields: [ 'version' ], + }; + + const isDirty = formData.version !== currentVersion; + + const handleSubmit = ( e: React.FormEvent ) => { + e.preventDefault(); + switchVersion( formData.version ); + }; + + return ( + + +
+
+ + + + data={ formData } + fields={ fields } + form={ form } + onChange={ ( edits: { version?: string } ) => { + setFormEdits( ( data ) => ( { ...data, ...edits } ) ); + } } + /> + + + + +
+
+
+
+ ); +} diff --git a/client/dashboard/sites/settings-wordpress/version-switch-notice.tsx b/client/dashboard/sites/settings-wordpress/version-switch-notice.tsx new file mode 100644 index 000000000000..f6a060d3b871 --- /dev/null +++ b/client/dashboard/sites/settings-wordpress/version-switch-notice.tsx @@ -0,0 +1,85 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { Notice } from '../../components/notice'; +import type { BackupState } from '../backups/use-backup-state'; + +interface VersionSwitchNoticeProps { + backupState: BackupState; + targetVersion: string; +} + +export function VersionSwitchNotice( { backupState, targetVersion }: VersionSwitchNoticeProps ) { + const { status, backup } = backupState; + + if ( status === 'enqueued' ) { + return ( + + { __( 'Creating a backup of your site before switching.' ) } + + ); + } + + if ( status === 'running' ) { + return ( + + { sprintf( + // translators: %s: backup progress percentage + __( + 'Generating backup… (%s%% progress). A backup is being created before switching. This may take a few minutes.' + ), + backup?.percent ?? '0' + ) } + + ); + } + + if ( status === 'success' ) { + return ( + + { __( 'Backup completed. Now switching WordPress version…' ) } + + ); + } + + if ( status === 'error' ) { + return ( + + { __( 'The backup could not be completed. Please try again or contact support.' ) } + + ); + } + + // Fallback for 'idle' state — pending version exists but backup hasn't started yet. + return ( + + { __( 'Preparing to switch WordPress version…' ) } + + ); +} diff --git a/packages/api-core/src/site-hosting/fetchers.ts b/packages/api-core/src/site-hosting/fetchers.ts index 809d5cc6c0f1..455fd8559ced 100644 --- a/packages/api-core/src/site-hosting/fetchers.ts +++ b/packages/api-core/src/site-hosting/fetchers.ts @@ -8,6 +8,13 @@ export async function fetchWordPressVersion( siteId: number ): Promise< string > } ); } +export async function fetchPendingWordPressVersion( siteId: number ): Promise< string | null > { + return wpcom.req.get( { + path: `/sites/${ siteId }/hosting/wp-version/pending`, + apiNamespace: 'wpcom/v2', + } ); +} + export async function fetchPHPVersion( siteId: number ): Promise< string > { return wpcom.req.get( { path: `/sites/${ siteId }/hosting/php-version`, diff --git a/packages/api-core/src/site-hosting/mutators.ts b/packages/api-core/src/site-hosting/mutators.ts index 582fae6c4ee5..92e97214bd9f 100644 --- a/packages/api-core/src/site-hosting/mutators.ts +++ b/packages/api-core/src/site-hosting/mutators.ts @@ -34,13 +34,20 @@ export async function restoreSitePlanSoftware( siteId: number ) { } ); } -export async function updateWordPressVersion( siteId: number, version: string ) { +export async function updateWordPressVersion( + siteId: number, + version: string, + deferUntilBackupComplete?: boolean +) { return wpcom.req.post( { path: `/sites/${ siteId }/hosting/wp-version`, apiNamespace: 'wpcom/v2', }, - { version } + { + version, + ...( deferUntilBackupComplete && { defer_until_backup_complete: true } ), + } ); } diff --git a/packages/api-queries/src/site-wordpress-version.ts b/packages/api-queries/src/site-wordpress-version.ts index 618e79a93ff8..9580c1397395 100644 --- a/packages/api-queries/src/site-wordpress-version.ts +++ b/packages/api-queries/src/site-wordpress-version.ts @@ -1,4 +1,8 @@ -import { fetchWordPressVersion, updateWordPressVersion } from '@automattic/api-core'; +import { + fetchWordPressVersion, + fetchPendingWordPressVersion, + updateWordPressVersion, +} from '@automattic/api-core'; import { queryOptions, mutationOptions } from '@tanstack/react-query'; import { queryClient } from './query-client'; @@ -8,10 +12,21 @@ export const siteWordPressVersionQuery = ( siteId: number ) => queryFn: () => fetchWordPressVersion( siteId ), } ); -export const siteWordPressVersionMutation = ( siteId: number ) => +export const sitePendingWordPressVersionQuery = ( siteId: number ) => + queryOptions( { + queryKey: [ 'site', siteId, 'wp-version', 'pending' ], + queryFn: () => fetchPendingWordPressVersion( siteId ), + } ); + +export const siteWordPressVersionMutation = ( + siteId: number, + options?: { deferUntilBackupComplete?: boolean } +) => mutationOptions( { - mutationFn: ( version: string ) => updateWordPressVersion( siteId, version ), + mutationFn: ( version: string ) => + updateWordPressVersion( siteId, version, options?.deferUntilBackupComplete ), onSuccess: () => { queryClient.invalidateQueries( siteWordPressVersionQuery( siteId ) ); + queryClient.invalidateQueries( sitePendingWordPressVersionQuery( siteId ) ); }, } );