Skip to content

Dashboard v2: Implement WP version settings #103560

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions client/dashboard/app/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
fetchPerformanceInsights,
updateSiteSettings,
restoreSitePlanSoftware,
fetchWordPressVersion,
updateWordPressVersion,
} from '../data';
import { SITE_FIELDS } from '../data/constants';
import { queryClient } from './query-client';
Expand Down Expand Up @@ -70,6 +72,24 @@ export function sitePHPVersionQuery( siteIdOrSlug: string ) {
};
}

export function siteWordPressVersionQuery( siteSlug: string ) {
return {
queryKey: [ 'site', siteSlug, 'wp-version' ],
queryFn: () => {
return fetchWordPressVersion( siteSlug );
},
};
}

export function siteWordPressVersionMutation( siteSlug: string ) {
return {
mutationFn: ( version: string ) => updateWordPressVersion( siteSlug, version ),
onSuccess: () => {
queryClient.invalidateQueries( { queryKey: [ 'site', siteSlug, 'wp-version' ] } );
},
};
}

export function domainsQuery() {
return {
queryKey: [ 'domains' ],
Expand Down
20 changes: 20 additions & 0 deletions client/dashboard/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createLazyRoute,
} from '@tanstack/react-router';
import { fetchTwoStep } from '../data';
import { canUpdateWordPressVersion } from '../sites/settings-wordpress/utils';
import NotFound from './404';
import UnknownError from './500';
import {
Expand All @@ -17,6 +18,7 @@ import {
profileQuery,
siteCurrentPlanQuery,
siteEngagementStatsQuery,
siteWordPressVersionQuery,
} from './queries';
import { queryClient } from './query-client';
import Root from './root';
Expand Down Expand Up @@ -150,6 +152,23 @@ const siteSettingsSubscriptionGiftingRoute = createRoute( {
)
);

const siteSettingsWordPressRoute = createRoute( {
getParentRoute: () => siteRoute,
path: 'settings/wordpress',
loader: async ( { params: { siteSlug } } ) => {
const site = await queryClient.ensureQueryData( siteQuery( siteSlug ) );
if ( canUpdateWordPressVersion( site ) ) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@youknowriad @ellatrix not sure if this is a good pattern (business logic in the router file)... but I'm not sure what the alternative is.

Copy link
Contributor

@arthur791004 arthur791004 May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's fine to call API on component level 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me this is a good approach, it ensure the loader works properly. What happens if we call the API without the check? Does the endpoint fail?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it fails and then the dashboard shows this instead
image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also ties back to my suggestion to use beforeLoad to protect this route. Because if the route was protected using beforeLoad there won't be any need to do this check here.

I've been thinking and maybe the right approach is going to be this

{
  path: 'parent',
  beforeLoad: async ({ context }) => {
    const sharedData = await fetchSharedData()
    return {
      context: {
        ...context,
        sharedData,
      },
    }
  },
  children: [
    {
      path: 'child',
      beforeLoad: ({ context }) => {
        const sharedData = context.sharedData
        // use sharedData here
      },
    }
  ]
}
  • Data that is required to for the "check" is loaded in "beforeLoad" of the parent route and added to the context
  • beforeLoad of the child uses that context to "throw" before even "load" is called.

I would love for this to be explored separately as a follow-up.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't fully understand, in this case, the parent siteSettingsRoute would need to fetch everything needed for the setting child routes even though we only open one of them (e.g. WordPress version)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't fully understand, in this case, the parent siteSettingsRoute would need to fetch everything needed for the setting child routes even though we only open one of them (e.g. WordPress version)?

Not really, all we need is the "site" object and the "site" object is already loaded as part of the parent route's loader.

return await queryClient.ensureQueryData( siteWordPressVersionQuery( siteSlug ) );
}
},
} ).lazy( () =>
import( '../sites/settings-wordpress' ).then( ( d ) =>
createLazyRoute( 'site-settings-wordpress' )( {
component: () => <d.default siteSlug={ siteRoute.useParams().siteSlug } />,
} )
)
);

const siteSettingsTransferSiteRoute = createRoute( {
getParentRoute: () => siteRoute,
path: 'settings/transfer-site',
Expand Down Expand Up @@ -326,6 +345,7 @@ const createRouteTree = ( config: AppConfig ) => {
siteSettingsRoute,
siteSettingsSiteVisibilityRoute,
siteSettingsSubscriptionGiftingRoute,
siteSettingsWordPressRoute,
siteSettingsTransferSiteRoute,
] )
);
Expand Down
1 change: 1 addition & 0 deletions client/dashboard/data/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const SITE_FIELDS = [
'is_coming_soon',
'is_private',
'is_wpcom_atomic',
'is_wpcom_staging_site',
'launch_status',
'site_migration',
'site_owner',
Expand Down
20 changes: 20 additions & 0 deletions client/dashboard/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,26 @@ export const fetchPHPVersion = async ( id: string ): Promise< string | undefined
} );
};

