Skip to content

Commit

Permalink
WebApp Redesign: Interact with sites list via redux (#1679)
Browse files Browse the repository at this point in the history
## Motivation for the change, related issues

This is a PR to start exploring site storage. In order to start from a
concrete place, this PR starts by setting up the site manager sidebar to
interact with the sites list via redux.

Related to #1659

## Implementation details

This PR adds a `site-storage` module that provides functions for adding,
removing, and listing sites. It currently only supports writing to sites
stored in OPFS.

Today, Safari only appears to support writing to OPFS files from worker
threads, so this PR adds a `site-storage-metadata-worker.ts` module that
the UI thread can spawn to write site metadata to OPFS.

This PR adds a `siteListing` property to our web app's redux state. It
looks like:
```ts
{
    status: SiteListingStatus;
    sites: SiteInfo[];
}
```

`SiteListingStatus` can reflect whether the listing is loading, loaded,
or in an error state.

The site-manager-sidebar has been updated to select sites state from
redux and interact with the sites list via redux actions. Currently, the
loading status is ignored, but we should show it to the user in a
follow-up PR.

## Testing Instructions (or ideally a Blueprint)

Run `npm run dev` and interact with the sites list in Chrome and Safari.
(Unfortunately, Firefox does not yet support loading Service Worker
modules, and that feature is required for our current dev setup)
  • Loading branch information
brandonpayton authored Aug 22, 2024
1 parent 6be65fb commit aea0181
Show file tree
Hide file tree
Showing 8 changed files with 531 additions and 96 deletions.
6 changes: 3 additions & 3 deletions packages/playground/website/public/wordpress.html
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,9 @@
landingPage: urlParams.get('url') || '/wp-admin',
login: true,
preferredVersions: {
php: "7.4",
wp: zipArtifactUrl
}
php: '7.4',
wp: zipArtifactUrl,
},
};
const encoded = JSON.stringify(blueprint);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,39 @@ import {
} from '@wordpress/components';
import css from './style.module.css';
import { useEffect, useRef, useState } from '@wordpress/element';
import { Site } from '../site-manager-sidebar';
import { type SiteInfo } from '../../../lib/site-storage';
import classNames from 'classnames';

function generateUniqueName(defaultName: string, sites: SiteInfo[]) {
const numberOfSitesStartingWithDefaultName = sites.filter((site) =>
site.name.startsWith(defaultName)
).length;
if (numberOfSitesStartingWithDefaultName === 0) {
return defaultName;
}
return `${defaultName} ${numberOfSitesStartingWithDefaultName}`;
}

