diff --git a/packages/playground/website/src/components/delete-site-modal/index.tsx b/packages/playground/website/src/components/delete-site-modal/index.tsx new file mode 100644 index 00000000000..1a1bcb48e58 --- /dev/null +++ b/packages/playground/website/src/components/delete-site-modal/index.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react'; +import { Notice, __experimentalText as Text } from '@wordpress/components'; +import { useAppDispatch, useAppSelector } from '../../lib/state/redux/store'; +import { + setActiveModal, + setSiteSlugToDelete, +} from '../../lib/state/redux/slice-ui'; +import { useSitesAPI } from '../../lib/state/redux/site-management-api-middleware'; +import { Modal } from '../modal'; +import ModalButtons from '../modal/modal-buttons'; +import css from '../modal/style.module.css'; + +export function DeleteSiteModal() { + const dispatch = useAppDispatch(); + const sitesAPI = useSitesAPI(); + const siteSlugToDelete = useAppSelector( + (state) => state.ui.siteSlugToDelete + ); + const site = useAppSelector((state) => + siteSlugToDelete ? state.sites.entities[siteSlugToDelete] : undefined + ); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!site) { + dispatch(setActiveModal(null)); + dispatch(setSiteSlugToDelete(undefined)); + } + }, [site, dispatch]); + + if (!site) { + return null; + } + + const closeModal = () => { + dispatch(setActiveModal(null)); + dispatch(setSiteSlugToDelete(undefined)); + }; + + const handleSubmit = async () => { + try { + setIsSubmitting(true); + setError(null); + await sitesAPI.delete(site.slug); + closeModal(); + } catch (e) { + setError( + e instanceof Error + ? e.message + : 'Deleting failed. Please try again.' + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
{ + e.preventDefault(); + handleSubmit(); + }} + > + + Are you sure you want to delete the site “ + {site.metadata.name}”? This action cannot be undone. + + {error ? ( + + {error} + + ) : null} + + +
+ ); +} diff --git a/packages/playground/website/src/components/layout/index.tsx b/packages/playground/website/src/components/layout/index.tsx index 3420ee95c1f..eb00c943b61 100644 --- a/packages/playground/website/src/components/layout/index.tsx +++ b/packages/playground/website/src/components/layout/index.tsx @@ -19,6 +19,7 @@ import { } from '../playground-viewport'; import { MissingSiteModal } from '../missing-site-modal'; import { RenameSiteModal } from '../rename-site-modal'; +import { DeleteSiteModal } from '../delete-site-modal'; import { SaveSiteModal } from '../save-site-modal'; import { modalSlugs } from '../../lib/state/redux/slice-ui'; import { GitHubPrivateRepoAuthModal } from '../github-private-repo-auth-modal'; @@ -167,6 +168,8 @@ function Modals() { return ; } else if (currentModal === modalSlugs.RENAME_SITE) { return ; + } else if (currentModal === modalSlugs.DELETE_SITE) { + return ; } else if (currentModal === modalSlugs.SAVE_SITE) { return ; } else if (currentModal === modalSlugs.GITHUB_PRIVATE_REPO_AUTH) { diff --git a/packages/playground/website/src/components/modal/style.module.css b/packages/playground/website/src/components/modal/style.module.css index 50a7bfb44a3..301332d8b3d 100644 --- a/packages/playground/website/src/components/modal/style.module.css +++ b/packages/playground/website/src/components/modal/style.module.css @@ -27,6 +27,12 @@ max-width: 350px; } +.modal-form { + display: flex; + flex-direction: column; + gap: 12px; +} + .modal-buttons { margin-top: 1.5rem; } diff --git a/packages/playground/website/src/components/rename-site-modal/index.tsx b/packages/playground/website/src/components/rename-site-modal/index.tsx index a7ae29a1791..ac2c2b656a3 100644 --- a/packages/playground/website/src/components/rename-site-modal/index.tsx +++ b/packages/playground/website/src/components/rename-site-modal/index.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react'; -import { TextControl } from '@wordpress/components'; +import { Notice, TextControl } from '@wordpress/components'; import { useAppDispatch, useAppSelector } from '../../lib/state/redux/store'; import { setActiveModal, @@ -8,6 +8,7 @@ import { import { useSitesAPI } from '../../lib/state/redux/site-management-api-middleware'; import { Modal } from '../modal'; import ModalButtons from '../modal/modal-buttons'; +import css from '../modal/style.module.css'; export function RenameSiteModal() { const dispatch = useAppDispatch(); @@ -22,6 +23,7 @@ export function RenameSiteModal() { const initialName = useMemo(() => site?.metadata?.name ?? '', [site]); const [name, setName] = useState(initialName); const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); if (!site || site.metadata.storage === 'none') { // Nothing to rename @@ -40,8 +42,15 @@ export function RenameSiteModal() { } try { setIsSubmitting(true); + setError(null); await sitesAPI.rename(trimmed); closeModal(); + } catch (e) { + setError( + e instanceof Error + ? e.message + : 'Renaming failed. Please try again.' + ); } finally { setIsSubmitting(false); } @@ -59,7 +68,7 @@ export function RenameSiteModal() { e.preventDefault(); handleSubmit(); }} - style={{ display: 'flex', flexDirection: 'column', gap: 12 }} + className={css.modalForm} > + {error ? ( + + {error} + + ) : null} {directoryError ? ( -