export const fetchWordPressVersion = async ( siteIdOrSlug: string ): Promise< string > => {
return wpcom.req.get( {
path: `/sites/${ siteIdOrSlug }/hosting/wp-version`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't find any docs to this endpoint. Can you share if you have a link that I missed?

Does it just return latest or beta? Do we need these labels (in design spec or if we deem them useful)?

Can we get this info and the WP version from a single place?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a whole project for this feature: pesfPa-qt-p2.

Yes, the endpoint just return either latest or beta. Also, this is how it looks in dashboard v1:

image

I'm really hesitant to change the endpoint (it would break v1 or whatnot) or the core design of this feature for now... porting features to v2 and changing the functionalities should be different activities.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

porting features to v2 and changing the functionalities should be different activities.

Agreed, but we should track what we can improve and/or simplify. I might be missing context, but I think we should consider getting WP info from a single endpoint (probably remove this one and update the calls of v1).

Additionally, not for this PR, we might want to revisit the design for this and add some help text about the options.

apiNamespace: 'wpcom/v2',
} );
};

export const updateWordPressVersion = async (
siteIdOrSlug: string,
version: string
): Promise< void > => {
return wpcom.req.post(
{
path: `/sites/${ siteIdOrSlug }/hosting/wp-version`,
apiNamespace: 'wpcom/v2',
},
{ version }
);
};