export function AddSiteButton({
onAddSite,
sites,
}: {
onAddSite: (siteName: string) => void;
sites: Site[];
sites: SiteInfo[];
}) {
const defaultName = 'My Site';
const [isModalOpen, setModalOpen] = useState(false);
const [siteName, setSiteName] = useState<string | undefined>(defaultName);
const addSiteButtonRef = useRef<HTMLFormElement>(null);
const [error, setError] = useState<string | undefined>(undefined);

const generateUniqueName = () => {
const numberOfSitesStartingWithDefaultName = sites.filter((site) =>
site.name.startsWith(defaultName)
).length;
if (numberOfSitesStartingWithDefaultName === 0) {
return defaultName;
}
return `${defaultName} ${numberOfSitesStartingWithDefaultName}`;
};

useEffect(() => {
setSiteName(generateUniqueName());
setSiteName(generateUniqueName(defaultName, sites));
}, [sites]);

const openModal = () => setModalOpen(true);
const closeModal = () => {
setSiteName(generateUniqueName());
setSiteName(generateUniqueName(defaultName, sites));
setModalOpen(false);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { Blueprint } from '@wp-playground/blueprints';
import { StorageType } from '../../../types';

import css from './style.module.css';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import {
__experimentalHeading as Heading,
NavigableMenu,
Expand All @@ -15,21 +12,14 @@ import {
__experimentalItem as Item,
} from '@wordpress/components';
import { Logo, TemporaryStorageIcon, WordPressIcon } from '../icons';
import store, {
PlaygroundReduxState,
addSite as addSiteToStore,
} from '../../../lib/redux-store';
import { type SiteLogo, createNewSiteInfo } from '../../../lib/site-storage';
import { AddSiteButton } from '../add-site-button';

// TODO: move types to site storage
// TODO: Explore better ways of obtaining site logos
type SiteLogo = {
mime: string;
data: string;
};
export type Site = {
slug: string;
name: string;
logo?: SiteLogo;
blueprint?: Blueprint;
storage?: StorageType;
};
import { LatestSupportedPHPVersion } from '@php-wasm/universal';
import { LatestMinifiedWordPressVersion } from '@wp-playground/wordpress-builds';

export function SiteManagerSidebar({
className,
Expand All @@ -40,65 +30,25 @@ export function SiteManagerSidebar({
siteSlug?: string;
onSiteClick: (siteSlug: string) => void;
}) {
const [sites, setSites] = useState<Site[]>([]);

const generateSiteFromSlug = (slug: string): Site => {
let name = slug.replaceAll('-', ' ');
name = name.charAt(0).toUpperCase() + name.slice(1);
/**
* Ensure WordPress is spelt correctly in the UI.
*/
name = name.replace(/wordpress/i, 'WordPress');
const unsortedSites = useSelector(
(state: PlaygroundReduxState) => state.siteListing.sites
);
const sites = unsortedSites
.concat()
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
);

return {
slug,
const addSite = async (name: string) => {
const newSiteInfo = createNewSiteInfo({
name,
storage: 'browser',
};
};

/**
* TODO: This is a temporary solution to get the sites from the OPFS.
* This will be removed when Site storage is implemented.
*/
useEffect(() => {
const getVirtualOpfsRoot = async () => {
const virtualOpfsRoot = await navigator.storage.getDirectory();
const opfsSites: Site[] = [];
for await (const entry of virtualOpfsRoot.values()) {
if (entry.kind === 'directory') {
/**
* Sites stored in browser storage are prefixed with "site-"
* so we need to remove the prefix to get the slug.
*
* The default site is stored in the `wordpress` directory
* and it doesn't have a prefix.
*/
const slug = entry.name.replace(/^site-/, '');
opfsSites.push(generateSiteFromSlug(slug));
}
}
setSites(opfsSites);
};
getVirtualOpfsRoot();
}, []);

const addSite = (newName: string) => {
/**
* Generate a slug from the site name.
* TODO: remove this when site storage is implemented.
* In site storage slugs will be generated automatically.
*/
const newSlug = newName.replaceAll(' ', '-');
/**
* If the site name already exists, we won't need to add it again.
* TODO: remove this check when site storage is implemented.
* In site storage we won't be limited to having unique site names.
*/
if (!sites.some((site) => site.slug === newSlug)) {
setSites([...sites, generateSiteFromSlug(newSlug)]);
}
onSiteClick(newSlug);
storage: 'opfs',
wpVersion: LatestMinifiedWordPressVersion,
phpVersion: LatestSupportedPHPVersion,
phpExtensionBundle: 'kitchen-sink',
});
await store.dispatch(addSiteToStore(newSiteInfo));
onSiteClick(newSiteInfo.slug);
};

const resources = [
Expand Down Expand Up @@ -161,7 +111,8 @@ export function SiteManagerSidebar({
isSelected={isSelected}
role="menuitemradio"
icon={
site.storage === 'none' || !site.storage ? (
site.storage === 'temporary' ||
!site.storage ? (
<TemporaryStorageIcon
className={
css.siteManagerSidebarItemStorageIcon
Expand Down
95 changes: 95 additions & 0 deletions packages/playground/website/src/lib/redux-store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';

import {
type SiteInfo,
listSites,
addSite as addSiteToStorage,
removeSite as removeSiteFromStorage,
} from './site-storage';
import { directoryHandleToOpfsPath } from '@wp-playground/storage';
import type { MountDevice } from '@php-wasm/web';

Expand All @@ -9,10 +16,34 @@ export type ActiveModal =
| 'mount-markdown-directory'
| false;

export type SiteListingStatus =
| {
type: 'uninitialized';
}
| {
type: 'loading';
// TODO
//progress: number,
//total?: number,
}
| {
type: 'loaded';
}
| {
type: 'error';
error: string;
};

export type SiteListing = {
status: SiteListingStatus;
sites: SiteInfo[];
};

// Define the state types
interface AppState {
activeModal: string | null;
offline: boolean;
siteListing: SiteListing;
opfsMountDescriptor?: {
device: MountDevice;
mountpoint: string;
Expand All @@ -28,6 +59,10 @@ const initialState: AppState = {
? 'mount-markdown-directory'
: null,
offline: !navigator.onLine,
siteListing: {
status: { type: 'loading' },
sites: [],
},
};

if (query.get('storage') === 'browser') {
Expand Down Expand Up @@ -63,6 +98,33 @@ const slice = createSlice({
setOfflineStatus: (state, action: PayloadAction<boolean>) => {
state.offline = action.payload;
},
setSiteListingLoaded: (state, action: PayloadAction<SiteInfo[]>) => {
state.siteListing = {
status: { type: 'loaded' },
sites: action.payload,
};
},
setSiteListingError: (state, action: PayloadAction<string>) => {
state.siteListing = {
status: {
type: 'error',
error: action.payload,
},
sites: [],
};
},
addSite: (state, action: PayloadAction<SiteInfo>) => {
state.siteListing.sites.push(action.payload);
},
removeSite: (state, action: PayloadAction<SiteInfo>) => {
const idToRemove = action.payload.id;
const siteIndex = state.siteListing.sites.findIndex(
(siteInfo) => siteInfo.id === idToRemove
);
if (siteIndex !== undefined) {
state.siteListing.sites.splice(siteIndex, 1);
}
},
setOpfsMountDescriptor: (
state,
action: PayloadAction<AppState['opfsMountDescriptor']>
Expand All @@ -75,6 +137,26 @@ const slice = createSlice({
// Export actions
export const { setActiveModal, setOpfsMountDescriptor } = slice.actions;

// Redux thunk for adding a site
export function addSite(siteInfo: SiteInfo) {
return async (dispatch: typeof store.dispatch) => {
// TODO: Handle errors
// TODO: Possibly reflect addition in progress
await addSiteToStorage(siteInfo);
dispatch(slice.actions.addSite(siteInfo));
};
}

// Redux thunk for removing a site
export function removeSite(site: SiteInfo) {
return async (dispatch: typeof store.dispatch) => {
// TODO: Handle errors
// TODO: Possibly reflect removal in progress
await removeSiteFromStorage(site);
dispatch(slice.actions.removeSite(site));
};
}

export function selectSite(siteSlug: string) {
return async (dispatch: typeof store.dispatch) => {
const opfsRoot = await navigator.storage.getDirectory();
Expand Down Expand Up @@ -122,6 +204,19 @@ function setupOnlineOfflineListeners(dispatch: PlaygroundDispatch) {
}
setupOnlineOfflineListeners(store.dispatch);

// NOTE: We will likely want to configure and list sites someplace else,
// but for now, it seems fine to just kick off loading from OPFS
// after the store is created.
listSites().then(
(sites) => store.dispatch(slice.actions.setSiteListingLoaded(sites)),
(error) =>
store.dispatch(
slice.actions.setSiteListingError(
error instanceof Error ? error.message : 'Unknown error'
)
)
);

// Define RootState type
export type PlaygroundReduxState = ReturnType<typeof store.getState>;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* This worker module exists to allow writing file content to OPFS from the
* main browser thread. Today (2024-08-17), Safari only appears to support
* writing to OPFS via createSyncAccessHandle(), and that is only supported
* within dedicated workers.
*
* This worker exists so non-worker threads can trigger writing to OPFS files.
*/
onmessage = async function (event: MessageEvent) {
const filePath: string = event.data.path;
const content: string = event.data.content;

const pathParts = filePath.split('/').filter((p) => p.length > 0);

const fileName = pathParts.pop();
if (fileName === undefined) {
throw new Error(`Invalid path: '${filePath}'`);
}

let parentDirHandle = await navigator.storage.getDirectory();
for (const part of pathParts) {
parentDirHandle = await parentDirHandle.getDirectoryHandle(part);
}

const fileHandle = await parentDirHandle.getFileHandle(fileName, {
create: true,
});

const syncAccessHandle = await fileHandle.createSyncAccessHandle();
try {
const encodedContent = new TextEncoder().encode(content);
syncAccessHandle.write(encodedContent);
postMessage('done');
} finally {
syncAccessHandle.close();
}
};
postMessage('ready');
Loading

0 comments on commit aea0181

Please sign in to comment.