{directoryError}

+ + {directoryError} + ) : null} )} @@ -389,6 +391,11 @@ export function SaveSiteModal() {

)} + {submitError ? ( + + {submitError} + + ) : null} - {submitError ? ( -

{submitError}

- ) : null} ); diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index a0843491b1b..4bf32a992af 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -31,6 +31,7 @@ import { setSiteManagerOpen, setSiteManagerSection, setSiteSlugToRename, + setSiteSlugToDelete, } from '../../lib/state/redux/slice-ui'; import { useSitesAPI } from '../../lib/state/redux/site-management-api-middleware'; import { WordPressIcon } from '@wp-playground/components'; @@ -247,14 +248,10 @@ export function SavedPlaygroundsOverlay({ return `data:${logo.mime};base64,${logo.data}`; }; - const handleDeleteSite = async (site: SiteInfo, closeMenu: () => void) => { - const proceed = window.confirm( - `Are you sure you want to delete the site '${site.metadata.name}'?` - ); - if (proceed) { - await sitesAPI.delete(site.slug); - closeMenu(); - } + const handleDeleteSite = (site: SiteInfo, closeMenu: () => void) => { + dispatch(setSiteSlugToDelete(site.slug)); + modalDispatch(setActiveModal(modalSlugs.DELETE_SITE)); + closeMenu(); }; const handleRenameSite = (site: SiteInfo, closeMenu: () => void) => { diff --git a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx index c77103f30a5..689d612a074 100644 --- a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx @@ -19,11 +19,10 @@ import { modalSlugs, setActiveModal, setSiteManagerOpen, - setSiteManagerSection, + setSiteSlugToDelete, setSiteSlugToRename, } from '../../../lib/state/redux/slice-ui'; import { useAppDispatch, useAppSelector } from '../../../lib/state/redux/store'; -import { useSitesAPI } from '../../../lib/state/redux/site-management-api-middleware'; import { usePlaygroundClientInfo } from '../../../lib/use-playground-client'; import { SiteLogs } from '../../log-modal'; import { OfflineNotice } from '../../offline-notice'; @@ -85,8 +84,6 @@ export function SiteInfoPanel({ }) { const offline = useAppSelector((state) => state.ui.offline); const dispatch = useAppDispatch(); - const sitesAPI = useSitesAPI(); - // Load the last active tab for this site const [initialTabName] = useState(() => { const lastTab = getSiteLastTab(site.slug); @@ -103,16 +100,10 @@ export function SiteInfoPanel({ const isTemporary = site.metadata.storage === 'none'; - const removeSiteAndCloseMenu = async (onClose: () => void) => { - // TODO: Replace with HTML-based dialog - const proceed = window.confirm( - `Are you sure you want to delete the site '${site.metadata.name}'?` - ); - if (proceed) { - await sitesAPI.delete(site.slug); - dispatch(setSiteManagerSection('sidebar')); - onClose(); - } + const removeSiteAndCloseMenu = (onClose: () => void) => { + dispatch(setSiteSlugToDelete(site.slug)); + dispatch(setActiveModal(modalSlugs.DELETE_SITE)); + onClose(); }; const clientInfo = useAppSelector((state) => selectClientInfoBySiteSlug(state, site.slug) diff --git a/packages/playground/website/src/lib/state/redux/persist-temporary-site.ts b/packages/playground/website/src/lib/state/redux/persist-temporary-site.ts index 3c4365dd855..9c704202460 100644 --- a/packages/playground/website/src/lib/state/redux/persist-temporary-site.ts +++ b/packages/playground/website/src/lib/state/redux/persist-temporary-site.ts @@ -32,7 +32,6 @@ export function persistTemporarySite( skipRenameModal?: boolean; } = {} ) { - // @TODO: Handle errors return async ( dispatch: typeof store.dispatch, getState: () => PlaygroundReduxState @@ -135,21 +134,15 @@ export function persistTemporarySite( } else if (storageType === 'local-fs') { let dirHandle = options.localFsHandle; if (!dirHandle) { - try { - // Request permission to access the directory. - // https://developer.mozilla.org/en-US/docs/Web/API/Window/showDirectoryPicker - dirHandle = await (window as any).showDirectoryPicker({ - // By specifying an ID, the browser can remember different directories - // for different IDs.If the same ID is used for another picker, the - // picker opens in the same directory. - id: 'playground-directory', - mode: 'readwrite', - }); - } catch (e) { - // No directory selected but log the error just in case. - logger.error(e); - return; - } + // Request permission to access the directory. + // https://developer.mozilla.org/en-US/docs/Web/API/Window/showDirectoryPicker + dirHandle = await (window as any).showDirectoryPicker({ + // By specifying an ID, the browser can remember different directories + // for different IDs.If the same ID is used for another picker, the + // picker opens in the same directory. + id: 'playground-directory', + mode: 'readwrite', + }); } await saveDirectoryHandle(siteSlug, dirHandle!); diff --git a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts index 626180cabd7..4281c79b937 100644 --- a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts +++ b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts @@ -1,12 +1,3 @@ -/** - * Centralized Playground site management middleware. - * - * Provides a unified API for listing, renaming, saving, and opening - * Playground sites. Used by the MCP bridge, the browser DevTools global - * (`window.playgroundSites`), and any other part of the Playground - * Website that needs programmatic site access. - */ - import { useMemo } from 'react'; import { useStore } from 'react-redux'; import { createListenerMiddleware } from '@reduxjs/toolkit'; @@ -37,24 +28,105 @@ export interface SiteSettings { multisite?: boolean; } +/** + * API for listing, renaming, saving, and opening Playground + * sites. Used by the MCP bridge, the `window.playgroundSites` + * DevTools global, and UI components. + */ export interface PlaygroundSitesAPI { + /** + * Lists all known sites. + * + * @returns List of site info objects. + */ list(): Array<{ slug: string; name: string; storage: string; isActive: boolean; }>; + + /** + * Returns the PlaygroundClient for the active site. + * + * @returns The client, or `undefined` if not yet booted. + * @throws When no site is selected. + */ getClient(): PlaygroundClient | undefined; + + /** + * Renames the active site. + * + * @param newName The new display name. + * @throws When no site is selected or the site is + * temporary. + */ rename(newName: string): Promise; + + /** + * Persists the active temporary site to OPFS. + * + * @param name Optional display name for the saved site. + * @returns The site's slug and storage type. + * @throws When no site is selected or saving fails. + */ saveInBrowser(name?: string): Promise<{ slug: string; storage: string }>; + + /** + * Persists the active temporary site to a local directory. + * + * @param name Optional display name for the saved site. + * @param localFsHandle Directory handle. When omitted the + * browser prompts the user to pick one. + * @returns The site's slug and storage type. + * @throws When no site is selected or saving fails. + */ saveToLocalFileSystem( name?: string, localFsHandle?: FileSystemDirectoryHandle ): Promise<{ slug: string; storage: string }>; + + /** + * Changes the PHP version for the active site and reboots it. + * + * @param version The PHP version to use (e.g. `"8.4"`). + * @throws When no site is selected or the site is temporary. + */ setPhpVersion(version: SupportedPHPVersion): Promise; + + /** + * Enables or disables network access for the active site + * and reboots it. + * + * @param enabled Whether networking should be on. + * @throws When no site is selected or the site is temporary. + */ setNetworking(enabled: boolean): Promise; + + /** + * Deletes a saved site by slug. + * + * @param siteSlug The slug of the site to delete. + * @throws When the site is not found or the site is temporary. + */ delete(siteSlug: string): Promise; + + /** + * Switches to a different site and boots it. + * + * @param siteSlug The slug of the site to activate. + * @throws When the site is not found or fails to boot. + */ setActiveSite(siteSlug: string): Promise; + + /** + * Creates a new temporary site and boots it. + * + * @param siteSlug Optional slug hint. A random name is + * generated when omitted. + * @param settings Optional site settings. + * @returns The new site's slug. + */ createNewTemporarySite( siteSlug?: string, settings?: SiteSettings @@ -78,14 +150,6 @@ export function createSitesAPI( getState: () => PlaygroundReduxState, dispatch: PlaygroundDispatch ): PlaygroundSitesAPI { - function getActiveSiteOrThrow() { - const site = selectActiveSite(getState()); - if (!site) { - throw new Error('No active site'); - } - return site; - } - const api: PlaygroundSitesAPI = { list() { const state = getState(); @@ -107,12 +171,18 @@ export function createSitesAPI( }, getClient() { - const site = getActiveSiteOrThrow(); + const site = selectActiveSite(getState()); + if (!site) { + throw new Error('No active site selected'); + } return selectClientBySiteSlug(getState(), site.slug); }, async rename(newName: string) { - const site = getActiveSiteOrThrow(); + const site = selectActiveSite(getState()); + if (!site) { + throw new Error('No active site selected'); + } if (site.metadata.storage === 'none') { throw new Error( 'Cannot rename a temporary site. Save it first.' @@ -127,7 +197,10 @@ export function createSitesAPI( }, async saveInBrowser(name?: string) { - const site = getActiveSiteOrThrow(); + const site = selectActiveSite(getState()); + if (!site) { + throw new Error('No active site selected'); + } if (site.metadata.storage !== 'none') { return { slug: site.slug, storage: site.metadata.storage }; } @@ -139,11 +212,6 @@ export function createSitesAPI( ); const updatedSite = selectSiteBySlug(getState(), site.slug); const storage = updatedSite?.metadata.storage ?? 'none'; - if (storage === 'none') { - throw new Error( - 'Failed to save the site — the storage is still temporary after persist.' - ); - } return { slug: site.slug, storage }; }, @@ -151,7 +219,10 @@ export function createSitesAPI( name?: string, localFsHandle?: FileSystemDirectoryHandle ) { - const site = getActiveSiteOrThrow(); + const site = selectActiveSite(getState()); + if (!site) { + throw new Error('No active site selected'); + } if (site.metadata.storage !== 'none') { return { slug: site.slug, storage: site.metadata.storage }; } @@ -164,16 +235,14 @@ export function createSitesAPI( ); const updatedSite = selectSiteBySlug(getState(), site.slug); const storage = updatedSite?.metadata.storage ?? 'none'; - if (storage === 'none') { - throw new Error( - 'Failed to save the site — the storage is still temporary after persist.' - ); - } return { slug: site.slug, storage }; }, async setPhpVersion(version: SupportedPHPVersion) { - const site = getActiveSiteOrThrow(); + const site = selectActiveSite(getState()); + if (!site) { + throw new Error('No active site selected'); + } if (site.metadata.storage === 'none') { throw new Error( 'Cannot update settings on a temporary site. Save it first.' @@ -193,7 +262,10 @@ export function createSitesAPI( }, async setNetworking(enabled: boolean) { - const site = getActiveSiteOrThrow(); + const site = selectActiveSite(getState()); + if (!site) { + throw new Error('No active site selected'); + } if (site.metadata.storage === 'none') { throw new Error( 'Cannot update settings on a temporary site. Save it first.' @@ -219,7 +291,7 @@ export function createSitesAPI( } if (site.metadata.storage === 'none') { throw new Error( - 'Cannot delete a temporary site. It will be removed automatically when you close the tab.' + 'Cannot delete a temporary site. It will be reset on the next page load.' ); } await dispatch(removeSite(siteSlug)); diff --git a/packages/playground/website/src/lib/state/redux/slice-ui.ts b/packages/playground/website/src/lib/state/redux/slice-ui.ts index 0d8a4436683..bd7588ec778 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -31,6 +31,7 @@ export const modalSlugs = { MISSING_SITE_PROMPT: 'missing-site-prompt', RENAME_SITE: 'rename-site', SAVE_SITE: 'save-site', + DELETE_SITE: 'delete-site', BLUEPRINT_URL: 'blueprint-url', } as const; @@ -146,6 +147,7 @@ export interface UIState { }; activeModal: string | null; siteSlugToRename?: string; + siteSlugToDelete?: string; githubAuthRepoUrl?: string; offline: boolean; siteManagerIsOpen: boolean; @@ -164,11 +166,16 @@ const initialState: UIState = { * not by loading a URL with the modal parameter. * The github-private-repo-auth modal should only be triggered by authentication errors, * not by loading a URL with the modal parameter. + * The delete-site and rename-site modals require Redux state (siteSlugToDelete / + * siteSlugToRename) that is not persisted in the URL, so they cannot be meaningfully + * restored from a URL parameter. */ activeModal: query.get('modal') === 'error-report' || query.get('modal') === 'save-site' || - query.get('modal') === 'github-private-repo-auth' + query.get('modal') === 'github-private-repo-auth' || + query.get('modal') === 'delete-site' || + query.get('modal') === 'rename-site' ? null : query.get('modal') || null, offline: !navigator.onLine, @@ -263,6 +270,12 @@ const uiSlice = createSlice({ ) => { state.siteSlugToRename = action.payload; }, + setSiteSlugToDelete: ( + state, + action: PayloadAction + ) => { + state.siteSlugToDelete = action.payload; + }, }, }); @@ -308,6 +321,7 @@ export const { setSiteManagerOpen, setSiteManagerSection, setSiteSlugToRename, + setSiteSlugToDelete, } = uiSlice.actions; export default uiSlice.reducer;