Skip to content

Commit ab0cc21

Browse files
danielbachhuberderekblankfluiddot
authored
Allow PHP version to be changed from Site Settings (#225)
* 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>
1 parent dfb43ed commit ab0cc21

18 files changed

Lines changed: 262 additions & 4 deletions

‎jest-setup.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,29 @@
11
import '@testing-library/jest-dom';
2+
// We need this polyfill because the `ReadableStream` class is
3+
// used by `@php-wasm/universal` and it's not available in the Jest environment.
4+
// eslint-disable-next-line import/no-unresolved
5+
import 'web-streams-polyfill/polyfill';
26
import nock from 'nock';
37

48
if ( typeof window !== 'undefined' ) {
59
// The ipcListener global is usually defined in preload.ts
610
window.ipcListener = { subscribe: jest.fn() };
11+
12+
// Mock `matchMedia` as it's not implemented in JSDOM
13+
// Reference: https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
14+
Object.defineProperty( window, 'matchMedia', {
15+
writable: true,
16+
value: jest.fn().mockImplementation( ( query ) => ( {
17+
matches: false,
18+
media: query,
19+
onchange: null,
20+
addListener: jest.fn(), // deprecated
21+
removeListener: jest.fn(), // deprecated
22+
addEventListener: jest.fn(),
23+
removeEventListener: jest.fn(),
24+
dispatchEvent: jest.fn(),
25+
} ) ),
26+
} );
727
}
828

929
nock.disableNetConnect();

‎package-lock.json‎

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"ts-loader": "^9.2.2",
8989
"ts-node": "^10.0.0",
9090
"typescript": "~5.3.2",
91+
"web-streams-polyfill": "^4.0.0",
9192
"webpack-dev-middleware": "5.3.4"
9293
},
9394
"dependencies": {

‎src/components/content-tab-settings.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { Icon, file } from '@wordpress/icons';
22
import { useI18n } from '@wordpress/react-i18n';
33
import { PropsWithChildren } from 'react';
4+
import { DEFAULT_PHP_VERSION } from '../../vendor/wp-now/src/constants';
45
import { useGetWpVersion } from '../hooks/use-get-wp-version';
56
import { getIpcApi } from '../lib/get-ipc-api';
67
import { decodePassword } from '../lib/passwords';
78
import Button from './button';
89
import { CopyTextButton } from './copy-text-button';
910
import DeleteSite from './delete-site';
11+
import EditPhpVersion from './edit-php-version';
1012
import EditSite from './edit-site';
1113

1214
interface ContentTabSettingsProps {
@@ -30,6 +32,7 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps )
3032
// Empty strings account for legacy sites lacking a stored password.
3133
const storedPassword = decodePassword( selectedSite.adminPassword ?? '' );
3234
const password = storedPassword === '' ? 'password' : storedPassword;
35+
const phpVersion = selectedSite.phpVersion ?? DEFAULT_PHP_VERSION;
3336
const wpVersion = useGetWpVersion( selectedSite );
3437
return (
3538
<div className="p-8">
@@ -67,6 +70,12 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps )
6770
</Button>
6871
</SettingsRow>
6972
<SettingsRow label={ __( 'WP Version' ) }>{ wpVersion }</SettingsRow>
73+
<SettingsRow label={ __( 'PHP Version' ) }>
74+
<div className="flex">
75+
<span className="line-clamp-1 break-all">{ phpVersion }</span>
76+
<EditPhpVersion />
77+
</div>
78+
</SettingsRow>
7079

7180
<tr>
7281
<th colSpan={ 2 } className="pb-4 ltr:text-left rtl:text-right">
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { SupportedPHPVersions } from '@php-wasm/universal';
2+
import { SelectControl } from '@wordpress/components';
3+
import { useI18n } from '@wordpress/react-i18n';
4+
import { FormEvent, useCallback, useEffect, useState } from 'react';
5+
import { DEFAULT_PHP_VERSION } from '../../vendor/wp-now/src/constants';
6+
import { useSiteDetails } from '../hooks/use-site-details';
7+
import Button from './button';
8+
import Modal from './modal';
9+
10+
export default function EditPhpVersion() {
11+
const { __ } = useI18n();
12+
const { updateSite, selectedSite, stopServer, startServer } = useSiteDetails();
13+
const [ editPhpVersionError, setEditPhpVersionError ] = useState( '' );
14+
const [ selectedPhpVersion, setSelectedPhpVersion ] = useState( DEFAULT_PHP_VERSION );
15+
const [ needsToEditPhpVersion, setNeedsToEditPhpVersion ] = useState( false );
16+
const [ isEditingSite, setIsEditingSite ] = useState( false );
17+
18+
useEffect( () => {
19+
if ( selectedSite ) {
20+
setSelectedPhpVersion( selectedSite.phpVersion );
21+
}
22+
}, [ selectedSite ] );
23+
24+
const resetLocalState = useCallback( () => {
25+
setNeedsToEditPhpVersion( false );
26+
setSelectedPhpVersion( '' );
27+
setEditPhpVersionError( '' );
28+
}, [] );
29+
30+
const onSiteEdit = useCallback(
31+
async ( event: FormEvent ) => {
32+
event.preventDefault();
33+
if ( ! selectedSite ) {
34+
return;
35+
}
36+
setIsEditingSite( true );
37+
try {
38+
const running = selectedSite.running;
39+
await updateSite( {
40+
...selectedSite,
41+
phpVersion: selectedPhpVersion,
42+
} );
43+
if ( running ) {
44+
await stopServer( selectedSite.id );
45+
await startServer( selectedSite.id );
46+
}
47+
setNeedsToEditPhpVersion( false );
48+
resetLocalState();
49+
} catch ( e ) {
50+
setEditPhpVersionError( ( e as Error )?.message );
51+
}
52+
setIsEditingSite( false );
53+
},
54+
[ updateSite, selectedSite, selectedPhpVersion, resetLocalState, startServer, stopServer ]
55+
);
56+
57+
return (
58+
<>
59+
{ needsToEditPhpVersion && (
60+
<Modal
61+
size="medium"
62+
title={ __( 'Edit PHP version' ) }
63+
isDismissible
64+
focusOnMount="firstContentElement"
65+
onRequestClose={ resetLocalState }
66+
>
67+
<form onSubmit={ onSiteEdit }>
68+
<label className="flex flex-col gap-1.5 leading-4">
69+
<span className="font-semibold">{ __( 'PHP version' ) }</span>
70+
<SelectControl
71+
value={ selectedPhpVersion }
72+
options={ SupportedPHPVersions.map( ( version ) => ( {
73+
label: version,
74+
value: version,
75+
} ) ) }
76+
onChange={ ( version ) => setSelectedPhpVersion( version ) }
77+
/>
78+
</label>
79+
<div className="flex flex-row justify-end gap-x-5 mt-6">
80+
<Button onClick={ resetLocalState } disabled={ isEditingSite } variant="tertiary">
81+
{ __( 'Cancel' ) }
82+
</Button>
83+
<Button
84+
type="submit"
85+
variant="primary"
86+
isBusy={ isEditingSite }
87+
disabled={ Boolean(
88+
isEditingSite ||
89+
! selectedSite ||
90+
selectedSite?.phpVersion === selectedPhpVersion ||
91+
editPhpVersionError
92+
) }
93+
>
94+
{ isEditingSite ? __( 'Restarting server…' ) : __( 'Save' ) }
95+
</Button>
96+
</div>
97+
</form>
98+
</Modal>
99+
) }
100+
<Button
101+
disabled={ ! selectedSite }
102+
className="!mx-4 shrink-0"
103+
onClick={ () => {
104+
if ( selectedSite ) {
105+
setSelectedPhpVersion( selectedSite.phpVersion );
106+
}
107+
setNeedsToEditPhpVersion( true );
108+
} }
109+
label={ __( 'Edit PHP version' ) }
110+
variant="link"
111+
>
112+
{ __( 'Edit' ) }
113+
</Button>
114+
</>
115+
);
116+
}

‎src/components/tests/content-tab-assistant.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const runningSite = {
2424
port: 8881,
2525
path: '/path/to/site',
2626
running: true,
27+
phpVersion: '8.0',
2728
id: 'site-id',
2829
url: 'http://example.com',
2930
};

‎src/components/tests/content-tab-overview-shortcuts-section.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const selectedSite: StartedSiteDetails = {
1010
port: 8881,
1111
path: '/path/to/site',
1212
running: true,
13+
phpVersion: '8.0',
1314
id: 'site-id',
1415
url: 'http://example.com',
1516
};

‎src/components/tests/content-tab-overview.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const runningSite: StartedSiteDetails = {
99
name: 'Test Site',
1010
port: 8881,
1111
path: '/path/to/site',
12+
phpVersion: '8.0',
1213
running: true,
1314
id: 'site-id',
1415
url: 'http://example.com',
@@ -18,6 +19,7 @@ const notRunningSite: SiteDetails = {
1819
name: 'Test Site',
1920
port: 8881,
2021
path: '/path/to/site',
22+
phpVersion: '8.0',
2123
running: false,
2224
id: 'site-id',
2325
};

‎src/components/tests/content-tab-settings.test.tsx‎

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// To run tests, execute `npm run test -- src/components/content-tab-settings.test.tsx` from the root directory
2-
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { fireEvent, render, screen, within } from '@testing-library/react';
33
import { userEvent } from '@testing-library/user-event';
44
import { useGetWpVersion } from '../../hooks/use-get-wp-version';
55
import { useOffline } from '../../hooks/use-offline';
@@ -17,6 +17,7 @@ const selectedSite: SiteDetails = {
1717
path: '/path/to/site',
1818
adminPassword: btoa( 'test-password' ),
1919
running: false,
20+
phpVersion: '8.0',
2021
id: 'site-id',
2122
};
2223

@@ -148,4 +149,84 @@ describe( 'ContentTabSettings', () => {
148149
expect( copyText ).toHaveBeenCalledWith( 'password' );
149150
} );
150151
} );
152+
153+
describe( 'PHP version', () => {
154+
it( 'changes PHP version when site is not running', async () => {
155+
const user = userEvent.setup();
156+
157+
const updateSite = jest.fn();
158+
const startServer = jest.fn();
159+
const stopServer = jest.fn();
160+
// Mock snapshots to include a snapshot for the selected site
161+
( useSiteDetails as jest.Mock ).mockReturnValue( {
162+
selectedSite: { ...selectedSite, running: false } as SiteDetails,
163+
snapshots: [ { localSiteId: selectedSite.id } ],
164+
updateSite,
165+
startServer,
166+
stopServer,
167+
} );
168+
169+
const { rerender } = render( <ContentTabSettings selectedSite={ selectedSite } /> );
170+
expect( screen.getByText( '8.0' ) ).toBeVisible();
171+
await user.click( screen.getByRole( 'button', { name: 'Edit PHP version' } ) );
172+
const dialog = screen.getByRole( 'dialog' );
173+
expect( dialog ).toBeVisible();
174+
await user.selectOptions(
175+
within( dialog ).getByRole( 'combobox', {
176+
name: 'PHP version',
177+
} ),
178+
'8.2'
179+
);
180+
await user.click(
181+
within( dialog ).getByRole( 'button', {
182+
name: 'Save',
183+
} )
184+
);
185+
expect( updateSite ).toHaveBeenCalledWith( expect.objectContaining( { phpVersion: '8.2' } ) );
186+
expect( stopServer ).not.toHaveBeenCalled();
187+
expect( startServer ).not.toHaveBeenCalled();
188+
189+
rerender( <ContentTabSettings selectedSite={ { ...selectedSite, phpVersion: '8.2' } } /> );
190+
expect( screen.getByText( '8.2' ) ).toBeVisible();
191+
} );
192+
193+
it( 'changes PHP version and restarts site when site is running', async () => {
194+
const user = userEvent.setup();
195+
196+
const updateSite = jest.fn();
197+
const startServer = jest.fn();
198+
const stopServer = jest.fn();
199+
// Mock snapshots to include a snapshot for the selected site
200+
( useSiteDetails as jest.Mock ).mockReturnValue( {
201+
selectedSite: { ...selectedSite, running: true } as SiteDetails,
202+
snapshots: [ { localSiteId: selectedSite.id } ],
203+
updateSite,
204+
startServer,
205+
stopServer,
206+
} );
207+
208+
const { rerender } = render( <ContentTabSettings selectedSite={ selectedSite } /> );
209+
expect( screen.getByText( '8.0' ) ).toBeVisible();
210+
await user.click( screen.getByRole( 'button', { name: 'Edit PHP version' } ) );
211+
const dialog = screen.getByRole( 'dialog' );
212+
expect( dialog ).toBeVisible();
213+
await user.selectOptions(
214+
within( dialog ).getByRole( 'combobox', {
215+
name: 'PHP version',
216+
} ),
217+
'8.2'
218+
);
219+
await user.click(
220+
within( dialog ).getByRole( 'button', {
221+
name: 'Save',
222+
} )
223+
);
224+
expect( updateSite ).toHaveBeenCalledWith( expect.objectContaining( { phpVersion: '8.2' } ) );
225+
expect( stopServer ).toHaveBeenCalled();
226+
expect( startServer ).toHaveBeenCalled();
227+
228+
rerender( <ContentTabSettings selectedSite={ { ...selectedSite, phpVersion: '8.2' } } /> );
229+
expect( screen.getByText( '8.2' ) ).toBeVisible();
230+
} );
231+
} );
151232
} );

‎src/components/tests/content-tab-snapshots.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const selectedSite = {
4646
name: 'Test Site',
4747
running: false as const,
4848
path: '/test-site',
49+
phpVersion: '8.0',
4950
adminPassword: btoa( 'test-password' ),
5051
};
5152

0 commit comments

Comments
 (0)