Skip to content

Commit 61c9a95

Browse files
authored
[Website] Saved Playgrounds overlay redesign (#2985)
## Summary This PR introduces a new "Saved Playgrounds" overlay that replaces the sidebar for site management. The overlay provides a centralized place for: - **Creating new Playgrounds**: Quick access buttons for Fresh WordPress, WordPress PR, Gutenberg PR, GitHub import, Blueprint URL, and zip import - **Browsing Blueprints**: Preview featured blueprints with a "View all" option to browse the full gallery with search and tag filtering - **Managing saved sites**: View and switch between your saved Playgrounds with site names and creation dates The site manager panel, file editor etc. are still available like before. https://github.com/user-attachments/assets/4525ddd4-8514-46c8-b025-a6f38a327677 ## Follow-up work * Add a "Download as zip" button to per-site hamburger menu in the overlay * Remove the hamburger menu in the site info sidebar and make those buttons more visible * Add a "Vanilla WordPress" first Blueprint to make starting over more convenient visually * Store the last 5 temporary Playgrounds in OPFS to avoid data loss on accidental refreshes * Move "Import from GitHub" OAuth flow to a popup for smoother UX and also to avoid data loss ## Test plan - [ ] Open the Saved Playgrounds overlay from the toolbar - [ ] Create a new Playground using each creation option - [ ] Browse blueprints, use search and tag filters - [ ] Switch between saved Playgrounds via the overlay - [ ] Verify the overlay works well on mobile viewports - [ ] Verify no "No site is selected" flash when creating sites - [ ] Run e2e tests: `npx nx e2e playground-website`
1 parent 125e3b6 commit 61c9a95

File tree

32 files changed

+1949
-794
lines changed

32 files changed

+1949
-794
lines changed

packages/playground/remote/project.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"outputPath": "dist/packages/playground/remote",
2525
"main": "packages/playground/remote/remote.html",
2626
"tsConfig": "packages/playground/remote/tsconfig.lib.json"
27-
}
27+
},
28+
"dependsOn": ["playground-blueprints:build:blueprint-schema"]
2829
},
2930
"dev": {
3031
"executor": "@nx/vite:dev-server",

packages/playground/remote/remote.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<!DOCTYPE html>
1+
<!doctype html>
22
<html>
33
<head>
44
<title>WordPress Playground</title>

packages/playground/remote/src/lib/boot-playground-remote.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ import workerV2Url from './playground-worker-endpoint-blueprints-v2.ts?worker&ur
3636
const origin = new URL('/', (import.meta || {}).url).origin;
3737

3838
function getWorkerUrl(): string {
39-
const runner = new URL(document.location.href).searchParams.get('blueprints-runner');
39+
const runner = new URL(document.location.href).searchParams.get(
40+
'blueprints-runner'
41+
);
4042
const isV2 = runner === 'v2';
4143
const selected = isV2 ? workerV2Url : workerV1Url;
4244
return new URL(selected, origin) + '';

packages/playground/website/playwright/e2e/opfs.spec.ts

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -66,24 +66,24 @@ test('should switch between sites', async ({ website, browserName }) => {
6666
// Save the temporary site using the modal
6767
await saveSiteViaModal(website.page);
6868

69-
await expect(
70-
website.page.locator('[aria-current="page"]')
71-
).not.toContainText('Temporary Playground', {
72-
// Saving the site takes a while on CI
73-
timeout: 90000,
74-
});
7569
await expect(website.page.getByLabel('Playground title')).not.toContainText(
76-
'Temporary Playground'
70+
'Temporary Playground',
71+
{
72+
// Saving the site takes a while on CI
73+
timeout: 90000,
74+
}
7775
);
7876

77+
// Open the saved playgrounds overlay to switch sites
78+
await website.openSavedPlaygroundsOverlay();
79+
80+
// Click on Temporary Playground in the overlay's site list
7981
await website.page
80-
.locator('button')
82+
.locator('[class*="siteRowContent"]')
8183
.filter({ hasText: 'Temporary Playground' })
8284
.click();
8385

84-
await expect(website.page.locator('[aria-current="page"]')).toContainText(
85-
'Temporary Playground'
86-
);
86+
// The overlay closes and site manager opens with the selected site
8787
await expect(website.page.getByLabel('Playground title')).toContainText(
8888
'Temporary Playground'
8989
);
@@ -118,27 +118,35 @@ test('should preserve PHP constants when saving a temporary site to OPFS', async
118118
// Save the temporary site using the modal
119119
await saveSiteViaModal(website.page);
120120

121-
await expect(
122-
website.page.locator('[aria-current="page"]')
123-
).not.toContainText('Temporary Playground', {
124-
// Saving the site takes a while on CI
125-
timeout: 90000,
126-
});
121+
await expect(website.page.getByLabel('Playground title')).not.toContainText(
122+
'Temporary Playground',
123+
{
124+
// Saving the site takes a while on CI
125+
timeout: 90000,
126+
}
127+
);
127128

128129
const storedPlaygroundTitleText = await website.page
129130
.getByLabel('Playground title')
130131
.textContent();
131132
await expect(storedPlaygroundTitleText).not.toBeNull();
132133
await expect(storedPlaygroundTitleText).not.toMatch('Temporary Playground');
133134

135+
// Open the saved playgrounds overlay to switch sites
136+
await website.openSavedPlaygroundsOverlay();
137+
138+
// Switch to Temporary Playground
134139
await website.page
135-
.locator('button')
140+
.locator('[class*="siteRowContent"]')
136141
.filter({ hasText: 'Temporary Playground' })
137142
.click();
138143

144+
// Open the overlay again to switch back to the stored site
145+
await website.openSavedPlaygroundsOverlay();
146+
139147
// Switch back to the stored site and confirm the PHP constant is still present.
140148
await website.page
141-
.locator('button')
149+
.locator('[class*="siteRowContent"]')
142150
.filter({ hasText: storedPlaygroundTitleText! })
143151
.click();
144152

@@ -194,9 +202,13 @@ test('should rename a saved Playground and persist after reload', async ({
194202
await expect(website.page.getByLabel('Playground title')).toContainText(
195203
newName
196204
);
205+
206+
// Verify the name is also updated in the saved playgrounds overlay
207+
await website.openSavedPlaygroundsOverlay();
197208
await expect(
198-
website.page.locator('[aria-current="page"]').first()
199-
).toContainText(newName);
209+
website.page.locator('[class*="siteRowName"]', { hasText: newName })
210+
).toBeVisible();
211+
await website.closeSavedPlaygroundsOverlay();
200212
});
201213

202214
test('should show save site modal with correct elements', async ({
@@ -335,9 +347,13 @@ test('should save site with custom name', async ({ website, browserName }) => {
335347
timeout: 90000,
336348
}
337349
);
338-
await expect(website.page.locator('[aria-current="page"]')).toContainText(
339-
customName
340-
);
350+
351+
// Verify the name also appears in the saved playgrounds overlay
352+
await website.openSavedPlaygroundsOverlay();
353+
await expect(
354+
website.page.locator('[class*="siteRowName"]', { hasText: customName })
355+
).toBeVisible();
356+
await website.closeSavedPlaygroundsOverlay();
341357
});
342358

343359
test('should not persist save site modal through page refresh', async ({

packages/playground/website/playwright/website-page.ts

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,26 +42,52 @@ export class WebsitePage {
4242
}
4343

4444
async ensureSiteManagerIsOpen() {
45-
const siteManager = this.page.locator('.main-sidebar');
46-
if (!(await siteManager.isVisible())) {
47-
await this.page
48-
.getByRole('button', { name: 'Open Site Manager' })
49-
.click();
45+
const siteManagerButton = this.page.getByRole('button', {
46+
name: /Site Manager/,
47+
});
48+
const isPressed = await siteManagerButton.getAttribute('aria-pressed');
49+
if (isPressed !== 'true') {
50+
await siteManagerButton.click();
5051
}
51-
await expect(siteManager).toBeVisible();
52+
// Wait for the site info panel section to be visible
53+
await expect(
54+
this.page.locator('section[class*="site-info-panel"]')
55+
).toBeVisible();
5256
}
5357

5458
async ensureSiteManagerIsClosed() {
55-
const siteManager = this.page.locator('.main-sidebar');
56-
if (await siteManager.isVisible()) {
57-
const closeButton = this.page.getByRole('button', {
58-
name: 'Close Site Manager',
59-
});
60-
if (await closeButton.isVisible()) {
61-
await closeButton.click();
62-
}
59+
const siteManagerButton = this.page.getByRole('button', {
60+
name: /Site Manager/,
61+
});
62+
const isPressed = await siteManagerButton.getAttribute('aria-pressed');
63+
if (isPressed === 'true') {
64+
await siteManagerButton.click();
65+
}
66+
// Wait for the site info panel section to be hidden
67+
await expect(
68+
this.page.locator('section[class*="site-info-panel"]')
69+
).not.toBeVisible();
70+
}
71+
72+
async openSavedPlaygroundsOverlay() {
73+
await this.page
74+
.getByRole('button', { name: 'Saved Playgrounds' })
75+
.click();
76+
await expect(
77+
this.page
78+
.locator('[class*="overlay"]')
79+
.filter({ hasText: 'Playground' })
80+
).toBeVisible();
81+
}
82+
83+
async closeSavedPlaygroundsOverlay() {
84+
const overlay = this.page
85+
.locator('[class*="overlay"]')
86+
.filter({ hasText: 'Playground' });
87+
if (await overlay.isVisible()) {
88+
await this.page.keyboard.press('Escape');
6389
}
64-
await expect(siteManager).not.toBeVisible();
90+
await expect(overlay).not.toBeVisible();
6591
}
6692

6793
async getSiteTitle(): Promise<string> {

packages/playground/website/project.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@
4545
"logLevel": "info"
4646
}
4747
},
48-
"dependsOn": ["^build"]
48+
"dependsOn": [
49+
"^build",
50+
"playground-blueprints:build:blueprint-schema"
51+
]
4952
},
5053
"dev": {
5154
"executor": "nx:run-commands",

packages/playground/website/src/components/blueprint-editor/AutosavedBlueprintBundleEditor.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,12 @@ export const AutosavedBlueprintBundleEditor = forwardRef<
186186

187187
// Otherwise, populate an in-memory filesystem with the Blueprint JSON.
188188
fs = new EventedFilesystem(new InMemoryFilesystemBackend());
189-
await populateFilesystemFromBlueprint(
190-
fs,
191-
originalBlueprint as Blueprint
192-
);
189+
if (originalBlueprint) {
190+
await populateFilesystemFromBlueprint(
191+
fs,
192+
originalBlueprint as Blueprint
193+
);
194+
}
193195
setFilesystem(fs);
194196
return;
195197
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import React, { useState } from 'react';
2+
import { TextControl } from '@wordpress/components';
3+
import { useAppDispatch } from '../../lib/state/redux/store';
4+
import {
5+
setActiveModal,
6+
setSiteManagerOpen,
7+
} from '../../lib/state/redux/slice-ui';
8+
import { Modal } from '../modal';
9+
import ModalButtons from '../modal/modal-buttons';
10+
import { PlaygroundRoute, redirectTo } from '../../lib/state/url/router';
11+
12+
export function BlueprintUrlModal() {
13+
const dispatch = useAppDispatch();
14+
const [url, setUrl] = useState<string>('');
15+
16+
const closeModal = () => dispatch(setActiveModal(null));
17+
18+
const handleSubmit = () => {
19+
const trimmed = url.trim();
20+
if (!trimmed) {
21+
return;
22+
}
23+
dispatch(setSiteManagerOpen(false));
24+
closeModal();
25+
redirectTo(
26+
PlaygroundRoute.newTemporarySite({
27+
query: {
28+
'blueprint-url': trimmed,
29+
},
30+
})
31+
);
32+
};
33+
34+
return (
35+
<Modal
36+
title="Run Blueprint from URL"
37+
contentLabel='This is a dialog window which overlays the main content of the page. The modal begins with a heading 2 called "Run Blueprint from URL". Pressing the Close button will close the modal and bring you back to where you were on the page.'
38+
onRequestClose={closeModal}
39+
small
40+
>
41+
<form
42+
onSubmit={(e) => {
43+
e.preventDefault();
44+
handleSubmit();
45+
}}
46+
style={{ display: 'flex', flexDirection: 'column', gap: 12 }}
47+
>
48+
<TextControl
49+
__nextHasNoMarginBottom
50+
label="Blueprint URL"
51+
value={url}
52+
onChange={(val: string) => setUrl(val)}
53+
placeholder="https://example.com/blueprint.json"
54+
type="url"
55+
autoFocus
56+
/>
57+
<ModalButtons
58+
submitText="Run Blueprint"
59+
areDisabled={!url.trim()}
60+
onCancel={closeModal}
61+
/>
62+
</form>
63+
</Modal>
64+
);
65+
}

packages/playground/website/src/components/browser-chrome/index.tsx

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ import {
1212
import { SyncLocalFilesButton } from '../sync-local-files-button';
1313
import { Dropdown, Icon } from '@wordpress/components';
1414
import { Modal } from '../../components/modal';
15-
import { cog } from '@wordpress/icons';
15+
import { cog, category } from '@wordpress/icons';
1616
import Button from '../button';
1717
import { ActiveSiteSettingsForm } from '../site-manager/site-settings-form';
1818
import { setSiteManagerOpen } from '../../lib/state/redux/slice-ui';
1919
import { SiteManagerIcon } from '@wp-playground/components';
20+
import { SavedPlaygroundsOverlay } from '../saved-playgrounds-overlay';
2021

2122
interface BrowserChromeProps {
2223
children?: React.ReactNode;
@@ -44,9 +45,12 @@ export default function BrowserChrome({
4445
className
4546
);
4647
const isMobileUi = useMediaQuery('(max-width: 875px)');
47-
const [isModalOpen, setIsModalOpen] = React.useState(false);
48-
const onToggle = () => setIsModalOpen(!isModalOpen);
49-
const closeModal = () => setIsModalOpen(false);
48+
const [isSettingsModalOpen, setIsSettingsModalOpen] = React.useState(false);
49+
const [isPlaygroundsOverlayOpen, setIsPlaygroundsOverlayOpen] =
50+
React.useState(false);
51+
const onSettingsToggle = () => setIsSettingsModalOpen(!isSettingsModalOpen);
52+
const closeSettingsModal = () => setIsSettingsModalOpen(false);
53+
const closePlaygroundsOverlay = () => setIsPlaygroundsOverlayOpen(false);
5054

5155
return (
5256
<div className={wrapperClass} data-cy="simulated-browser">
@@ -67,6 +71,16 @@ export default function BrowserChrome({
6771
</div>
6872

6973
<div className={css.toolbarButtons}>
74+
<Button
75+
variant="browser-chrome"
76+
aria-label="Saved Playgrounds"
77+
onClick={() => setIsPlaygroundsOverlayOpen(true)}
78+
aria-expanded={isPlaygroundsOverlayOpen}
79+
className={css.savedPlaygroundsButton}
80+
>
81+
<Icon icon={category} size={20} />
82+
</Button>
83+
7084
<Button
7185
variant="browser-chrome"
7286
aria-label={
@@ -95,8 +109,8 @@ export default function BrowserChrome({
95109
<Button
96110
variant="browser-chrome"
97111
aria-label="Edit Playground settings"
98-
onClick={onToggle}
99-
aria-expanded={isModalOpen}
112+
onClick={onSettingsToggle}
113+
aria-expanded={isSettingsModalOpen}
100114
style={{
101115
fill: '#FFF',
102116
alignItems: 'center',
@@ -105,14 +119,14 @@ export default function BrowserChrome({
105119
>
106120
<Icon icon={cog} size={28} />
107121
</Button>
108-
{isModalOpen && (
122+
{isSettingsModalOpen && (
109123
<Modal
110124
isFullScreen={true}
111125
title="Playground settings"
112-
onRequestClose={closeModal}
126+
onRequestClose={closeSettingsModal}
113127
>
114128
<ActiveSiteSettingsForm
115-
onSubmit={closeModal}
129+
onSubmit={closeSettingsModal}
116130
/>
117131
</Modal>
118132
)}
@@ -164,6 +178,9 @@ export default function BrowserChrome({
164178
</header>
165179
<div className={css.content}>{children}</div>
166180
</div>
181+
{isPlaygroundsOverlayOpen && (
182+
<SavedPlaygroundsOverlay onClose={closePlaygroundsOverlay} />
183+
)}
167184
</div>
168185
);
169186
}

0 commit comments

Comments
 (0)