diff --git a/apps/cli/commands/blueprint/use.ts b/apps/cli/commands/blueprint/use.ts index f5b04e8bd1..a721b94db4 100644 --- a/apps/cli/commands/blueprint/use.ts +++ b/apps/cli/commands/blueprint/use.ts @@ -18,6 +18,11 @@ import { __, _n, sprintf } from '@wordpress/i18n'; import { runCommand as runCreateSiteCommand } from 'cli/commands/site/create'; import { getDefaultSitePath } from 'cli/lib/site-paths'; import { untildify } from 'cli/lib/utils'; +import { + CLI_AUTO_UPDATE_WP_VERSION, + coerceWpVersionOption, + getWpVersionOptionDescription, +} from 'cli/lib/wp-version-option'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -162,8 +167,9 @@ export const registerCommand = ( yargs: StudioArgv ) => { } ) .option( 'wp', { type: 'string', - describe: __( 'WordPress version' ), - defaultDescription: DEFAULT_WORDPRESS_VERSION, + describe: getWpVersionOptionDescription(), + defaultDescription: CLI_AUTO_UPDATE_WP_VERSION, + coerce: coerceWpVersionOption, } ) .option( 'php', { type: 'string', diff --git a/apps/cli/commands/config/set.ts b/apps/cli/commands/config/set.ts index 55e9f208a4..3e9bb0dbe7 100644 --- a/apps/cli/commands/config/set.ts +++ b/apps/cli/commands/config/set.ts @@ -1,4 +1,4 @@ -import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants'; +import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants'; import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { getDomainNameValidationError } from '@studio/common/lib/domains'; import { arePathsEqual } from '@studio/common/lib/fs-utils'; @@ -24,11 +24,7 @@ import { siteRuntimeFromMode, type SiteMode, } from '@studio/common/lib/site-runtime'; -import { - getWordPressVersionUrl, - isValidWordPressVersion, - isWordPressVersionAtLeast, -} from '@studio/common/lib/wordpress-version-utils'; +import { getWordPressVersionUrl } from '@studio/common/lib/wordpress-version-utils'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { SupportedPHPVersions } from '@studio/common/types/php-versions'; import { __, sprintf } from '@wordpress/i18n'; @@ -46,12 +42,12 @@ import { validateSupportedPhpVersion } from 'cli/lib/php-versions'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; import { withSiteOperation } from 'cli/lib/site-operations'; import { setupCustomDomain } from 'cli/lib/site-utils'; -import { ValidationError } from 'cli/lib/validation-error'; import { isServerRunning, startWordPressServer, stopWordPressServer, } from 'cli/lib/wordpress-server-manager'; +import { coerceWpVersionOption, getWpVersionOptionDescription } from 'cli/lib/wp-version-option'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -423,28 +419,8 @@ export const registerCommand = ( yargs: StudioArgv ) => { } ) .option( 'wp', { type: 'string', - description: __( - 'WordPress version. Use "latest" to let the site auto-update, or pin a version (e.g., "6.4", "6.4.1")' - ), - coerce: ( value: string ) => { - if ( ! isValidWordPressVersion( value ) ) { - throw new ValidationError( - 'wp', - value, - __( - 'Must be: "latest", "nightly", or a valid version number (e.g., "6.4", "6.4.1", "6.4-beta1")' - ) - ); - } - if ( ! isWordPressVersionAtLeast( value, MINIMUM_WORDPRESS_VERSION ) ) { - throw new ValidationError( - 'wp', - value, - sprintf( __( 'Must be: at least %s' ), MINIMUM_WORDPRESS_VERSION ) - ); - } - return value; - }, + description: getWpVersionOptionDescription(), + coerce: coerceWpVersionOption, } ) .option( 'runtime', { type: 'string', diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index 0f30bf4335..b141f19362 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -2,7 +2,7 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { confirm, input, password, select } from '@inquirer/prompts'; -import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants'; +import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants'; import { installAiInstructionsToSite } from '@studio/common/lib/agent-skills'; import { createBlueprintTempDirSync, @@ -53,10 +53,6 @@ import { } from '@studio/common/lib/site-runtime'; import { sortSites } from '@studio/common/lib/sort-sites'; import { getServerFilesPath } from '@studio/common/lib/well-known-paths'; -import { - isValidWordPressVersion, - isWordPressVersionAtLeast, -} from '@studio/common/lib/wordpress-version-utils'; import { fetchWordPressVersions } from '@studio/common/lib/wordpress-versions'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { @@ -98,6 +94,11 @@ import { StatsGroup } from 'cli/lib/types/bump-stats'; import { untildify } from 'cli/lib/utils'; import { ValidationError } from 'cli/lib/validation-error'; import { runBlueprint, startWordPressServer } from 'cli/lib/wordpress-server-manager'; +import { + CLI_AUTO_UPDATE_WP_VERSION, + coerceWpVersionOption, + getWpVersionOptionDescription, +} from 'cli/lib/wp-version-option'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -1110,28 +1111,6 @@ function coerceSiteId( value: string ) { return value; } -function coerceWpVersion( value: string ) { - if ( ! isValidWordPressVersion( value ) ) { - throw new ValidationError( - 'wp', - value, - __( - 'Must be: "latest", "nightly", or a valid version number (e.g., "6.4", "6.4.1", "6.4-beta1")' - ) - ); - } - - if ( ! isWordPressVersionAtLeast( value, MINIMUM_WORDPRESS_VERSION ) ) { - throw new ValidationError( - 'wp', - value, - sprintf( __( 'Must be: at least %s' ), MINIMUM_WORDPRESS_VERSION ) - ); - } - - return value; -} - export const registerCommand = ( yargs: StudioArgv ) => { return yargs.command( { command: 'create', @@ -1150,11 +1129,9 @@ export const registerCommand = ( yargs: StudioArgv ) => { } ) .option( 'wp', { type: 'string', - describe: __( - 'WordPress version. Use "latest" to let the site auto-update, or pin a version (e.g., "6.4", "6.4.1")' - ), - defaultDescription: DEFAULT_WORDPRESS_VERSION, - coerce: coerceWpVersion, + describe: getWpVersionOptionDescription(), + defaultDescription: CLI_AUTO_UPDATE_WP_VERSION, + coerce: coerceWpVersionOption, } ) .option( 'php', { type: 'string', @@ -1379,15 +1356,18 @@ export const registerCommand = ( yargs: StudioArgv ) => { try { const versions = await fetchWordPressVersions(); wpChoices = versions.map( ( v ) => ( { - name: v.value === 'latest' ? `latest (${ v.label })` : v.label, + name: + v.value === DEFAULT_WORDPRESS_VERSION + ? sprintf( __( 'Auto-update (%s)' ), v.label ) + : v.label, value: v.value, } ) ); } catch { - // Offline or API failure — offer only "latest" + // Offline or API failure — offer only the auto-update mode wpChoices = [ { - name: __( 'Latest (recommended)' ), - value: 'latest', + name: __( 'Auto-update (recommended)' ), + value: DEFAULT_WORDPRESS_VERSION, }, ]; } diff --git a/apps/cli/lib/tests/wp-version-option.test.ts b/apps/cli/lib/tests/wp-version-option.test.ts new file mode 100644 index 0000000000..bd6467a544 --- /dev/null +++ b/apps/cli/lib/tests/wp-version-option.test.ts @@ -0,0 +1,46 @@ +import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants'; +import { ValidationError } from 'cli/lib/validation-error'; +import { + CLI_AUTO_UPDATE_WP_VERSION, + coerceWpVersionOption, + normalizeCliWpVersion, +} from 'cli/lib/wp-version-option'; + +describe( 'normalizeCliWpVersion', () => { + it( 'resolves the auto-update alias to the internal mode value', () => { + expect( normalizeCliWpVersion( CLI_AUTO_UPDATE_WP_VERSION ) ).toBe( DEFAULT_WORDPRESS_VERSION ); + } ); + + it( 'passes every other value through untouched', () => { + for ( const value of [ 'latest', 'nightly', '6.4', '6.4.1', '6.4-beta1' ] ) { + expect( normalizeCliWpVersion( value ) ).toBe( value ); + } + } ); +} ); + +describe( 'coerceWpVersionOption', () => { + it( 'accepts the auto-update value and returns the internal mode', () => { + expect( coerceWpVersionOption( CLI_AUTO_UPDATE_WP_VERSION ) ).toBe( DEFAULT_WORDPRESS_VERSION ); + } ); + + it( 'still accepts `latest`, so existing scripts keep working', () => { + expect( coerceWpVersionOption( 'latest' ) ).toBe( DEFAULT_WORDPRESS_VERSION ); + } ); + + it( 'accepts pinned versions and nightly', () => { + expect( coerceWpVersionOption( '6.4.1' ) ).toBe( '6.4.1' ); + expect( coerceWpVersionOption( 'nightly' ) ).toBe( 'nightly' ); + } ); + + it( 'rejects an unknown value', () => { + expect( () => coerceWpVersionOption( 'auto' ) ).toThrow( ValidationError ); + } ); + + it( 'rejects a version below the supported minimum', () => { + expect( () => coerceWpVersionOption( '4.9' ) ).toThrow( ValidationError ); + } ); + + it( 'reports the value the user typed, not the resolved one', () => { + expect( () => coerceWpVersionOption( 'auto-updates' ) ).toThrow( /auto-updates/ ); + } ); +} ); diff --git a/apps/cli/lib/wp-version-option.ts b/apps/cli/lib/wp-version-option.ts new file mode 100644 index 0000000000..8b9d40ab60 --- /dev/null +++ b/apps/cli/lib/wp-version-option.ts @@ -0,0 +1,63 @@ +import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants'; +import { + isValidWordPressVersion, + isWordPressVersionAtLeast, +} from '@studio/common/lib/wordpress-version-utils'; +import { __, sprintf } from '@wordpress/i18n'; +import { ValidationError } from 'cli/lib/validation-error'; + +/** + * CLI-facing name for the auto-update mode, matching the "Auto-update" option in + * the apps. + * + * `latest` is the legacy spelling. It stays the internal value — it names a cache + * directory, a segment of the wordpress.org download URL, and the persisted + * `wpVersion` — so `auto-update` is resolved to it here, at the boundary, and + * never reaches storage. Passing `latest` keeps working for existing scripts. + */ +export const CLI_AUTO_UPDATE_WP_VERSION = 'auto-update'; + +export function normalizeCliWpVersion( value: string ): string { + return value === CLI_AUTO_UPDATE_WP_VERSION ? DEFAULT_WORDPRESS_VERSION : value; +} + +/** Description for the `--wp` option, shared so both commands read alike. */ +export function getWpVersionOptionDescription(): string { + return sprintf( + /* translators: 1: the CLI value "auto-update". 2: the legacy CLI value "latest". Do not translate either. */ + __( + 'WordPress version. Use "%1$s" to let the site auto-update, or pin a version (e.g., "6.4", "6.4.1"). Replaces the legacy "%2$s" option, which still works.' + ), + CLI_AUTO_UPDATE_WP_VERSION, + DEFAULT_WORDPRESS_VERSION + ); +} + +/** Resolves the auto-update alias, then validates. Returns the internal value. */ +export function coerceWpVersionOption( value: string ): string { + const version = normalizeCliWpVersion( value ); + + if ( ! isValidWordPressVersion( version ) ) { + throw new ValidationError( + 'wp', + value, + sprintf( + /* translators: %s: the literal CLI value "auto-update", do not translate. */ + __( + 'Must be: "%s", "nightly", or a valid version number (e.g., "6.4", "6.4.1", "6.4-beta1")' + ), + CLI_AUTO_UPDATE_WP_VERSION + ) + ); + } + + if ( ! isWordPressVersionAtLeast( version, MINIMUM_WORDPRESS_VERSION ) ) { + throw new ValidationError( + 'wp', + value, + sprintf( __( 'Must be: at least %s' ), MINIMUM_WORDPRESS_VERSION ) + ); + } + + return version; +} diff --git a/apps/studio/src/components/settings-section.tsx b/apps/studio/src/components/settings-section.tsx new file mode 100644 index 0000000000..c97f02c60f --- /dev/null +++ b/apps/studio/src/components/settings-section.tsx @@ -0,0 +1,25 @@ +import { cx } from 'src/lib/cx'; +import type { ReactNode } from 'react'; + +/** + * One titled group of settings, separated from the previous group by a rule. + * The first section omits the rule so a form doesn't open with a stray line. + */ +export function SettingsSection( { + title, + isFirst = false, + children, +}: { + title: string; + isFirst?: boolean; + children: ReactNode; +} ) { + return ( +
+

{ title }

+ { children } +
+ ); +} diff --git a/apps/studio/src/components/wp-version-selector/index.tsx b/apps/studio/src/components/wp-version-selector/index.tsx index 0d3b9dea74..17e62d8af7 100644 --- a/apps/studio/src/components/wp-version-selector/index.tsx +++ b/apps/studio/src/components/wp-version-selector/index.tsx @@ -1,15 +1,19 @@ import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants'; -import { getAutoUpdateVersionLabel } from '@studio/common/lib/wordpress-version-labels'; +import { + getAutomaticUpdatesDescription, + getAutomaticUpdatesLabel, + getSelectAVersionLabel, +} from '@studio/common/lib/wordpress-version-labels'; import { isWordPressBetaVersion } from '@studio/common/lib/wordpress-version-utils'; import { SelectControl, Icon } from '@wordpress/components'; import { info } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; -import { useEffect } from 'react'; +import { useEffect, useId } from 'react'; import offlineIcon from 'src/components/offline-icon'; import { Tooltip } from 'src/components/tooltip'; import { useOffline } from 'src/hooks/use-offline'; import { cx } from 'src/lib/cx'; -import { isWordPressDevVersion } from 'src/lib/version-utils'; +import { getLatestStableWpVersion, isWordPressDevVersion } from 'src/lib/version-utils'; import { useGetWordPressVersions } from 'src/stores/wordpress-versions-api'; import { addWpVersionToList } from './add-wp-version-to-list'; @@ -40,6 +44,9 @@ export const WPVersionSelector = ( { }: WPVersionSelectorProps ) => { const { __ } = useI18n(); const isOffline = useOffline(); + // Unique per instance, so two selectors on one screen keep their own radio + // group and their own label/description associations. + const modeControlName = useId(); const defaultOfflineMessage = __( 'Changing WordPress version requires an internet connection.' ); const message = offlineMessage || defaultOfflineMessage; const { data: wpVersions = [] } = useGetWordPressVersions( { @@ -76,11 +83,137 @@ export const WPVersionSelector = ( { } } ); + // Without a fetched version list there is nothing to pin to, so the control + // degrades to a plain dropdown over `fallbackOptions`. + const usesUpdateMode = wpVersions.length > 0; + const automaticUpdates = selectedValue === DEFAULT_WORDPRESS_VERSION; + // Leaving auto-update lands on the version the site already runs, or on the + // newest stable release for a site that doesn't exist yet. + const pinnedFallback = + autoUpdateVersion || getLatestStableWpVersion( wpVersions ) || DEFAULT_WORDPRESS_VERSION; + + // A function, not an element: auto-update is the default in both forms, and + // there the dropdown is never rendered. + const renderVersionSelect = () => ( + already names the control. + label={ usesUpdateMode ? __( 'Version' ) : undefined } + hideLabelFromVision={ usesUpdateMode } + disabled={ disabled || isOffline } + // While "Automatic updates" is chosen the value is the mode, not a + // version, so preview the version the radio below would pin to. + value={ usesUpdateMode && automaticUpdates ? pinnedFallback : selectedValue } + onChange={ onChange } + __next40pxDefaultSize + __nextHasNoMarginBottom + > + { usesUpdateMode ? ( + <> + + { betaVersions.map( ( { label, value } ) => ( + + ) ) } + + + { stableVersions.map( ( { label, value } ) => ( + + ) ) } + + + ) : ( + fallbackOptions.map( ( { label, value } ) => ( + + ) ) + ) } + + ); + + if ( usesUpdateMode ) { + const automaticId = `${ modeControlName }-automatic`; + const automaticDescriptionId = `${ automaticId }-description`; + const pinnedId = `${ modeControlName }-pinned`; + return ( +
+ { __( 'WordPress version' ) } + + { /* `.components-radio-control` sets no gap of its own, so the first + option's description would butt against the second radio. + 16px matches the agentic UI's --wpds-dimension-padding-lg. */ } +
+
+ onChange( DEFAULT_WORDPRESS_VERSION ) } + /> + +

+ { /* Naming a version on a pinned site would read as if + auto-update were keeping it there. */ } + { getAutomaticUpdatesDescription( + automaticUpdates ? autoUpdateVersion : undefined + ) } +

+
+
+ onChange( pinnedFallback ) } + /> + +
+ { renderVersionSelect() } +
+
+
+
+
+ ); + } + return ( ); diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css index d9839371fb..b546b863e2 100644 --- a/apps/studio/src/index.css +++ b/apps/studio/src/index.css @@ -616,6 +616,18 @@ div:has( > progress ) > div:first-child { color: var( --color-frame-text-secondary ); } + /* Radio — the update-mode control is the app's only radio group, so the + unchecked state has no dark treatment from anywhere else. The `p` rule + above paints every paragraph in primary text, which would make the option + descriptions read as body copy. */ + .components-radio-control__input[type='radio']:not( :checked ) { + border-color: var( --color-frame-border ); + background-color: var( --color-frame-surface ); + } + .components-radio-control__option-description { + color: var( --color-frame-text-secondary ); + } + /* Checkbox / Toggle */ .components-checkbox-control__input[type='checkbox'], input[type='checkbox'] { diff --git a/apps/studio/src/modules/add-site/components/create-site-form.tsx b/apps/studio/src/modules/add-site/components/create-site-form.tsx index 72a78ed9a5..d7df319bc4 100644 --- a/apps/studio/src/modules/add-site/components/create-site-form.tsx +++ b/apps/studio/src/modules/add-site/components/create-site-form.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants'; +import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants'; import { generateCustomDomainFromSiteName, getDomainNameValidationError, @@ -19,7 +19,6 @@ import { type SiteRuntime, } from '@studio/common/lib/site-runtime'; import { getAutoUpdateVersionLabel } from '@studio/common/lib/wordpress-version-labels'; -import { getLatestVersionLabel } from '@studio/common/lib/wordpress-versions'; import { RecommendedPHPVersion, SupportedPHPVersion, @@ -35,13 +34,13 @@ import Button from 'src/components/button'; import { FormPathInputComponent } from 'src/components/form-path-input'; import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more'; import PasswordControl from 'src/components/password-control'; +import { SettingsSection } from 'src/components/settings-section'; import { SiteFormError } from 'src/components/site-form-error'; import TextControlComponent from 'src/components/text-control'; import { WPVersionSelector } from 'src/components/wp-version-selector'; import { cx } from 'src/lib/cx'; import { FileAccessDescription, RuntimeDescription } from 'src/lib/site-runtime-copy'; import { useCheckCertificateTrustQuery } from 'src/stores/certificate-trust-api'; -import { useGetWordPressVersions } from 'src/stores/wordpress-versions-api'; import type { BlueprintPreferredVersions } from '@studio/common/lib/blueprint-validation'; import type { CreateSiteFormValues, PathValidationResult } from 'src/hooks/use-add-site'; @@ -93,9 +92,6 @@ export const CreateSiteForm = ( { }: CreateSiteFormProps ) => { const { __, isRTL } = useI18n(); const { data: isCertificateTrusted } = useCheckCertificateTrustQuery(); - const { data: wpVersions } = useGetWordPressVersions( { - minimumVersion: MINIMUM_WORDPRESS_VERSION, - } ); const [ siteName, setSiteName ] = useState( defaultValues.siteName ?? '' ); const [ sitePath, setSitePath ] = useState( defaultValues.sitePath ?? '' ); const [ phpVersion, setPhpVersion ] = useState< SupportedPHPVersion >( @@ -477,32 +473,50 @@ export const CreateSiteForm = ( { isAdvancedSettingsVisible ? 'h-auto opacity-100' : 'h-0 opacity-0' ) } > -
- - - { createInterpolateElement( - __( - 'Select an empty directory or a directory with an existing WordPress site. ' - ), - { - learn_more_link: , + +
+ + + { createInterpolateElement( + __( + 'Select an empty directory or a directory with an existing WordPress site. ' + ), + { + learn_more_link: , + } + ) } + + - -
+ error={ pathError } + value={ sitePath } + onClick={ handleSelectPath } + id="local-path" + /> +
+ +
+ +
+ + + +
- - -
- -
+
-
- { __( 'Admin credentials' ) } -
-
- - { - hasUserEditedCredentials.current = true; - setAdminUsername( value ); - } } - className={ adminUsernameError ? '[&_input]:!border-red-500' : '' } - /> -
- -
- - { - hasUserEditedCredentials.current = true; - setAdminPassword( value ); - } } - className={ adminPasswordError ? '[&_input]:!border-red-500' : '' } - /> -
+
+ { __( 'Admin credentials' ) } +
+
+ + { + hasUserEditedCredentials.current = true; + setAdminUsername( value ); + } } + className={ adminUsernameError ? '[&_input]:!border-red-500' : '' } + />
- { ( adminUsernameError || adminPasswordError ) && ( - - { adminUsernameError || adminPasswordError } - - ) } -
-
- - { - hasUserEditedCredentials.current = true; - setAdminEmail( value ); - } } - placeholder="admin@localhost.com" - className={ adminEmailError ? '[&_input]:!border-red-500' : '' } - /> - { adminEmailError && ( - { adminEmailError } - ) } +
+ + { + hasUserEditedCredentials.current = true; + setAdminPassword( value ); + } } + className={ adminPasswordError ? '[&_input]:!border-red-500' : '' } + /> +
- - { showBlueprintVersionWarning && ( - - { __( 'Version differs from Blueprint recommendation' ) } -
- { __( 'This Blueprint recommends:' ) } -
    - { showPhpVersionWarning && ( -
  • - { sprintf( - /* translators: %1$s: recommended PHP version, %2$s: default PHP version */ - __( 'PHP %s (selected is %s)' ), - blueprintPreferredVersions?.php as string, - phpVersion - ) } -
  • - ) } - { showWpVersionWarning && ( -
  • - { sprintf( - /* translators: %1$s: recommended WordPress version, %2$s: default WordPress version */ - __( 'WordPress %s (selected is %s)' ), - blueprintPreferredVersions?.wp as string, - wpVersion - ) } -
  • - ) } -
- { __( 'Using different versions may cause compatibility issues.' ) } -
+ { ( adminUsernameError || adminPasswordError ) && ( + + { adminUsernameError || adminPasswordError } + ) } +
-
- setUseCustomDomain( e.target.checked ) } - /> - -
- - { blueprintRequiresCustomDomain && ( - - { __( 'WordPress multisite requires a custom domain.' ) } - +
+ + { + hasUserEditedCredentials.current = true; + setAdminEmail( value ); + } } + placeholder="admin@localhost.com" + className={ adminEmailError ? '[&_input]:!border-red-500' : '' } + /> + { adminEmailError && ( + { adminEmailError } ) } +
-
- { __( 'Your system password will be required to set up the domain.' ) } -
+ { showBlueprintVersionWarning && ( + + { __( 'Version differs from Blueprint recommendation' ) } +
+ { __( 'This Blueprint recommends:' ) } +
    + { showPhpVersionWarning && ( +
  • + { sprintf( + /* translators: %1$s: recommended PHP version, %2$s: default PHP version */ + __( 'PHP %s (selected is %s)' ), + blueprintPreferredVersions?.php as string, + phpVersion + ) } +
  • + ) } + { showWpVersionWarning && ( +
  • + { sprintf( + /* translators: %1$s: recommended WordPress version, %2$s: default WordPress version */ + __( 'WordPress %s (selected is %s)' ), + blueprintPreferredVersions?.wp as string, + wpVersion + ) } +
  • + ) } +
+ { __( 'Using different versions may cause compatibility issues.' ) } +
+ ) } - { useCustomDomain && ( - <> -
- - - { customDomainError && } -
+
+ setUseCustomDomain( e.target.checked ) } + /> + +
-
- setEnableHttps( e.target.checked ) } - /> - -
+ { blueprintRequiresCustomDomain && ( + + { __( 'WordPress multisite requires a custom domain.' ) } + + ) } - { ! isCertificateTrusted && ( -
- { __( - 'You need to manually add the Studio root certificate authority to your keychain and trust it to enable HTTPS.' - ) }{ ' ' } - -
- ) } - - ) } +
+ { __( 'Your system password will be required to set up the domain.' ) }
+ + { useCustomDomain && ( + <> +
+ + + { customDomainError && } +
+ +
+ setEnableHttps( e.target.checked ) } + /> + +
+ + { ! isCertificateTrusted && ( +
+ { __( + 'You need to manually add the Studio root certificate authority to your keychain and trust it to enable HTTPS.' + ) }{ ' ' } + +
+ ) } + + ) }
) } diff --git a/apps/studio/src/modules/add-site/tests/add-site.test.tsx b/apps/studio/src/modules/add-site/tests/add-site.test.tsx index 30295d6cdd..b59432585d 100644 --- a/apps/studio/src/modules/add-site/tests/add-site.test.tsx +++ b/apps/studio/src/modules/add-site/tests/add-site.test.tsx @@ -385,7 +385,9 @@ describe( 'AddSite', () => { expect( screen.getByText( '/default_path/my-wordpress-website-mutated' ) ).toBeVisible(); } ); - it( 'should name the version a new site will be created with', async () => { + // The update-mode radios replace the auto-update dropdown option, and the + // site does not exist yet, so the description cannot claim a version is in use. + it( 'should describe automatic updates without naming a version', async () => { const user = userEvent.setup(); mockGenerateProposedSitePath.mockResolvedValue( { path: '/default_path/my-wordpress-website', @@ -402,7 +404,9 @@ describe( 'AddSite', () => { await user.click( screen.getByRole( 'button', { name: 'Continue' } ) ); await user.click( screen.getByRole( 'button', { name: 'Advanced settings' } ) ); - expect( screen.getByRole( 'option', { name: 'Auto-update (6.4)' } ) ).toBeInTheDocument(); + expect( + screen.getByRole( 'radio', { name: 'Automatic updates' } ) + ).toHaveAccessibleDescription( 'WordPress installs updates on its own schedule.' ); } ); it( 'should display WordPress version dropdown', async () => { @@ -426,13 +430,11 @@ describe( 'AddSite', () => { expect( screen.getByText( 'WordPress version' ) ).toBeInTheDocument(); - const comboboxes = screen.getAllByRole( 'combobox' ); - expect( comboboxes.length ).toBeGreaterThanOrEqual( 2 ); - - const wpVersionDropdown = comboboxes[ 1 ]; - expect( wpVersionDropdown ).toBeInTheDocument(); + // New sites auto-update until the user picks a version. + expect( screen.getByRole( 'radio', { name: 'Automatic updates' } ) ).toBeChecked(); - await user.selectOptions( wpVersionDropdown, '6.3.3' ); + await user.click( screen.getByRole( 'radio', { name: 'Select a version' } ) ); + await user.selectOptions( screen.getByLabelText( 'Version' ), '6.3.3' ); mockShowOpenFolderDialog.mockResolvedValue( { path: 'test', @@ -539,15 +541,9 @@ describe( 'AddSite', () => { await user.click( screen.getByRole( 'button', { name: 'Advanced settings' } ) ); - expect( screen.getByText( 'PHP version' ) ).toBeInTheDocument(); - - const comboboxes = screen.getAllByRole( 'combobox' ); - expect( comboboxes.length ).toBeGreaterThanOrEqual( 2 ); - - const phpVersionDropdown = comboboxes[ 0 ]; - expect( phpVersionDropdown ).toBeInTheDocument(); - - await user.selectOptions( phpVersionDropdown, '8.2' ); + // By label, not by position: the WordPress version control sits above + // PHP version now, so a positional lookup picks the wrong select. + await user.selectOptions( screen.getByLabelText( 'PHP version' ), '8.2' ); mockShowOpenFolderDialog.mockResolvedValue( { path: 'test', @@ -577,8 +573,7 @@ describe( 'AddSite', () => { await user.click( screen.getByRole( 'button', { name: 'Continue' } ) ); await user.click( screen.getByRole( 'button', { name: 'Advanced settings' } ) ); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); - expect( wpVersionSelect ).toBeDisabled(); + expect( screen.getByRole( 'radio', { name: 'Automatic updates' } ) ).toBeDisabled(); } ); it( 'should enable WordPress version field when online', async () => { @@ -593,8 +588,7 @@ describe( 'AddSite', () => { await user.click( screen.getByRole( 'button', { name: 'Continue' } ) ); await user.click( screen.getByRole( 'button', { name: 'Advanced settings' } ) ); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); - expect( wpVersionSelect ).toBeEnabled(); + expect( screen.getByRole( 'radio', { name: 'Automatic updates' } ) ).toBeEnabled(); } ); it( 'should show tooltip with offline message when hovering over disabled WordPress version field', async () => { @@ -609,8 +603,7 @@ describe( 'AddSite', () => { await user.click( screen.getByRole( 'button', { name: 'Continue' } ) ); await user.click( screen.getByRole( 'button', { name: 'Advanced settings' } ) ); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); - await user.hover( wpVersionSelect ); + await user.hover( screen.getByRole( 'radio', { name: 'Automatic updates' } ) ); expect( screen.getByText( @@ -631,8 +624,7 @@ describe( 'AddSite', () => { await user.click( screen.getByRole( 'button', { name: 'Continue' } ) ); await user.click( screen.getByRole( 'button', { name: 'Advanced settings' } ) ); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); - await user.hover( wpVersionSelect ); + await user.hover( screen.getByRole( 'radio', { name: 'Automatic updates' } ) ); expect( screen.queryByText( diff --git a/apps/studio/src/modules/site-settings/edit-site-details.tsx b/apps/studio/src/modules/site-settings/edit-site-details.tsx index 66051efa5f..d49e59245f 100644 --- a/apps/studio/src/modules/site-settings/edit-site-details.tsx +++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx @@ -39,6 +39,7 @@ import { ErrorInformation } from 'src/components/error-information'; import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more'; import Modal from 'src/components/modal'; import PasswordControl from 'src/components/password-control'; +import { SettingsSection } from 'src/components/settings-section'; import { AgentInstructionsPanel, WordPressSkillsPanel } from 'src/components/site-settings-panels'; import TextControlComponent from 'src/components/text-control'; import { Tooltip } from 'src/components/tooltip'; @@ -388,19 +389,46 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) =
{ name === 'general' && ( <> - + + -
+
+ +
+ { errorUpdatingWpVersion && ( + + { errorUpdatingWpVersion } + + ) } + + + - -
- { errorUpdatingWpVersion && ( - - { errorUpdatingWpVersion } - - ) } - -
- +
+ - -
+ + + + +
+
diff --git a/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx b/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx index e198357714..8f93dfc06d 100644 --- a/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx +++ b/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx @@ -69,6 +69,15 @@ vi.mock( 'src/hooks/use-offline', () => ( { useOffline: vi.fn().mockReturnValue( false ), } ) ); +/** The picker only applies once "Select a version" is the chosen mode. */ +const pinVersion = async ( + user: ReturnType< typeof userEvent.setup >, + version: string +): Promise< void > => { + await user.click( screen.getByRole( 'radio', { name: 'Select a version' } ) ); + await user.selectOptions( screen.getByLabelText( 'Version' ), version ); +}; + const renderWithProvider = ( children: React.ReactElement ) => { const store = createTestStore( { preloadedState: { @@ -148,12 +157,16 @@ describe( 'EditSiteDetails', () => { expect( screen.getByLabelText( 'Site name' ) ).toHaveValue( 'Test Site' ); expect( screen.getByLabelText( 'PHP version' ) ).toHaveValue( '8.4' ); - expect( screen.getByLabelText( 'WordPress version' ) ).toHaveValue( 'latest' ); - expect( screen.getByRole( 'option', { name: 'Auto-update (6.3)' } ) ).toBeInTheDocument(); - expect( screen.getByRole( 'group', { name: 'Stable Versions' } ) ).toBeInTheDocument(); + const automatic = screen.getByRole( 'radio', { name: 'Automatic updates' } ); + expect( automatic ).toBeChecked(); + // Forms mode announces only the radio's label and description. + expect( automatic ).toHaveAccessibleDescription( + 'WordPress installs updates on its own schedule. Currently using version 6.3.' + ); + expect( screen.getByLabelText( 'Version' ) ).toHaveValue( '6.3' ); } ); - it( 'should omit the installed version from the auto-update option for pinned sites', async () => { + it( 'should show the version picker for pinned sites', async () => { vi.mocked( useSiteDetails ).mockReturnValue( createMock< ReturnType< typeof useSiteDetails > >( { ...baseMockSiteDetails, @@ -168,8 +181,12 @@ describe( 'EditSiteDetails', () => { expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); } ); - expect( screen.getByRole( 'option', { name: 'Auto-update' } ) ).toBeInTheDocument(); - expect( screen.queryByRole( 'option', { name: 'Auto-update (6.3)' } ) ).not.toBeInTheDocument(); + expect( screen.getByRole( 'radio', { name: 'Select a version' } ) ).toBeChecked(); + expect( screen.getByLabelText( 'Version' ) ).toHaveValue( '6.3' ); + expect( screen.getByRole( 'group', { name: 'Stable Versions' } ) ).toBeInTheDocument(); + // Naming the version under "Automatic updates" on a pinned site would + // read as if auto-update were keeping the site on it (STU-2348). + expect( screen.queryByText( /Currently using version/ ) ).not.toBeInTheDocument(); } ); it( 'should name the installed version as soon as a pinned site selects auto-update', async () => { @@ -187,9 +204,9 @@ describe( 'EditSiteDetails', () => { expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); } ); - await userEvent.setup().selectOptions( screen.getByLabelText( 'WordPress version' ), 'latest' ); + await userEvent.setup().click( screen.getByRole( 'radio', { name: 'Automatic updates' } ) ); - expect( screen.getByRole( 'option', { name: 'Auto-update (6.3)' } ) ).toBeInTheDocument(); + expect( screen.getByText( /Currently using version 6\.3\./ ) ).toBeInTheDocument(); } ); it( 'should close the modal when cancel button is clicked', async () => { @@ -314,8 +331,7 @@ describe( 'EditSiteDetails', () => { } ); const user = userEvent.setup(); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); - await user.selectOptions( wpVersionSelect, '6.4' ); + await pinVersion( user, '6.4' ); expect( screen.getByRole( 'button', { name: 'Save' } ) ).toBeEnabled(); } ); @@ -403,8 +419,7 @@ describe( 'EditSiteDetails', () => { } ); const user = userEvent.setup(); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); - await user.selectOptions( wpVersionSelect, '6.4' ); + await pinVersion( user, '6.4' ); await user.click( screen.getByRole( 'button', { name: 'Save' } ) ); @@ -430,8 +445,7 @@ describe( 'EditSiteDetails', () => { } ); const user = userEvent.setup(); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); - await user.selectOptions( wpVersionSelect, '6.8-beta1' ); + await pinVersion( user, '6.8-beta1' ); await user.click( screen.getByRole( 'button', { name: 'Save' } ) ); @@ -457,8 +471,7 @@ describe( 'EditSiteDetails', () => { } ); const user = userEvent.setup(); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); - await user.selectOptions( wpVersionSelect, '6.4' ); + await pinVersion( user, '6.4' ); await user.click( screen.getByRole( 'button', { name: 'Save' } ) ); @@ -503,7 +516,7 @@ describe( 'EditSiteDetails', () => { } ); expect( screen.getByLabelText( 'Site name' ) ).toBeDisabled(); expect( screen.getByLabelText( 'PHP version' ) ).toBeDisabled(); - expect( screen.getByLabelText( 'WordPress version' ) ).toBeDisabled(); + expect( screen.getByRole( 'radio', { name: 'Automatic updates' } ) ).toBeDisabled(); expect( screen.getByRole( 'button', { name: 'Cancel' } ) ).toBeDisabled(); resolveUpdate(); @@ -528,8 +541,7 @@ describe( 'EditSiteDetails', () => { expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); } ); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); - expect( wpVersionSelect ).toBeDisabled(); + expect( screen.getByRole( 'radio', { name: 'Automatic updates' } ) ).toBeDisabled(); } ); it( 'should enable WordPress version field when online', async () => { @@ -546,8 +558,7 @@ describe( 'EditSiteDetails', () => { expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); } ); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); - expect( wpVersionSelect ).toBeEnabled(); + expect( screen.getByRole( 'radio', { name: 'Automatic updates' } ) ).toBeEnabled(); } ); it( 'should show tooltip with offline message when hovering over disabled WordPress version field', async () => { @@ -565,8 +576,7 @@ describe( 'EditSiteDetails', () => { } ); const user = userEvent.setup(); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); - await user.hover( wpVersionSelect ); + await user.hover( screen.getByRole( 'radio', { name: 'Automatic updates' } ) ); expect( screen.getByText( 'Changing WordPress version requires an internet connection.' ) @@ -585,8 +595,7 @@ describe( 'EditSiteDetails', () => { renderWithProvider( ); const user = userEvent.setup(); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); - await user.hover( wpVersionSelect ); + await user.hover( screen.getByRole( 'radio', { name: 'Automatic updates' } ) ); expect( screen.queryByText( 'Changing WordPress version requires an internet connection.' ) diff --git a/apps/ui/src/components/create-site-form/index.test.tsx b/apps/ui/src/components/create-site-form/index.test.tsx index 90b675b980..65a0ad3dc6 100644 --- a/apps/ui/src/components/create-site-form/index.test.tsx +++ b/apps/ui/src/components/create-site-form/index.test.tsx @@ -465,12 +465,16 @@ describe( 'CreateSiteForm', () => { renderForm( { name: 'Old Blueprint', wpVersion: '4.9' } ); openAdvancedSettings(); + // Auto-update is the default, so no version is pinned. await waitFor( () => - expect( screen.getByLabelText( 'WordPress version' ) ).toHaveValue( 'latest' ) + expect( screen.getByRole( 'radio', { name: 'Automatic updates' } ) ).toBeChecked() ); } ); - it( 'names the version a new site will be created with in the auto-update option', () => { + // The update-mode radios replace the auto-update dropdown option, and the + // site does not exist yet, so the description cannot claim a version is in + // use. Naming the version a new site will get is left to the picker. + it( 'describes automatic updates without naming a version on the create form', () => { useWordPressVersionsMock.mockReturnValue( { data: [ { label: '6.8', value: 'latest', isBeta: false, isDevelopment: false }, @@ -480,7 +484,9 @@ describe( 'CreateSiteForm', () => { renderForm( { name: 'New site' } ); openAdvancedSettings(); - expect( screen.getByRole( 'option', { name: 'Auto-update (6.8)' } ) ).toBeInTheDocument(); + expect( + screen.getByRole( 'radio', { name: 'Automatic updates' } ) + ).toHaveAccessibleDescription( 'WordPress installs updates on its own schedule.' ); } ); it( 'uses a stable select control for long WordPress version lists', () => { @@ -497,8 +503,25 @@ describe( 'CreateSiteForm', () => { } ); renderForm( { name: 'Versioned site' } ); openAdvancedSettings(); + expect( screen.getByLabelText( 'Version' ).tagName ).toBe( 'SELECT' ); + } ); + + it( 'pins to the newest stable release when automatic updates are turned off', () => { + useWordPressVersionsMock.mockReturnValue( { + data: [ + { label: '6.9', value: 'latest', isBeta: false, isDevelopment: false }, + { label: '7.0-beta1', value: '7.0-beta1', isBeta: true, isDevelopment: false }, + { label: '6.9', value: '6.9', isBeta: false, isDevelopment: false }, + { label: '6.8', value: '6.8', isBeta: false, isDevelopment: false }, + ], + } ); + renderForm( { name: 'Pinned site' } ); + openAdvancedSettings(); + fireEvent.click( screen.getByRole( 'radio', { name: 'Select a version' } ) ); - expect( screen.getByLabelText( 'WordPress version' ).tagName ).toBe( 'SELECT' ); + // The list is newest-first, so the first stable entry is the newest one — + // a prerelease would be a surprising default for a new site. + expect( screen.getByLabelText( 'Version' ) ).toHaveValue( '6.9' ); } ); it( 'locks the WordPress version to a disabled "latest" select while offline', async () => { diff --git a/apps/ui/src/components/create-site-form/index.tsx b/apps/ui/src/components/create-site-form/index.tsx index f35c38fdc6..1e25e20977 100644 --- a/apps/ui/src/components/create-site-form/index.tsx +++ b/apps/ui/src/components/create-site-form/index.tsx @@ -502,23 +502,32 @@ export function CreateSiteForm( { layout: { type: 'regular', labelPosition: 'top' }, fields: [ { - id: 'path', - layout: { type: 'regular', labelPosition: 'top' }, + id: 'siteDetails', + label: __( 'Site details' ), + layout: { type: 'card', withHeader: true, isCollapsible: false }, + children: [ + { id: 'path', layout: { type: 'regular', labelPosition: 'top' } }, + 'wpVersion', + ], }, { - id: 'versions', - layout: { type: 'row', alignment: 'start' }, - children: [ 'phpVersion', 'wpVersion' ], + id: 'phpEnvironment', + label: __( 'PHP environment' ), + layout: { type: 'card', withHeader: true, isCollapsible: false }, + children: [ 'phpVersion' ], }, { - id: 'adminCredentials', - layout: { type: 'row', alignment: 'start' }, - children: [ 'adminUsername', 'adminPassword' ], + id: 'wordpressAdmin', + label: __( 'WordPress admin' ), + layout: { type: 'card', withHeader: true, isCollapsible: false }, + children: [ 'adminUsername', 'adminPassword', 'adminEmail' ], + }, + { + id: 'domain', + label: __( 'Domain' ), + layout: { type: 'card', withHeader: true, isCollapsible: false }, + children: [ 'useCustomDomain', 'customDomain', 'enableHttps' ], }, - 'adminEmail', - 'useCustomDomain', - 'customDomain', - 'enableHttps', ], } ), [] diff --git a/apps/ui/src/components/site-fields/compact-select-control.tsx b/apps/ui/src/components/site-fields/compact-select-control.tsx new file mode 100644 index 0000000000..7f8a17ac3b --- /dev/null +++ b/apps/ui/src/components/site-fields/compact-select-control.tsx @@ -0,0 +1,32 @@ +import { SelectControl } from '@wordpress/components'; +import styles from './style.module.css'; +import type { DataFormControlProps, Option } from '@wordpress/dataviews'; + +/** + * Select for fields whose values are only a few characters wide, like a version + * number. The default control spans the form, which leaves the value stranded + * beside a long empty box and breaks the column rhythm on wide screens. + */ +export function CompactSelectControl< Item >( { + data, + field, + onChange, + hideLabelFromVision, +}: DataFormControlProps< Item > ) { + const value = field.getValue( { item: data } ) ?? ''; + return ( + + onChange( field.setValue( { item: data, value: newValue } ) ) + } + options={ ( field.elements ?? [] ) as Option[] } + /> + ); +} diff --git a/apps/ui/src/components/site-fields/index.test.ts b/apps/ui/src/components/site-fields/index.test.ts index 8fae2f717c..1c568a56bb 100644 --- a/apps/ui/src/components/site-fields/index.test.ts +++ b/apps/ui/src/components/site-fields/index.test.ts @@ -22,18 +22,28 @@ function autoUpdateLabels( field: ReturnType< typeof wpVersionField > ): string[ } describe( 'wpVersionField', () => { + it( 'keeps the seed for a pinned site out of the auto-update group', () => { + const field = wpVersionField( DEFAULT_WORDPRESS_VERSION, VERSIONS, { latestValue: '' } ); + + // The settings form seeds this value when a pinned site's installed + // version can't be read. It has to stay selectable and unoffered, and it + // must not claim the site auto-updates — the update-mode radios read the + // auto-update group to decide what the picker can offer. + expect( optionsOf( field ) ).toContainEqual( { + value: DEFAULT_WORDPRESS_VERSION, + label: 'Unknown version', + group: 'stable', + hidden: true, + } ); + } ); + it( 'names the installed version in the auto-update option', () => { const field = wpVersionField( DEFAULT_WORDPRESS_VERSION, VERSIONS, { latestValue: '', autoUpdateVersion: '6.9.7', } ); - expect( autoUpdateLabels( field ) ).toEqual( [ - 'Auto-update (6.9.7)', - // The hidden `latest` fallback has to match, or a pinned site whose - // files can't be read renders a differently worded option. - 'Auto-update (6.9.7)', - ] ); + expect( autoUpdateLabels( field ) ).toEqual( [ 'Auto-update (6.9.7)' ] ); } ); it( 'falls back to the bare mode name when the version is unknown', () => { @@ -44,7 +54,7 @@ describe( 'wpVersionField', () => { autoUpdateVersion, } ); - expect( autoUpdateLabels( field ) ).toEqual( [ 'Auto-update', 'Auto-update' ] ); + expect( autoUpdateLabels( field ) ).toEqual( [ 'Auto-update' ] ); } } ); @@ -61,7 +71,7 @@ describe( 'wpVersionField', () => { const options = optionsOf( field ); expect( options.filter( ( option ) => option.group === 'prerelease' ) ).toEqual( [ - { value: '7.2-alpha-63347', label: 'nightly', group: 'prerelease' }, + { value: '7.2-alpha-63347', label: 'nightly', group: 'prerelease', current: false }, ] ); expect( options.filter( ( option ) => option.group === 'stable' ).map( ( o ) => o.value ) diff --git a/apps/ui/src/components/site-fields/index.ts b/apps/ui/src/components/site-fields/index.ts index 5c67813ff6..33b4b359b7 100644 --- a/apps/ui/src/components/site-fields/index.ts +++ b/apps/ui/src/components/site-fields/index.ts @@ -19,6 +19,7 @@ import { } from '@studio/common/lib/wordpress-version-utils'; import { SupportedPHPVersions } from '@studio/common/types/php-versions'; import { __ } from '@wordpress/i18n'; +import { CompactSelectControl } from '@/components/site-fields/compact-select-control'; import { WpVersionControl } from '@/components/site-fields/wp-version-control'; import type { WpVersionOption } from '@/components/site-fields/wp-version-control'; import type { WordPressVersion } from '@studio/common/lib/wordpress-versions'; @@ -45,6 +46,7 @@ export function phpVersionField< T extends { phpVersion: SupportedPHPVersion } > type: 'text', label: __( 'PHP version' ), elements: PHP_VERSION_ELEMENTS, + Edit: CompactSelectControl, }; } @@ -111,36 +113,28 @@ export function wpVersionField< T extends { wpVersion: string } >( // only versions we can actually install. Otherwise the create form keeps // its free-text fallback. if ( offers.length || latestValue !== DEFAULT_WORDPRESS_VERSION || offline ) { - let prerelease: WpVersionOption[] = offers + const toOption = ( + { value, label }: { value: string; label: string }, + group: WpVersionOption[ 'group' ] + ): WpVersionOption => ( { value, label, group, current: value === currentVersion } ); + let prerelease = offers .filter( ( version ) => version.isBeta || version.isDevelopment ) - .map( ( version ) => ( { - value: version.value, - label: version.label, - group: 'prerelease' as const, - } ) ); - let stable: WpVersionOption[] = offers + .map( ( version ) => toOption( version, 'prerelease' ) ); + let stable = offers .filter( ( version ) => version.value !== DEFAULT_WORDPRESS_VERSION && ! version.isBeta && ! version.isDevelopment ) - .map( ( version ) => ( { - value: version.value, - label: version.label, - group: 'stable' as const, - } ) ); + .map( ( version ) => toOption( version, 'stable' ) ); // The site's installed version may predate the fetched offers — keep it // selectable, sorted into the right group, like the legacy selector's // extraOptions. if ( currentVersion && ! offers.some( ( version ) => version.value === currentVersion ) ) { - const option: WpVersionOption = { - value: currentVersion, - label: currentVersion, - group: 'stable', - }; + const offer = { value: currentVersion, label: currentVersion }; if ( isWordPressBetaVersion( currentVersion ) || isWordPressDevVersion( currentVersion ) ) { - prerelease = addVersionOption( { ...option, group: 'prerelease' }, prerelease ); + prerelease = addVersionOption( toOption( offer, 'prerelease' ), prerelease ); } else { - stable = addVersionOption( option, stable ); + stable = addVersionOption( toOption( offer, 'stable' ), stable ); } } const autoUpdateLabel = getAutoUpdateVersionLabel( autoUpdateVersion ); @@ -153,11 +147,13 @@ export function wpVersionField< T extends { wpVersion: string } >( // The settings form maps "latest" to '' (auto-update) but falls back // to seeding pinned sites with DEFAULT_WORDPRESS_VERSION when their // installed version can't be read. Keep that seed renderable without - // offering it. + // offering it, and out of the auto-update group: the site is pinned, + // so an "Auto-update" readout there would be wrong. options.push( { value: DEFAULT_WORDPRESS_VERSION, - label: autoUpdateLabel, - group: 'latest', + /* translators: WordPress version option for a pinned site whose installed version Studio cannot read. */ + label: __( 'Unknown version' ), + group: 'stable', hidden: true, } ); } diff --git a/apps/ui/src/components/site-fields/style.module.css b/apps/ui/src/components/site-fields/style.module.css index 7aff2c6ae3..200770e3db 100644 --- a/apps/ui/src/components/site-fields/style.module.css +++ b/apps/ui/src/components/site-fields/style.module.css @@ -9,3 +9,27 @@ flex-shrink: 0; fill: currentColor; } + +.updateModeControl { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-padding-sm); + min-inline-size: 0; + margin: 0; + padding: 0; + border: 0; +} + +.updateModeControl :global(.components-radio-control) { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-padding-lg); +} + +.pinnedVersionSelect { + width: min(180px, 100%); +} + +.compactSelect { + width: min(180px, 100%); +} diff --git a/apps/ui/src/components/site-fields/wp-version-control.tsx b/apps/ui/src/components/site-fields/wp-version-control.tsx index cebb23f481..912610985e 100644 --- a/apps/ui/src/components/site-fields/wp-version-control.tsx +++ b/apps/ui/src/components/site-fields/wp-version-control.tsx @@ -1,8 +1,13 @@ -import { SelectControl } from '@wordpress/components'; +import { + getAutomaticUpdatesDescription, + getAutomaticUpdatesLabel, + getSelectAVersionLabel, +} from '@studio/common/lib/wordpress-version-labels'; +import { BaseControl, SelectControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { Icon } from '@wordpress/icons'; import { Tooltip } from '@wordpress/ui'; -import { useState } from 'react'; +import { useId, useState } from 'react'; import styles from './style.module.css'; import type { DataFormControlProps, Option } from '@wordpress/dataviews'; @@ -21,13 +26,16 @@ const offlineIcon = ( export type WpVersionOption = Option & { group: 'latest' | 'prerelease' | 'stable'; hidden?: boolean; + current?: boolean; }; /** - * WordPress version dropdown: the auto-update option first, then the pinned - * versions in optgroups. When the field is disabled with a description - * (offline), the description shows as a hover tooltip like the legacy UI - * instead of inline help text. + * WordPress version control. Whenever there are versions to pin to, it splits + * into "Automatic updates" and "Select a version" radios, with the version + * dropdown nested under the second one; without a version list it degrades to a + * single dropdown carrying the auto-update option. When the field is disabled + * with a description (offline), the description shows as a hover tooltip like + * the legacy UI instead of inline help text. */ export function WpVersionControl< Item >( { data, @@ -39,10 +47,29 @@ export function WpVersionControl< Item >( { // selector) because Base UI's hover detection doesn't fire over a // disabled form control. const [ showTooltip, setShowTooltip ] = useState( false ); + // Unique per instance so two controls on one screen keep their own radio + // group and their own label/description associations. + const modeControlName = useId(); const value = field.getValue( { item: data } ) ?? ''; const disabled = field.isDisabled( { item: data, field } ); const options = ( field.elements ?? [] ) as WpVersionOption[]; - const autoUpdateOptions = options.filter( ( option ) => option.group === 'latest' ); + const autoUpdateOption = options.find( ( option ) => option.group === 'latest' ); + const pinnedOptions = options.filter( + ( option ) => option.group !== 'latest' && ! option.hidden + ); + const currentVersion = pinnedOptions.find( ( option ) => option.current ); + // Leaving auto-update lands on the version the site already runs, or on the + // newest stable release for a site that doesn't exist yet. The list is + // newest-first, so the first stable entry is the newest one — a prerelease + // would be a surprising default. + const defaultPinnedOption = + currentVersion ?? + pinnedOptions.find( ( option ) => option.group === 'stable' ) ?? + pinnedOptions[ 0 ]; + // Without a version list there is nothing to pin to, so the form keeps the + // plain dropdown rather than offering an empty picker. + const usesUpdateModeControl = !! autoUpdateOption && !! defaultPinnedOption; + const automaticUpdates = value === autoUpdateOption?.value; const groups = [ { label: __( 'Beta & Nightly' ), @@ -54,21 +81,26 @@ export function WpVersionControl< Item >( { }, ].filter( ( group ) => group.options.length > 0 ); - const select = ( + const updateValue = ( newValue: string ) => + onChange( field.setValue( { item: data, value: newValue } ) ); + const renderVersionSelect = () => ( onChange( field.setValue( { item: data, value: newValue } ) ) } + onChange={ updateValue } > - { autoUpdateOptions.map( ( option ) => ( - - ) ) } + { /* Outside the update-mode control the auto-update option carries its own + explanation, so it needs no heading; HTML allows plain options before + the first optgroup. */ } + { ! usesUpdateModeControl && autoUpdateOption && ( + + ) } { groups.map( ( group ) => ( { group.options.map( ( option ) => ( @@ -80,6 +112,75 @@ export function WpVersionControl< Item >( { ) ) } ); + const control = + autoUpdateOption && defaultPinnedOption ? ( + +
+
+
+ updateValue( autoUpdateOption.value ) } + /> + +

+ { /* Naming a version on a pinned site would read as if + auto-update were keeping it there. */ } + { getAutomaticUpdatesDescription( + automaticUpdates ? currentVersion?.value : undefined + ) } +

+
+
+ updateValue( defaultPinnedOption.value ) } + /> + +
+ { renderVersionSelect() } +
+
+
+
+
+ ) : ( + renderVersionSelect() + ); if ( disabled && field.description ) { // wpds narrows Tooltip.Root's props to hover-only usage; the runtime @@ -98,7 +199,7 @@ export function WpVersionControl< Item >( { onMouseEnter={ () => setShowTooltip( true ) } onMouseLeave={ () => setShowTooltip( false ) } > -
{ select }
+
{ control }
} /> @@ -112,5 +213,5 @@ export function WpVersionControl< Item >( { ); } - return select; + return control; } diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx index d58ea7553c..027313f6b9 100644 --- a/apps/ui/src/components/site-overview-view/index.test.tsx +++ b/apps/ui/src/components/site-overview-view/index.test.tsx @@ -480,20 +480,28 @@ describe( 'SiteOverviewView', () => { ).not.toHaveClass( settingsStyles.emailControl ); } ); - it( 'renders the WordPress version dropdown with auto-update preselected for auto-updating sites', () => { + it( 'preselects automatic updates and names the installed version for auto-updating sites', () => { useWordPressVersionsMock.mockReturnValue( { data: WP_VERSIONS } ); useWpVersionMock.mockReturnValue( { data: '6.7.2' } ); renderView( 'general' ); - const select = screen.getByLabelText( 'WordPress version' ); + const automatic = screen.getByRole( 'radio', { name: 'Automatic updates' } ); + expect( automatic ).toBeChecked(); + // The readout names the version the site runs now, so "latest" can't be + // read as "already on the newest release" (STU-2348). It has to reach + // screen readers in forms mode too, where only the control's own label + // and description are announced. + expect( automatic ).toHaveAccessibleDescription( + 'WordPress installs updates on its own schedule. Currently using version 6.7.2.' + ); + const select = screen.getByLabelText( 'Version' ); expect( select.tagName ).toBe( 'SELECT' ); - expect( select ).toHaveValue( '' ); - expect( screen.getByRole( 'option', { name: 'Auto-update (6.7.2)' } ) ).toBeInTheDocument(); + expect( select ).toHaveValue( '6.7.2' ); expect( screen.getByRole( 'group', { name: 'Stable Versions' } ) ).toBeInTheDocument(); } ); - it( 'omits the installed version from the auto-update option for pinned sites', () => { + it( 'preselects the version picker for pinned sites', () => { useWordPressVersionsMock.mockReturnValue( { data: WP_VERSIONS } ); useWpVersionMock.mockReturnValue( { data: '6.7.2' } ); useSitesMock.mockReturnValue( { @@ -502,10 +510,11 @@ describe( 'SiteOverviewView', () => { renderView( 'general' ); - expect( screen.getByRole( 'option', { name: 'Auto-update' } ) ).toBeInTheDocument(); - expect( - screen.queryByRole( 'option', { name: 'Auto-update (6.7.2)' } ) - ).not.toBeInTheDocument(); + expect( screen.getByRole( 'radio', { name: 'Select a version' } ) ).toBeChecked(); + expect( screen.getByLabelText( 'Version' ) ).toHaveValue( '6.7.2' ); + // Naming the version under "Automatic updates" on a pinned site would + // read as if auto-update were keeping the site on it (STU-2348). + expect( screen.queryByText( /Currently using version/ ) ).not.toBeInTheDocument(); } ); it( 'saves a pinned WordPress version picked from the dropdown', () => { @@ -515,7 +524,8 @@ describe( 'SiteOverviewView', () => { renderView( 'general' ); - fireEvent.change( screen.getByLabelText( 'WordPress version' ), { + fireEvent.click( screen.getByRole( 'radio', { name: 'Select a version' } ) ); + fireEvent.change( screen.getByLabelText( 'Version' ), { target: { value: '6.7.2' }, } ); fireEvent.click( screen.getByRole( 'button', { name: 'Save settings' } ) ); @@ -539,7 +549,7 @@ describe( 'SiteOverviewView', () => { renderView( 'general' ); - const select = screen.getByLabelText( 'WordPress version' ); + const select = screen.getByLabelText( 'Version' ); expect( select ).toHaveValue( '6.5.2' ); expect( screen.getByRole( 'option', { name: '6.5.2' } ) ).toBeInTheDocument(); } ); @@ -584,7 +594,7 @@ describe( 'SiteOverviewView', () => { const { showSite } = renderView( 'general' ); - fireEvent.change( screen.getByLabelText( 'WordPress version' ), { + fireEvent.change( screen.getByLabelText( 'Version' ), { target: { value: '6.7.2' }, } ); @@ -595,7 +605,7 @@ describe( 'SiteOverviewView', () => { } ); showSite( 'site-1' ); - expect( screen.getByLabelText( 'WordPress version' ) ).toHaveValue( '6.7.2' ); + expect( screen.getByLabelText( 'Version' ) ).toHaveValue( '6.7.2' ); } ); it( 'keeps a pinned site pinned when saving other settings while offline', () => { @@ -627,9 +637,12 @@ describe( 'SiteOverviewView', () => { it( 'keeps the version field a dropdown when the version list is unavailable', () => { renderView( 'general' ); + // Nothing to pin to, so the update mode stays a plain dropdown rather + // than an empty picker. const select = screen.getByLabelText( 'WordPress version' ); expect( select.tagName ).toBe( 'SELECT' ); expect( select ).toHaveValue( '' ); + expect( screen.queryByRole( 'radio' ) ).not.toBeInTheDocument(); } ); // Offline only blocks *changing* the version, so the field stays on the @@ -644,7 +657,7 @@ describe( 'SiteOverviewView', () => { renderView( 'general' ); - const select = screen.getByLabelText( 'WordPress version' ); + const select = screen.getByLabelText( 'Version' ); expect( select.tagName ).toBe( 'SELECT' ); expect( select ).toBeDisabled(); expect( select ).toHaveValue( '6.5.2' ); @@ -673,10 +686,14 @@ describe( 'SiteOverviewView', () => { renderView( 'general' ); - const select = screen.getByLabelText( 'WordPress version' ); - expect( select ).toHaveValue( 'latest' ); + expect( screen.getByRole( 'radio', { name: 'Select a version' } ) ).toBeChecked(); + // The site is pinned to files Studio can't read a version from, so the + // picker says so instead of showing a version the site may not run. + const picker = screen.getByLabelText( 'Version' ) as HTMLSelectElement; + expect( picker ).toHaveValue( 'latest' ); + expect( picker.selectedOptions[ 0 ] ).toHaveTextContent( 'Unknown version' ); - fireEvent.change( select, { target: { value: '' } } ); + fireEvent.click( screen.getByRole( 'radio', { name: 'Automatic updates' } ) ); fireEvent.click( screen.getByRole( 'button', { name: 'Save settings' } ) ); // 'latest' has to reach the CLI so it actually installs the newest diff --git a/apps/ui/src/components/site-settings-view/index.tsx b/apps/ui/src/components/site-settings-view/index.tsx index baf8c6b65e..4b62fc25fb 100644 --- a/apps/ui/src/components/site-settings-view/index.tsx +++ b/apps/ui/src/components/site-settings-view/index.tsx @@ -195,21 +195,30 @@ export function SiteSettingsForm( { site, activeTab }: { site: SiteDetails; acti () => ( { layout: { type: 'regular', labelPosition: 'top' }, fields: [ - 'name', { - id: 'versions', - layout: { type: 'row', alignment: 'start' }, - children: [ 'phpVersion', 'wpVersion' ], + id: 'siteDetails', + label: __( 'Site details' ), + layout: { type: 'card', withHeader: true, isCollapsible: false }, + children: [ 'name', 'wpVersion' ], }, { - id: 'adminCredentials', - layout: { type: 'row', alignment: 'start' }, - children: [ 'adminUsername', 'adminPassword' ], + id: 'phpEnvironment', + label: __( 'PHP environment' ), + layout: { type: 'card', withHeader: true, isCollapsible: false }, + children: [ 'phpVersion' ], + }, + { + id: 'wordpressAdmin', + label: __( 'WordPress admin' ), + layout: { type: 'card', withHeader: true, isCollapsible: false }, + children: [ 'adminUsername', 'adminPassword', 'adminEmail' ], + }, + { + id: 'domain', + label: __( 'Domain' ), + layout: { type: 'card', withHeader: true, isCollapsible: false }, + children: [ 'useCustomDomain', 'customDomain', 'enableHttps' ], }, - 'adminEmail', - 'useCustomDomain', - 'customDomain', - 'enableHttps', ], } ), [] diff --git a/apps/ui/src/index.css b/apps/ui/src/index.css index 4595d477a6..34e409d8db 100644 --- a/apps/ui/src/index.css +++ b/apps/ui/src/index.css @@ -83,6 +83,22 @@ body:lang( ckb ) [class*='components-'] { color: var( --wpds-color-foreground-content-neutral-weak ) !important; } +/* Section grouping for the site forms. The `card` layout gives the sections + their headings declaratively, but its panel chrome reads as four stacked + boxes; flattening it to a hairline rule between sections matches the + settings design and keeps one rhythm down the form. */ +form .dataforms-layouts-card__field { + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + padding-inline: 0; +} + +form .dataforms-layouts-card__field + .dataforms-layouts-card__field { + border-top: 1px solid var( --wpds-color-stroke-surface-neutral ); +} + /* Universal heading styles derived from the design system's font-size / line-height ramp. Each level steps down one token (h1 = 2xl, h6 = xs), keeping the rest of the type contract — family, weight, color, zero @@ -271,11 +287,22 @@ input[type='checkbox']:focus:not( :focus-visible ) { fill: var( --wpds-color-foreground-content-neutral-weak ); } +select optgroup, select option { background-color: var( --wpds-color-background-surface-neutral ); color: var( --wpds-color-foreground-content-neutral ); } +[data-wpds-root-provider] .components-radio-control__input[type='radio']:not(:checked) { + border-color: var( --wpds-color-stroke-interactive-neutral ); + background: transparent; +} + +[data-wpds-root-provider] .components-radio-control__option-description { + color: var( --wpds-color-foreground-content-neutral-weak ); + unicode-bidi: plaintext; +} + /* @wordpress/components ships its RTL fixups in a separate style-rtl.css that this app doesn't load, so its physical checkbox margins put the label gap on the wrong side in RTL and shift the input over the label. diff --git a/packages/common/lib/wordpress-version-labels.ts b/packages/common/lib/wordpress-version-labels.ts index 85f4fb0811..174aeaa52b 100644 --- a/packages/common/lib/wordpress-version-labels.ts +++ b/packages/common/lib/wordpress-version-labels.ts @@ -1,8 +1,15 @@ import { __, sprintf } from '@wordpress/i18n'; /** - * Label for the auto-updating WordPress version option, shared by the agentic UI - * and the Classic renderer so translators only see one phrasing. + * Copy for the WordPress version controls, shared by the agentic UI and the + * Classic renderer so translators only see one phrasing. Each one is a function + * so the Classic renderer, which swaps locale data live, re-reads it on render. + */ + +/** + * Label for the auto-updating WordPress version option. Only the plain dropdown + * renders it — the fallback for a missing version list. Everywhere else the mode + * is one of the update-mode radios below. * * @param version Version the site runs under auto-update: the installed one on * an existing site, the one about to be installed on the create @@ -22,3 +29,29 @@ export function getAutoUpdateVersionLabel( version?: string ): string { version ); } + +export function getAutomaticUpdatesLabel(): string { + return __( 'Automatic updates' ); +} + +export function getSelectAVersionLabel(): string { + return __( 'Select a version' ); +} + +/** + * @param version Version the site runs under auto-update. Pass it only while + * auto-update is the selected mode: on a pinned site it would + * read as if auto-update were keeping the site on that version + * (STU-2348). A value that is not a concrete version number + * (`-`, `latest`) falls back to the bare description. + */ +export function getAutomaticUpdatesDescription( version?: string ): string { + if ( ! version || ! /^\d/.test( version ) ) { + return __( 'WordPress installs updates on its own schedule.' ); + } + return sprintf( + /* translators: %s: WordPress version the site runs under auto-update, e.g. 6.9.7 */ + __( 'WordPress installs updates on its own schedule. Currently using version %s.' ), + version + ); +} diff --git a/skills/studio-cli/SKILL.md b/skills/studio-cli/SKILL.md index 22870b6682..98bd52e508 100644 --- a/skills/studio-cli/SKILL.md +++ b/skills/studio-cli/SKILL.md @@ -33,7 +33,7 @@ studio config # Get/set site settings (config get | config set) studio create --name "My Site" --path ~/Studio/my-site ``` -**Options:** `--name`, `--wp` (default: "latest", min: 6.2.1), `--php` (default: 8.4, choices: 8.5/8.4/8.3/8.2/8.1/8.0/7.4), `--domain`, `--https`, `--blueprint` (local JSON file path), `--admin-username` (default: "admin"), `--admin-password` (auto-generated if omitted), `--admin-email` (default: "admin@localhost.com"), `--start` (default: true, use `--no-start` to skip), `--skip-browser`, `--skip-log-details`. +**Options:** `--name`, `--wp` (default: "auto-update", which keeps WordPress core auto-updating; replaces the legacy "latest" option, which still works; pin with a version number, min: 6.2.1), `--php` (default: 8.4, choices: 8.5/8.4/8.3/8.2/8.1/8.0/7.4), `--domain`, `--https`, `--blueprint` (local JSON file path), `--admin-username` (default: "admin"), `--admin-password` (auto-generated if omitted), `--admin-email` (default: "admin@localhost.com"), `--start` (default: true, use `--no-start` to skip), `--skip-browser`, `--skip-log-details`. Without flags in a TTY, the CLI prompts interactively for name, path, WP/PHP versions, and domain.