diff --git a/packages/docs/site/docs/developers/06-apis/query-api/01-index.md b/packages/docs/site/docs/developers/06-apis/query-api/01-index.md index 911afa26fe..fd2170e0e3 100644 --- a/packages/docs/site/docs/developers/06-apis/query-api/01-index.md +++ b/packages/docs/site/docs/developers/06-apis/query-api/01-index.md @@ -21,25 +21,26 @@ You can go ahead and try it out. The Playground will automatically install the t ## Available options -| Option | Default Value | Description | -| --------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `php` | `8.0` | Loads the specified PHP version. Accepts `7.0`, `7.1`, `7.2`, `7.3`, `7.4`, `8.0`, `8.1`, `8.2`, `8.3`, or `latest`. | -| `wp` | `latest` | Loads the specified WordPress version. Accepts the last three major WordPress versions. As of June 1, 2024, that's `6.3`, `6.4`, or `6.5`. You can also use the generic values `latest`, `nightly`, or `beta`. | -| `blueprint-url` | | The URL of the Blueprint that will be used to configure this Playground instance. | -| `networking` | `no` | Enables or disables the networking support for Playground. Accepts `yes` or `no`. | -| `plugin` | | Installs the specified plugin. Use the plugin name from the WordPress Plugins Directory URL. For example, if the URL is `https://wordpress.org/plugins/wp-lazy-loading/`, the plugin name would be `wp-lazy-loading`. You can pre-install multiple plugins by saying `plugin=coblocks&plugin=wp-lazy-loading&…`. Installing a plugin automatically logs the user in as an admin. | -| `theme` | | Installs the specified theme. Use the theme name from the WordPress Themes Directory URL. For example, if the URL is `https://wordpress.org/themes/disco/`, the theme name would be `disco`. Installing a theme automatically logs the user in as an admin. | -| `url` | `/wp-admin/` | Load the specified initial WordPress page in this Playground instance. | -| `mode` | `browser-full-screen` | Determines how the WordPress instance is displayed. Either wrapped in a browser UI or full width as a seamless experience. Accepts `browser-full-screen`, or `seamless`. | -| `lazy` | | Defer loading the Playground assets until someone clicks on the "Run" button. Does not accept any values. If `lazy` is added as a URL parameter, loading will be deferred. | -| `login` | `yes` | Log the user in as an admin. Accepts `yes` or `no`. | -| `multisite` | `no` | Enables the WordPress multisite mode. Accepts `yes` or `no`. | -| `import-site` | | Imports site files and database from a ZIP file specified by a URL. | -| `import-wxr` | | Imports site content from a WXR file specified by a URL. It uses the WordPress Importer plugin, so the default admin user must be logged in. | -| `site-slug` | | Selects which site to load from browser storage. | -| `language` | `en_US` | Sets the locale for the WordPress instance. This must be used in combination with `networking=yes` otherwise WordPress won't be able to download translations. | -| `core-pr` | | Installs a specific https://github.com/WordPress/wordpress-develop core PR. Accepts the PR number. For example, `core-pr=6883`. | -| `gutenberg-pr` | | Installs a specific https://github.com/WordPress/gutenberg PR. Accepts the PR number. For example, `gutenberg-pr=65337`. | +| Option | Default Value | Description | +| ------------------------ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `php` | `8.0` | Loads the specified PHP version. Accepts `7.0`, `7.1`, `7.2`, `7.3`, `7.4`, `8.0`, `8.1`, `8.2`, `8.3`, or `latest`. | +| `wp` | `latest` | Loads the specified WordPress version. Accepts the last three major WordPress versions. As of June 1, 2024, that's `6.3`, `6.4`, or `6.5`. You can also use the generic values `latest`, `nightly`, or `beta`. | +| `blueprint-url` | | The URL of the Blueprint that will be used to configure this Playground instance. | +| `networking` | `no` | Enables or disables the networking support for Playground. Accepts `yes` or `no`. | +| `plugin` | | Installs the specified plugin. Use the plugin name from the WordPress Plugins Directory URL. For example, if the URL is `https://wordpress.org/plugins/wp-lazy-loading/`, the plugin name would be `wp-lazy-loading`. You can pre-install multiple plugins by saying `plugin=coblocks&plugin=wp-lazy-loading&…`. Installing a plugin automatically logs the user in as an admin. | +| `theme` | | Installs the specified theme. Use the theme name from the WordPress Themes Directory URL. For example, if the URL is `https://wordpress.org/themes/disco/`, the theme name would be `disco`. Installing a theme automatically logs the user in as an admin. | +| `url` | `/wp-admin/` | Load the specified initial WordPress page in this Playground instance. | +| `mode` | `browser-full-screen` | Determines how the WordPress instance is displayed. Either wrapped in a browser UI or full width as a seamless experience. Accepts `browser-full-screen`, or `seamless`. | +| `lazy` | | Defer loading the Playground assets until someone clicks on the "Run" button. Does not accept any values. If `lazy` is added as a URL parameter, loading will be deferred. | +| `login` | `yes` | Log the user in as an admin. Accepts `yes` or `no`. | +| `multisite` | `no` | Enables the WordPress multisite mode. Accepts `yes` or `no`. | +| `import-site` | | Imports site files and database from a ZIP file specified by a URL. | +| `import-wxr` | | Imports site content from a WXR file specified by a URL. It uses the WordPress Importer plugin, so the default admin user must be logged in. | +| `site-slug` | | Selects which site to load from browser storage. | +| `language` | `en_US` | Sets the locale for the WordPress instance. This must be used in combination with `networking=yes` otherwise WordPress won't be able to download translations. | +| `core-pr` | | Installs a specific https://github.com/WordPress/wordpress-develop core PR. Accepts the PR number. For example, `core-pr=6883`. | +| `gutenberg-pr` | | Installs a specific https://github.com/WordPress/gutenberg PR. Accepts the PR number. For example, `gutenberg-pr=65337`. | +| `if-stored-site-missing` | | Indicates how to handle the scenario where the `site-slug` parameter identifies a site that does not exist. Use `if-stored-site-missing=prompt` to indicate that the user should be asked whether they would like to save a new site with the specified `site-slug`. | For example, the following code embeds a Playground with a preinstalled Gutenberg plugin and opens the post editor: diff --git a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx index 9d8cc32963..a4f4a5594d 100644 --- a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx +++ b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { resolveBlueprintFromURL } from '../../lib/state/url/resolve-blueprint-from-url'; import { useCurrentUrl } from '../../lib/state/url/router-hooks'; import { opfsSiteStorage } from '../../lib/state/opfs/opfs-site-storage'; @@ -6,6 +6,7 @@ import { siteListingLoaded, selectSiteBySlug, setTemporarySiteSpec, + deriveSiteNameFromSlug, } from '../../lib/state/redux/slice-sites'; import { selectActiveSite, @@ -17,6 +18,9 @@ import { redirectTo } from '../../lib/state/url/router'; import { logger } from '@php-wasm/logger'; import { Blueprint } from '@wp-playground/blueprints'; import { usePrevious } from '../../lib/hooks/use-previous'; +import { modalSlugs } from '../layout'; +import { setActiveModal } from '../../lib/state/redux/slice-ui'; +import { selectClientBySiteSlug } from '../../lib/state/redux/slice-clients'; /** * Ensures the redux store always has an activeSite value. @@ -41,6 +45,16 @@ export function EnsurePlaygroundSiteIsSelected({ const requestedSiteObject = useAppSelector((state) => selectSiteBySlug(state, requestedSiteSlug!) ); + const requestedClientInfo = useAppSelector( + (state) => + requestedSiteSlug && + selectClientBySiteSlug(state, requestedSiteSlug) + ); + const [needMissingSitePromptForSlug, setNeedMissingSitePromptForSlug] = + useState(false); + + const promptIfSiteMissing = + url.searchParams.get('if-stored-site-missing') === 'prompt'; const prevUrl = usePrevious(url); useEffect(() => { @@ -72,14 +86,27 @@ export function EnsurePlaygroundSiteIsSelected({ if (requestedSiteSlug) { // If the site does not exist, redirect to a new temporary site. if (!requestedSiteObject) { - // @TODO: Notification: 'The requested site was not found. Redirecting to a new temporary site.' - logger.log( - 'The requested site was not found. Redirecting to a new temporary site.' - ); - const currentUrl = new URL(window.location.href); - currentUrl.searchParams.delete('site-slug'); - redirectTo(currentUrl.toString()); - return; + if (promptIfSiteMissing) { + logger.log( + 'The requested site was not found. Creating a new temporary site.' + ); + + await createNewTemporarySite( + dispatch, + requestedSiteSlug + ); + setNeedMissingSitePromptForSlug(requestedSiteSlug); + return; + } else { + // @TODO: Notification: 'The requested site was not found. Redirecting to a new temporary site.' + logger.log( + 'The requested site was not found. Redirecting to a new temporary site.' + ); + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.delete('site-slug'); + redirectTo(currentUrl.toString()); + return; + } } dispatch(setActiveSite(requestedSiteSlug)); @@ -98,35 +125,29 @@ export function EnsurePlaygroundSiteIsSelected({ return; } - // If the site slug is missing, create a new temporary site. - // Lean on the Query API parameters and the Blueprint API to - // create the new site. - const newUrl = new URL(window.location.href); - let blueprint: Blueprint | undefined = undefined; - try { - blueprint = await resolveBlueprintFromURL(newUrl); - } catch (e) { - logger.error('Error resolving blueprint:', e); - } - // Create a new site otherwise - const newSiteInfo = await dispatch( - setTemporarySiteSpec({ - metadata: { - originalBlueprint: blueprint, - }, - originalUrlParams: { - searchParams: parseSearchParams(newUrl.searchParams), - hash: newUrl.hash, - }, - }) - ); - dispatch(setActiveSite(newSiteInfo.slug)); + await createNewTemporarySite(dispatch); } ensureSiteIsSelected(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [url.href, requestedSiteSlug, siteListingStatus]); + useEffect(() => { + if ( + needMissingSitePromptForSlug && + needMissingSitePromptForSlug === requestedSiteSlug && + requestedClientInfo + ) { + dispatch(setActiveModal(modalSlugs.MISSING_SITE_PROMPT)); + setNeedMissingSitePromptForSlug(false); + } + }, [ + needMissingSitePromptForSlug, + requestedSiteSlug, + requestedClientInfo, + dispatch, + ]); + return children; } @@ -138,3 +159,35 @@ function parseSearchParams(searchParams: URLSearchParams) { } return params; } + +async function createNewTemporarySite( + dispatch: ReturnType, + requestedSiteSlug?: string +) { + // If the site slug is missing, create a new temporary site. + // Lean on the Query API parameters and the Blueprint API to + // create the new site. + const newUrl = new URL(window.location.href); + let blueprint: Blueprint | undefined = undefined; + try { + blueprint = await resolveBlueprintFromURL(newUrl); + } catch (e) { + logger.error('Error resolving blueprint:', e); + } + // Create a new site otherwise + const newSiteInfo = await dispatch( + setTemporarySiteSpec({ + metadata: { + originalBlueprint: blueprint, + name: requestedSiteSlug + ? deriveSiteNameFromSlug(requestedSiteSlug) + : undefined, + }, + originalUrlParams: { + searchParams: parseSearchParams(newUrl.searchParams), + hash: newUrl.hash, + }, + }) + ); + await dispatch(setActiveSite(newSiteInfo.slug)); +} diff --git a/packages/playground/website/src/components/layout/index.tsx b/packages/playground/website/src/components/layout/index.tsx index 25a6ee3211..bdd9c23ef7 100644 --- a/packages/playground/website/src/components/layout/index.tsx +++ b/packages/playground/website/src/components/layout/index.tsx @@ -34,6 +34,7 @@ import { } from '../../lib/state/redux/slice-ui'; import { ImportFormModal } from '../import-form-modal'; import { PreviewPRModal } from '../../github/preview-pr'; +import { MissingSiteModal } from '../missing-site-modal'; acquireOAuthTokenIfNeeded(); @@ -46,7 +47,8 @@ export const modalSlugs = { GITHUB_EXPORT: 'github-export', PREVIEW_PR_WP: 'preview-pr-wordpress', PREVIEW_PR_GUTENBERG: 'preview-pr-gutenberg', -} + MISSING_SITE_PROMPT: 'missing-site-prompt', +}; const displayMode = getDisplayModeFromQuery(); function getDisplayModeFromQuery(): DisplayMode { @@ -186,39 +188,45 @@ function Modals(blueprint: Blueprint) { } else if (currentModal === modalSlugs.PREVIEW_PR_GUTENBERG) { return ; } else if (currentModal === modalSlugs.GITHUB_IMPORT) { - return { - setGithubExportValues({ - repoUrl: url, - prNumber: pr?.toString(), - toPathInRepo: path, - prAction: pr ? 'update' : 'create', + return ( + ; + urlInformation: { owner, repo, type, pr }, + }) => { + setGithubExportValues({ + repoUrl: url, + prNumber: pr?.toString(), + toPathInRepo: path, + prAction: pr ? 'update' : 'create', + contentType, + plugin: pluginOrThemeName, + theme: pluginOrThemeName, + }); + setGithubExportFiles(files); + }} + /> + ); } else if (currentModal === modalSlugs.GITHUB_EXPORT) { - return { - setGithubExportValues(formValues); - setGithubExportFiles(undefined); - }} - />; + return ( + { + setGithubExportValues(formValues); + setGithubExportFiles(undefined); + }} + /> + ); + } else if (currentModal === modalSlugs.MISSING_SITE_PROMPT) { + return ; } if (query.get('gh-ensure-auth') === 'yes') { diff --git a/packages/playground/website/src/components/missing-site-modal/index.tsx b/packages/playground/website/src/components/missing-site-modal/index.tsx new file mode 100644 index 0000000000..af7b57ff02 --- /dev/null +++ b/packages/playground/website/src/components/missing-site-modal/index.tsx @@ -0,0 +1,92 @@ +import { Button, Flex, FlexItem } from '@wordpress/components'; +import { Modal } from '../modal'; +import { SitePersistButton } from '../site-manager/site-persist-button'; +import { + useAppDispatch, + useAppSelector, + selectActiveSite, +} from '../../lib/state/redux/store'; +import { setActiveModal } from '../../lib/state/redux/slice-ui'; +import { selectClientInfoBySiteSlug } from '../../lib/state/redux/slice-clients'; + +export function MissingSiteModal() { + const dispatch = useAppDispatch(); + const closeModal = () => dispatch(setActiveModal(null)); + + const activeSite = useAppSelector((state) => selectActiveSite(state)); + const clientInfo = useAppSelector( + (state) => + activeSite?.slug && + selectClientInfoBySiteSlug(state, activeSite?.slug) + ); + + if (!activeSite) { + return null; + } + if (activeSite.metadata.storage !== 'none') { + return null; + } + + // TODO: Improve language for this modal + return ( + +

