Skip to content

Commit

Permalink
Allow PHP version to be changed from Site Settings (#225)
Browse files Browse the repository at this point in the history
* Display the PHP version on Site Settings screen

* Add a modal for editing the PHP version

* First pass at saving selected PHP version

* Update site details to allow user to set PHP version (#226)

* Add phpVersion to updateSiteDetails

* Update tests for changing the PHP version

* Abstract available PHP versions to a constant

* Use PHP constants from `wp-now` (#231)

* Use `DEFAULT_PHP_VERSION` constant from `wp-now`

* Use available PHP versions of Playground

* Add `web-streams-polyfill` package for unit tests

* Polyfill `ReadableStream` in unit tests

Web streams are used by `php-wasm`, so we need to polyfull them if we import the library in unit tests.

* Add unit tests to cover changing PHP version functionality (#233)

* Mock `matchMedia`

* Expose label in `SettingsRow` component

* Add test to cover the case of changing PHP version

* Revert "Expose label in `SettingsRow` component"

This reverts commit bc37161.

* Update change PHP version test case

* Remove `getPhpVersion` hook and IPC handler (#239)

* Remove `getPhpVersion` hook and IPC handler

* Use `??` operator instead of `||` when setting the php version

* Add inline comment in Jest setup

The comment clarifies why we need the polyfill.
Related to: #231 (comment)

* Update `ContentTabSettings` unit tests

* Bump default PHP version to `8.1` (#240)

* Bump default PHP version to `8.1`

This change is driven by the fact that version `8.0` already reached its end-of-life by 2024.
https://www.php.net/supported-versions.php

* Address failure in `createSite` unit test

---------

Co-authored-by: Derek Blank <derekpblank@gmail.com>
Co-authored-by: Carlos Garcia <fluiddot@gmail.com>
  • Loading branch information
3 people authored Jun 17, 2024
1 parent dfb43ed commit ab0cc21
Show file tree
Hide file tree
Showing 18 changed files with 262 additions and 4 deletions.
20 changes: 20 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
import '@testing-library/jest-dom';
// We need this polyfill because the `ReadableStream` class is
// used by `@php-wasm/universal` and it's not available in the Jest environment.
// eslint-disable-next-line import/no-unresolved
import 'web-streams-polyfill/polyfill';
import nock from 'nock';

if ( typeof window !== 'undefined' ) {
// The ipcListener global is usually defined in preload.ts
window.ipcListener = { subscribe: jest.fn() };

// Mock `matchMedia` as it's not implemented in JSDOM
// Reference: https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
Object.defineProperty( window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation( ( query ) => ( {
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
} ) ),
} );
}

nock.disableNetConnect();
Expand Down
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"ts-loader": "^9.2.2",
"ts-node": "^10.0.0",
"typescript": "~5.3.2",
"web-streams-polyfill": "^4.0.0",
"webpack-dev-middleware": "5.3.4"
},
"dependencies": {
Expand Down
9 changes: 9 additions & 0 deletions src/components/content-tab-settings.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Icon, file } from '@wordpress/icons';
import { useI18n } from '@wordpress/react-i18n';
import { PropsWithChildren } from 'react';
import { DEFAULT_PHP_VERSION } from '../../vendor/wp-now/src/constants';
import { useGetWpVersion } from '../hooks/use-get-wp-version';
import { getIpcApi } from '../lib/get-ipc-api';
import { decodePassword } from '../lib/passwords';
import Button from './button';
import { CopyTextButton } from './copy-text-button';
import DeleteSite from './delete-site';
import EditPhpVersion from './edit-php-version';
import EditSite from './edit-site';

interface ContentTabSettingsProps {
Expand All @@ -30,6 +32,7 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps )
// Empty strings account for legacy sites lacking a stored password.
const storedPassword = decodePassword( selectedSite.adminPassword ?? '' );
const password = storedPassword === '' ? 'password' : storedPassword;
const phpVersion = selectedSite.phpVersion ?? DEFAULT_PHP_VERSION;
const wpVersion = useGetWpVersion( selectedSite );
return (
<div className="p-8">
Expand Down Expand Up @@ -67,6 +70,12 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps )
</Button>
</SettingsRow>
<SettingsRow label={ __( 'WP Version' ) }>{ wpVersion }</SettingsRow>
<SettingsRow label={ __( 'PHP Version' ) }>
<div className="flex">
<span className="line-clamp-1 break-all">{ phpVersion }</span>
<EditPhpVersion />
</div>
</SettingsRow>

<tr>
<th colSpan={ 2 } className="pb-4 ltr:text-left rtl:text-right">
Expand Down
116 changes: 116 additions & 0 deletions src/components/edit-php-version.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { SupportedPHPVersions } from '@php-wasm/universal';
import { SelectControl } from '@wordpress/components';
import { useI18n } from '@wordpress/react-i18n';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { DEFAULT_PHP_VERSION } from '../../vendor/wp-now/src/constants';
import { useSiteDetails } from '../hooks/use-site-details';
import Button from './button';
import Modal from './modal';

export default function EditPhpVersion() {
const { __ } = useI18n();
const { updateSite, selectedSite, stopServer, startServer } = useSiteDetails();
const [ editPhpVersionError, setEditPhpVersionError ] = useState( '' );
const [ selectedPhpVersion, setSelectedPhpVersion ] = useState( DEFAULT_PHP_VERSION );
const [ needsToEditPhpVersion, setNeedsToEditPhpVersion ] = useState( false );
const [ isEditingSite, setIsEditingSite ] = useState( false );

useEffect( () => {
if ( selectedSite ) {
setSelectedPhpVersion( selectedSite.phpVersion );
}
}, [ selectedSite ] );

const resetLocalState = useCallback( () => {
setNeedsToEditPhpVersion( false );
setSelectedPhpVersion( '' );
setEditPhpVersionError( '' );
}, [] );

const onSiteEdit = useCallback(
async ( event: FormEvent ) => {
event.preventDefault();
if ( ! selectedSite ) {
return;
}
setIsEditingSite( true );
try {
const running = selectedSite.running;
await updateSite( {
...selectedSite,
phpVersion: selectedPhpVersion,
} );
if ( running ) {
await stopServer( selectedSite.id );
await startServer( selectedSite.id );
}
setNeedsToEditPhpVersion( false );
resetLocalState();
} catch ( e ) {
setEditPhpVersionError( ( e as Error )?.message );
}
setIsEditingSite( false );
},
[ updateSite, selectedSite, selectedPhpVersion, resetLocalState, startServer, stopServer ]
);

return (
<>
{ needsToEditPhpVersion && (
<Modal
size="medium"
title={ __( 'Edit PHP version' ) }
isDismissible
focusOnMount="firstContentElement"
onRequestClose={ resetLocalState }
>
<form onSubmit={ onSiteEdit }>
<label className="flex flex-col gap-1.5 leading-4">
<span className="font-semibold">{ __( 'PHP version' ) }</span>
<SelectControl
value={ selectedPhpVersion }
options={ SupportedPHPVersions.map( ( version ) => ( {
label: version,
value: version,
} ) ) }
onChange={ ( version ) => setSelectedPhpVersion( version ) }
/>
</label>
<div className="flex flex-row justify-end gap-x-5 mt-6">
<Button onClick={ resetLocalState } disabled={ isEditingSite } variant="tertiary">
{ __( 'Cancel' ) }
</Button>
<Button
type="submit"
variant="primary"
isBusy={ isEditingSite }
disabled={ Boolean(
isEditingSite ||
! selectedSite ||
selectedSite?.phpVersion === selectedPhpVersion ||
editPhpVersionError
) }
>
{ isEditingSite ? __( 'Restarting server…' ) : __( 'Save' ) }
</Button>
</div>
</form>
</Modal>
) }
<Button
disabled={ ! selectedSite }
className="!mx-4 shrink-0"
onClick={ () => {
if ( selectedSite ) {
setSelectedPhpVersion( selectedSite.phpVersion );
}
setNeedsToEditPhpVersion( true );
} }
label={ __( 'Edit PHP version' ) }
variant="link"
>
{ __( 'Edit' ) }
</Button>
</>
);
}
1 change: 1 addition & 0 deletions src/components/tests/content-tab-assistant.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const runningSite = {
port: 8881,
path: '/path/to/site',
running: true,
phpVersion: '8.0',
id: 'site-id',
url: 'http://example.com',
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const selectedSite: StartedSiteDetails = {
port: 8881,
path: '/path/to/site',
running: true,
phpVersion: '8.0',
id: 'site-id',
url: 'http://example.com',
};
Expand Down
2 changes: 2 additions & 0 deletions src/components/tests/content-tab-overview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const runningSite: StartedSiteDetails = {
name: 'Test Site',
port: 8881,
path: '/path/to/site',
phpVersion: '8.0',
running: true,
id: 'site-id',
url: 'http://example.com',
Expand All @@ -18,6 +19,7 @@ const notRunningSite: SiteDetails = {
name: 'Test Site',
port: 8881,
path: '/path/to/site',
phpVersion: '8.0',
running: false,
id: 'site-id',
};
Expand Down
83 changes: 82 additions & 1 deletion src/components/tests/content-tab-settings.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// To run tests, execute `npm run test -- src/components/content-tab-settings.test.tsx` from the root directory
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { useGetWpVersion } from '../../hooks/use-get-wp-version';
import { useOffline } from '../../hooks/use-offline';
Expand All @@ -17,6 +17,7 @@ const selectedSite: SiteDetails = {
path: '/path/to/site',
adminPassword: btoa( 'test-password' ),
running: false,
phpVersion: '8.0',
id: 'site-id',
};

Expand Down Expand Up @@ -148,4 +149,84 @@ describe( 'ContentTabSettings', () => {
expect( copyText ).toHaveBeenCalledWith( 'password' );
} );
} );

describe( 'PHP version', () => {
it( 'changes PHP version when site is not running', async () => {
const user = userEvent.setup();

const updateSite = jest.fn();
const startServer = jest.fn();
const stopServer = jest.fn();
// Mock snapshots to include a snapshot for the selected site
( useSiteDetails as jest.Mock ).mockReturnValue( {
selectedSite: { ...selectedSite, running: false } as SiteDetails,
snapshots: [ { localSiteId: selectedSite.id } ],
updateSite,
startServer,
stopServer,
} );

const { rerender } = render( <ContentTabSettings selectedSite={ selectedSite } /> );
expect( screen.getByText( '8.0' ) ).toBeVisible();
await user.click( screen.getByRole( 'button', { name: 'Edit PHP version' } ) );
const dialog = screen.getByRole( 'dialog' );
expect( dialog ).toBeVisible();
await user.selectOptions(
within( dialog ).getByRole( 'combobox', {
name: 'PHP version',
} ),
'8.2'
);
await user.click(
within( dialog ).getByRole( 'button', {
name: 'Save',
} )
);
expect( updateSite ).toHaveBeenCalledWith( expect.objectContaining( { phpVersion: '8.2' } ) );
expect( stopServer ).not.toHaveBeenCalled();
expect( startServer ).not.toHaveBeenCalled();

rerender( <ContentTabSettings selectedSite={ { ...selectedSite, phpVersion: '8.2' } } /> );
expect( screen.getByText( '8.2' ) ).toBeVisible();
} );

it( 'changes PHP version and restarts site when site is running', async () => {
const user = userEvent.setup();

const updateSite = jest.fn();
const startServer = jest.fn();
const stopServer = jest.fn();
// Mock snapshots to include a snapshot for the selected site
( useSiteDetails as jest.Mock ).mockReturnValue( {
selectedSite: { ...selectedSite, running: true } as SiteDetails,
snapshots: [ { localSiteId: selectedSite.id } ],
updateSite,
startServer,
stopServer,
} );

const { rerender } = render( <ContentTabSettings selectedSite={ selectedSite } /> );
expect( screen.getByText( '8.0' ) ).toBeVisible();
await user.click( screen.getByRole( 'button', { name: 'Edit PHP version' } ) );
const dialog = screen.getByRole( 'dialog' );
expect( dialog ).toBeVisible();
await user.selectOptions(
within( dialog ).getByRole( 'combobox', {
name: 'PHP version',
} ),
'8.2'
);
await user.click(
within( dialog ).getByRole( 'button', {
name: 'Save',
} )
);
expect( updateSite ).toHaveBeenCalledWith( expect.objectContaining( { phpVersion: '8.2' } ) );
expect( stopServer ).toHaveBeenCalled();
expect( startServer ).toHaveBeenCalled();

rerender( <ContentTabSettings selectedSite={ { ...selectedSite, phpVersion: '8.2' } } /> );
expect( screen.getByText( '8.2' ) ).toBeVisible();
} );
} );
} );
1 change: 1 addition & 0 deletions src/components/tests/content-tab-snapshots.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const selectedSite = {
name: 'Test Site',
running: false as const,
path: '/test-site',
phpVersion: '8.0',
adminPassword: btoa( 'test-password' ),
};

Expand Down
1 change: 1 addition & 0 deletions src/hooks/tests/use-update-demo-site.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe( 'useUpdateDemoSite', () => {
const mockLocalSite: SiteDetails = {
name: 'Test Site',
running: false,
phpVersion: '8.0',
id: '54321',
path: '/path/to/site',
};
Expand Down
3 changes: 2 additions & 1 deletion src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import nodePath from 'path';
import * as Sentry from '@sentry/electron/main';
import archiver from 'archiver';
import { copySync } from 'fs-extra';
import { SQLITE_FILENAME } from '../vendor/wp-now/src/constants';
import { SQLITE_FILENAME, DEFAULT_PHP_VERSION } from '../vendor/wp-now/src/constants';
import { downloadSqliteIntegrationPlugin } from '../vendor/wp-now/src/download';
import { executeWPCli } from '../vendor/wp-now/src/execute-wp-cli';
import { LIMIT_ARCHIVE_SIZE } from './constants';
Expand Down Expand Up @@ -158,6 +158,7 @@ export async function createSite(
path,
adminPassword: createPassword(),
running: false,
phpVersion: DEFAULT_PHP_VERSION,
} as const;

const server = SiteServer.create( details );
Expand Down
Loading

0 comments on commit ab0cc21

Please sign in to comment.