export const fetchCurrentPlan = async ( siteIdOrSlug: string ): Promise< Plan > => {
const plans: Record< string, Plan > = await wpcom.req.get( {
path: `/sites/${ siteIdOrSlug }/plans`,
Expand Down
1 change: 1 addition & 0 deletions client/dashboard/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export interface Site {
is_coming_soon: boolean;
is_private: boolean;
is_wpcom_atomic: boolean;
is_wpcom_staging_site: boolean;
launch_status: string | boolean;
site_migration: {
migration_status: string;
Expand Down
11 changes: 6 additions & 5 deletions client/dashboard/sites/overview/site-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { __, sprintf } from '@wordpress/i18n';
import { sitePHPVersionQuery } from '../../app/queries';
import { TextBlur } from '../../components/text-blur';
import { getSiteStatusLabel } from '../../utils/site-status';
import { getFormattedWordPressVersion } from '../../utils/wp-version';
import SitePreview from '../site-preview';
import type { Site, Plan } from '../../data/types';

Expand All @@ -23,7 +24,9 @@ function PHPVersion( { siteSlug }: { siteSlug: string } ) {
* SiteCard component to display site information in a card format
*/
export default function SiteCard( { site, currentPlan }: { site: Site; currentPlan: Plan } ) {
const { options, URL: url, is_private, is_wpcom_atomic } = site;
const { URL: url, is_private, is_wpcom_atomic } = site;
const wpVersion = getFormattedWordPressVersion( site );

// If the site is a private A8C site, X-Frame-Options is set to same
// origin.
const iframeDisabled = site.is_a8c && is_private;
Expand Down Expand Up @@ -64,11 +67,9 @@ export default function SiteCard( { site, currentPlan }: { site: Site; currentPl
<HStack justify="space-between">
<Field title={ __( 'Status' ) }>{ getSiteStatusLabel( site ) }</Field>
</HStack>
{ ( options?.software_version || is_wpcom_atomic ) && (
{ ( wpVersion || is_wpcom_atomic ) && (
<HStack justify="space-between">
{ options?.software_version && (
<Field title={ __( 'WordPress' ) }>{ options.software_version }</Field>
) }
{ wpVersion && <Field title={ __( 'WordPress' ) }>{ wpVersion }</Field> }
{ is_wpcom_atomic && (
<Field title={ __( 'PHP' ) }>
<PHPVersion siteSlug={ site.slug } />
Expand Down
147 changes: 147 additions & 0 deletions client/dashboard/sites/settings-wordpress/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { DataForm } from '@automattic/dataviews';
import { useQuery, useMutation } from '@tanstack/react-query';
import {
Card,
CardBody,
__experimentalHStack as HStack,
__experimentalVStack as VStack,
__experimentalText as Text,
Button,
Notice,
} from '@wordpress/components';
import { useDispatch } from '@wordpress/data';
import { createInterpolateElement } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { useState } from 'react';
import {
siteQuery,
siteWordPressVersionQuery,
siteWordPressVersionMutation,
} from '../../app/queries';
import PageLayout from '../../components/page-layout';
import { getFormattedWordPressVersion } from '../../utils/wp-version';
import SettingsPageHeader from '../settings-page-header';
import { canUpdateWordPressVersion } from './utils';
import type { Field } from '@automattic/dataviews';

export default function WordPressVersionSettings( { siteSlug }: { siteSlug: string } ) {
const { data: site } = useQuery( siteQuery( siteSlug ) );
const canUpdate = site && canUpdateWordPressVersion( site );

const { data: version } = useQuery( {
...siteWordPressVersionQuery( siteSlug ),
enabled: canUpdate,
} );
const mutation = useMutation( siteWordPressVersionMutation( siteSlug ) );
const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore );

const [ formData, setFormData ] = useState< { version: string } >( {
version: version ?? '',
} );

if ( ! site ) {
return null;
}

const fields: Field< { version: string } >[] = [
{
id: 'version',
label: __( 'WordPress version' ),
Edit: 'select',
elements: [
{ value: 'latest', label: getFormattedWordPressVersion( site, 'latest' ) },
{ value: 'beta', label: getFormattedWordPressVersion( site, 'beta' ) },
],
},
];

const form = {
type: 'regular' as const,
fields: [ 'version' ],
};

const isDirty = formData.version !== version;
const { isPending } = mutation;

const handleSubmit = ( e: React.FormEvent ) => {
e.preventDefault();
mutation.mutate( formData.version, {
onSuccess: () => {
createSuccessNotice( __( 'Settings saved.' ), { type: 'snackbar' } );
},
onError: () => {
createErrorNotice( __( 'Failed to save settings.' ), {
type: 'snackbar',
} );
},
} );
};

const renderForm = () => {
return (
<Card>
<CardBody>
<form onSubmit={ handleSubmit }>
<VStack spacing={ 4 }>
<DataForm< { version: string } >
data={ formData }
fields={ fields }
form={ form }
onChange={ ( edits: { version?: string } ) => {
setFormData( ( data ) => ( { ...data, ...edits } ) );
} }
/>
<HStack justify="flex-start">
<Button
variant="primary"
type="submit"
isBusy={ isPending }
disabled={ isPending || ! isDirty }
>
{ __( 'Save' ) }
</Button>
</HStack>
</VStack>
</form>
</CardBody>
</Card>
);
};

const renderNotice = () => {
return (
<Notice isDismissible={ false }>
<Text>
{ site.is_wpcom_atomic
? createInterpolateElement(
sprintf(
// translators: %s: WordPress version, e.g. 6.8
__(
'Every WordPress.com site runs the latest WordPress version (%s). For testing purposes, you can switch to the beta version of the next WordPress release on <a>your staging site</a>.'
),
getFormattedWordPressVersion( site )
),
{
// TODO: use correct staging site URL when it's available.
// eslint-disable-next-line jsx-a11y/anchor-is-valid
a: <a href="#" />,
}
)
: sprintf(
// translators: %s: WordPress version, e.g. 6.8
__( 'Every WordPress.com site runs the latest WordPress version (%s).' ),
getFormattedWordPressVersion( site )
) }
</Text>
</Notice>
);
};

return (
<PageLayout size="small">
<SettingsPageHeader title="WordPress" />
{ canUpdate ? renderForm() : renderNotice() }
</PageLayout>
);
}
37 changes: 37 additions & 0 deletions client/dashboard/sites/settings-wordpress/summary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import { Icon } from '@wordpress/components';
import { wordpress } from '@wordpress/icons';
import { siteWordPressVersionQuery } from '../../app/queries';
import RouterLinkSummaryButton from '../../components/router-link-summary-button';
import { getFormattedWordPressVersion } from '../../utils/wp-version';
import { canUpdateWordPressVersion } from './utils';
import type { Site } from '../../data/types';

export default function WordPressSettingsSummary( { site }: { site: Site } ) {
const { data: versionTag } = useQuery( {
...siteWordPressVersionQuery( site.slug ),
enabled: canUpdateWordPressVersion( site ),
} );

const wpVersion = getFormattedWordPressVersion( site, versionTag );
if ( ! wpVersion ) {
return null;
}

const badges = [
{
text: wpVersion,
intent: versionTag === 'beta' ? ( 'warning' as const ) : ( 'success' as const ),
},
];

return (
<RouterLinkSummaryButton
to={ `/sites/${ site.slug }/settings/wordpress` }
title="WordPress"
density="medium"
decoration={ <Icon icon={ wordpress } /> }
badges={ badges }
/>
);
}
5 changes: 5 additions & 0 deletions client/dashboard/sites/settings-wordpress/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Site } from '../../data/types';

export function canUpdateWordPressVersion( site: Site ) {
return site.is_wpcom_staging_site;
}
7 changes: 7 additions & 0 deletions client/dashboard/sites/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { PageHeader } from '../../components/page-header';
import PageLayout from '../../components/page-layout';
import SiteVisibilitySettingsSummary from '../settings-site-visibility/summary';
import SubscriptionGiftingSettingsSummary from '../settings-subscription-gifting/summary';
import WordPressSettingsSummary from '../settings-wordpress/summary';
import DangerZone from './danger-zone';
import SiteActions from './site-actions';

Expand All @@ -30,6 +31,12 @@ export default function SiteSettings( { siteSlug }: { siteSlug: string } ) {
<SubscriptionGiftingSettingsSummary site={ site } settings={ settings } />
</VStack>
</Card>
<Heading>{ __( 'Server' ) }</Heading>
<Card>
<VStack>
<WordPressSettingsSummary site={ site } />
</VStack>
</Card>
<SiteActions site={ site } />
<DangerZone site={ site } />
</PageLayout>
Expand Down
Loading