+ The {activeSite.metadata.name} Playground does not exist, + so we loaded a temporary Playground instead. +

+

+ If you want to preserve your changes, you can save the + Playground to browser storage. +

+ {/* Note: We are using row-reverse direction so the secondary + button can display first in row orientation and last when + wrapping to vertical orientation. + + This matches Modal style recommendations here: + https://github.com/WordPress/gutenberg/tree/1418350eb5a1f15e109fc96af385bdd029fc7304/packages/components/src/modal#side-by-side-buttons-recommended + */} + + + + + + + + + + +
+ ); +} diff --git a/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx b/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx index 611b27bede..865704ca3d 100644 --- a/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx @@ -11,13 +11,16 @@ import { persistTemporarySite } from '../../../lib/state/redux/persist-temporary import { selectClientInfoBySiteSlug } from '../../../lib/state/redux/slice-clients'; import { useLocalFsAvailability } from '../../../lib/hooks/use-local-fs-availability'; import { isOpfsAvailable } from '../../../lib/state/opfs/opfs-site-storage'; +import { SiteStorageType } from '../../../lib/site-metadata'; export function SitePersistButton({ siteSlug, children, + storage = null, }: { siteSlug: string; children: React.ReactNode; + storage?: Extract | null; }) { const clientInfo = useAppSelector((state) => selectClientInfoBySiteSlug(state, siteSlug) @@ -26,8 +29,19 @@ export function SitePersistButton({ const dispatch = useAppDispatch(); if (!clientInfo?.opfsSync || clientInfo.opfsSync?.status === 'error') { - return ( - <> + let button = null; + if (storage) { + button = ( +
+ dispatch(persistTemporarySite(siteSlug, storage)) + } + > + {children} +
+ ); + } else { + button = ( + ); + } + + return ( + <> + {button} {clientInfo?.opfsSync?.status === 'error' && (
There has been an error. Please try again. 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 26560346d4..ece5fa16d4 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 @@ -14,10 +14,11 @@ import { updateSiteMetadata, } from './slice-sites'; import { PlaygroundRoute, redirectTo } from '../url/router'; +import { SiteStorageType } from '../../site-metadata'; export function persistTemporarySite( siteSlug: string, - storageType: 'opfs' | 'local-fs' + storageType: Extract ) { // @TODO: Handle errors return async ( diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.ts b/packages/playground/website/src/lib/state/redux/slice-sites.ts index 2f47417a95..e159050038 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -98,6 +98,12 @@ export const getSitesLoadingState = (state: { export function deriveSlugFromSiteName(name: string) { return name.toLowerCase().replaceAll(' ', '-'); } +export function deriveSiteNameFromSlug(slug: string) { + return slug + .replaceAll('-', ' ') + .replaceAll(/\b\w/g, (c) => c.toUpperCase()) + .replaceAll(/WordPress/gi, 'WordPress'); +} /** * Updates the site metadata in the OPFS and in the redux state.