diff --git a/packages/toolpad-app/cli/server.ts b/packages/toolpad-app/cli/server.ts index 23ae8a89573..c1d6e7e44ac 100644 --- a/packages/toolpad-app/cli/server.ts +++ b/packages/toolpad-app/cli/server.ts @@ -11,9 +11,10 @@ import { createServer as createViteServer } from 'vite'; import * as fs from 'fs/promises'; import serializeJavascript from 'serialize-javascript'; import { WebSocket, WebSocketServer } from 'ws'; +import { listen } from '@mui/toolpad-utils/http'; +import { asyncHandler } from '../src/utils/express'; import { createProdHandler } from '../src/server/toolpadAppServer'; import { getUserProjectRoot } from '../src/server/localMode'; -import { asyncHandler, listen } from '../src/utils/http'; import { getProject } from '../src/server/liveProject'; import { Command as AppDevServerCommand, Event as AppDevServerEvent } from './appServer'; import { createRpcHandler, rpcServer } from '../src/server/rpc'; diff --git a/packages/toolpad-app/src/server/data.ts b/packages/toolpad-app/src/server/data.ts index 4772ac2adc2..49c386c93d9 100644 --- a/packages/toolpad-app/src/server/data.ts +++ b/packages/toolpad-app/src/server/data.ts @@ -9,7 +9,7 @@ import serverDataSources from '../toolpadDataSources/server'; import * as appDom from '../appDom'; import applyTransform from '../toolpadDataSources/applyTransform'; import { loadDom, saveDom } from './liveProject'; -import { asyncHandler } from '../utils/http'; +import { asyncHandler } from '../utils/express'; export async function getConnectionParams

( connectionId: string | null, diff --git a/packages/toolpad-app/src/server/har.spec.ts b/packages/toolpad-app/src/server/har.spec.ts index bedacb0436a..4e3a4ed12c7 100644 --- a/packages/toolpad-app/src/server/har.spec.ts +++ b/packages/toolpad-app/src/server/har.spec.ts @@ -5,7 +5,7 @@ import { streamToString } from '../utils/streams'; describe('har', () => { test('headers in array form', async () => { - const { port, stopServer } = await listen(async (req, res) => { + const { port, close } = await listen(async (req, res) => { res.write( JSON.stringify({ body: await streamToString(req), @@ -40,7 +40,7 @@ describe('har', () => { }), ); } finally { - await stopServer(); + await close(); } }); }); diff --git a/packages/toolpad-app/src/server/rpc.ts b/packages/toolpad-app/src/server/rpc.ts index d88ce8f59c1..0ddfb6c5449 100644 --- a/packages/toolpad-app/src/server/rpc.ts +++ b/packages/toolpad-app/src/server/rpc.ts @@ -11,7 +11,7 @@ import { execQuery, dataSourceFetchPrivate, dataSourceExecPrivate } from './data import { getVersionInfo } from './versionInfo'; import { createComponent, deletePage } from './localMode'; import { loadDom, saveDom, applyDomDiff, openCodeEditor } from './liveProject'; -import { asyncHandler } from '../utils/http'; +import { asyncHandler } from '../utils/express'; export interface Method

{ (...params: P): Promise; diff --git a/packages/toolpad-app/src/server/toolpadAppServer.ts b/packages/toolpad-app/src/server/toolpadAppServer.ts index 76cc58b8a53..8ca45757c20 100644 --- a/packages/toolpad-app/src/server/toolpadAppServer.ts +++ b/packages/toolpad-app/src/server/toolpadAppServer.ts @@ -6,7 +6,7 @@ import config from '../config'; import { postProcessHtml } from './toolpadAppBuilder'; import { loadDom } from './liveProject'; import { getAppOutputFolder } from './localMode'; -import { asyncHandler } from '../utils/http'; +import { asyncHandler } from '../utils/express'; import { createDataHandler } from './data'; import { basicAuthUnauthorized, checkBasicAuthHeader } from './basicAuth'; diff --git a/packages/toolpad-app/src/toolpad/AppEditor/BindingEditor.tsx b/packages/toolpad-app/src/toolpad/AppEditor/BindingEditor.tsx index 5d93cf5a5e5..315f6886fb3 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/BindingEditor.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/BindingEditor.tsx @@ -36,10 +36,10 @@ import { } from '@mui/toolpad-core'; import { createProvidedContext } from '@mui/toolpad-utils/react'; import { TabContext, TabList } from '@mui/lab'; +import useDebounced from '@mui/toolpad-utils/hooks/useDebounced'; import { JsExpressionEditor } from './PageEditor/JsExpressionEditor'; import JsonView from '../../components/JsonView'; import useLatest from '../../utils/useLatest'; -import useDebounced from '../../utils/useDebounced'; import { useEvaluateLiveBinding } from './useEvaluateLiveBinding'; import GlobalScopeExplorer from './GlobalScopeExplorer'; import { WithControlledProp, Maybe } from '../../utils/types'; diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/index.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/index.tsx index 0ec14461b24..d58c4b0eacc 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/index.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/index.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { styled } from '@mui/material'; import { NodeId } from '@mui/toolpad-core'; +import useDebouncedHandler from '@mui/toolpad-utils/hooks/useDebouncedHandler'; import SplitPane from '../../../components/SplitPane'; import RenderPanel from './RenderPanel'; import ComponentPanel from './ComponentPanel'; @@ -11,7 +12,6 @@ import ComponentCatalog from './ComponentCatalog'; import NotFoundEditor from '../NotFoundEditor'; import usePageTitle from '../../../utils/usePageTitle'; import useLocalStorageState from '../../../utils/useLocalStorageState'; -import useDebouncedHandler from '../../../utils/useDebouncedHandler'; import useUndoRedo from '../../hooks/useUndoRedo'; const classes = { @@ -41,7 +41,10 @@ function PageEditorContent({ node }: PageEditorContentProps) { 300, ); - const handleSplitChange = useDebouncedHandler((newSize) => setSplitDefaultSize(newSize), 100); + const handleSplitChange = useDebouncedHandler( + (newSize: number) => setSplitDefaultSize(newSize), + 100, + ); return ( diff --git a/packages/toolpad-app/src/toolpad/AppState.tsx b/packages/toolpad-app/src/toolpad/AppState.tsx index a7d0b5b734d..f0f9c0271b4 100644 --- a/packages/toolpad-app/src/toolpad/AppState.tsx +++ b/packages/toolpad-app/src/toolpad/AppState.tsx @@ -6,11 +6,11 @@ import { debounce, DebouncedFunc } from 'lodash-es'; import { useLocation } from 'react-router-dom'; import { mapValues } from '@mui/toolpad-utils/collections'; +import useDebouncedHandler from '@mui/toolpad-utils/hooks/useDebouncedHandler'; import * as appDom from '../appDom'; import { omit, update } from '../utils/immutability'; import client from '../api'; import useShortcut from '../utils/useShortcut'; -import useDebouncedHandler from '../utils/useDebouncedHandler'; import insecureHash from '../utils/insecureHash'; import useEvent from '../utils/useEvent'; import { NodeHashes } from '../types'; diff --git a/packages/toolpad-app/src/toolpadDataSources/googleSheets/client.tsx b/packages/toolpad-app/src/toolpadDataSources/googleSheets/client.tsx index bb1d0bb2175..6fc03ec5b4c 100644 --- a/packages/toolpad-app/src/toolpadDataSources/googleSheets/client.tsx +++ b/packages/toolpad-app/src/toolpadDataSources/googleSheets/client.tsx @@ -14,6 +14,7 @@ import { inferColumns, parseColumns } from '@mui/toolpad-components'; import { DataGridPro, GridColDef } from '@mui/x-data-grid-pro'; import { UseQueryResult } from '@tanstack/react-query'; import { getObjectKey } from '@mui/toolpad-utils/objectKey'; +import useDebounced from '@mui/toolpad-utils/hooks/useDebounced'; import { ClientDataSource, ConnectionEditorProps, QueryEditorProps } from '../../types'; import { GoogleSheetsConnectionParams, @@ -26,7 +27,6 @@ import { GoogleSheetsPrivateQuery, GoogleSheetsResult, } from './types'; -import useDebounced from '../../utils/useDebounced'; import { usePrivateQuery } from '../context'; import QueryInputPanel from '../QueryInputPanel'; import SplitPane from '../../components/SplitPane'; diff --git a/packages/toolpad-app/src/utils/express.ts b/packages/toolpad-app/src/utils/express.ts new file mode 100644 index 00000000000..069a1510a65 --- /dev/null +++ b/packages/toolpad-app/src/utils/express.ts @@ -0,0 +1,14 @@ +import * as express from 'express'; +import { Awaitable } from './types'; + +export function asyncHandler( + handler: ( + req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => Awaitable, +): express.RequestHandler { + return (req, res, next) => { + Promise.resolve(handler(req, res, next)).catch(next); + }; +} diff --git a/packages/toolpad-app/src/utils/http.ts b/packages/toolpad-app/src/utils/http.ts deleted file mode 100644 index 43597840dae..00000000000 --- a/packages/toolpad-app/src/utils/http.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as express from 'express'; -import * as http from 'http'; -import { Awaitable } from './types'; - -/** - * async version of http.Server listen(port) method - */ -export async function listen(server: http.Server, port?: number) { - await new Promise((resolve, reject) => { - const handleError = (err: Error) => { - reject(err); - }; - server.once('error', handleError).listen(port, () => { - server.off('error', handleError); - resolve(); - }); - }); -} - -export function asyncHandler( - handler: ( - req: express.Request, - res: express.Response, - next: express.NextFunction, - ) => Awaitable, -): express.RequestHandler { - return (req, res, next) => { - Promise.resolve(handler(req, res, next)).catch(next); - }; -} diff --git a/packages/toolpad-app/src/utils/useLocalStorageState.ts b/packages/toolpad-app/src/utils/useLocalStorageState.ts index 78c51557a19..481d4391d99 100644 --- a/packages/toolpad-app/src/utils/useLocalStorageState.ts +++ b/packages/toolpad-app/src/utils/useLocalStorageState.ts @@ -1,56 +1,5 @@ import * as React from 'react'; -import { Emitter } from '@mui/toolpad-utils/events'; - -// storage events only work across windows, we'll use an event emitter to announce within the window -const emitter = new Emitter>(); -// local cache, needed for getSnapshot -const cache = new Map(); - -function subscribe(key: string, cb: () => void): () => void { - const onKeyChange = () => { - // invalidate local cache - cache.delete(key); - cb(); - }; - const storageHandler = (event: StorageEvent) => { - if (event.storageArea === window.localStorage && event.key === key) { - onKeyChange(); - } - }; - window.addEventListener('storage', storageHandler); - emitter.on(key, onKeyChange); - return () => { - window.removeEventListener('storage', storageHandler); - emitter.off(key, onKeyChange); - }; -} - -function getSnapshot(key: string): T | undefined { - try { - let value = cache.get(key); - if (!value) { - const item = window.localStorage.getItem(key); - value = item ? JSON.parse(item) : undefined; - cache.set(key, value); - } - return value; - } catch (error) { - console.error(error); - return undefined; - } -} - -function setValue(key: string, value: T) { - try { - if (typeof window !== 'undefined') { - cache.set(key, value); - window.localStorage.setItem(key, JSON.stringify(value)); - emitter.emit(key, null); - } - } catch (error) { - console.error(error); - } -} +import useStorageState from '@mui/toolpad-utils/hooks/useStorageState'; /** * Sync state to local storage so that it persists through a page refresh. Usage is @@ -70,26 +19,16 @@ export default function useLocalStorageState( key: string, initialValue: V, ): [V, React.Dispatch>] { - const subscribeKey = React.useCallback((cb: () => void) => subscribe(key, cb), [key]); - const getKeySnapshot = React.useCallback( - () => getSnapshot(key) ?? initialValue, - [initialValue, key], - ); - const getKeyServerSnapshot = React.useCallback(() => initialValue, [initialValue]); - - const storedValue: V = React.useSyncExternalStore( - subscribeKey, - getKeySnapshot, - getKeyServerSnapshot, - ); - - const setStoredValue = React.useCallback( - (value: React.SetStateAction) => { - const valueToStore = value instanceof Function ? value(storedValue) : value; - setValue(key, valueToStore); - }, - [key, storedValue], + const [input, setInput] = useStorageState('local', key, () => JSON.stringify(initialValue)); + + const value: V = React.useMemo(() => JSON.parse(input), [input]); + const handleChange: React.Dispatch> = React.useCallback( + (newValue) => + setInput( + JSON.stringify(typeof newValue === 'function' ? (newValue as Function)(value) : newValue), + ), + [setInput, value], ); - return [storedValue, setStoredValue]; + return [value, handleChange]; } diff --git a/packages/toolpad-app/src/utils/useDebounced.ts b/packages/toolpad-utils/src/hooks/useDebounced.ts similarity index 100% rename from packages/toolpad-app/src/utils/useDebounced.ts rename to packages/toolpad-utils/src/hooks/useDebounced.ts diff --git a/packages/toolpad-app/src/utils/useDebouncedHandler.ts b/packages/toolpad-utils/src/hooks/useDebouncedHandler.ts similarity index 83% rename from packages/toolpad-app/src/utils/useDebouncedHandler.ts rename to packages/toolpad-utils/src/hooks/useDebouncedHandler.ts index 08ff850b893..ddbbdc52ee3 100644 --- a/packages/toolpad-app/src/utils/useDebouncedHandler.ts +++ b/packages/toolpad-utils/src/hooks/useDebouncedHandler.ts @@ -1,16 +1,16 @@ import * as React from 'react'; -interface Handler

{ +interface Handler

{ (...params: P): void; } -interface DelayedInvocation

{ +interface DelayedInvocation

{ startTime: number; timeout: NodeJS.Timeout; params: P; } -function clear

( +function clear

( delayedInvocation: React.MutableRefObject | null>, ) { if (delayedInvocation.current) { @@ -19,7 +19,11 @@ function clear

( } } -function defer

(fn: React.MutableRefObject>, params: P, delay: number) { +function defer

( + fn: React.MutableRefObject>, + params: P, + delay: number, +) { const timeout = setTimeout(() => { fn.current(...params); }, delay); @@ -34,7 +38,7 @@ function defer

(fn: React.MutableRefObject>, params: * This implementation adds on the lodash implementation in that it handles updates to the * delay value. */ -export default function useDebouncedHandler

( +export default function useDebouncedHandler

( fn: Handler

, delay: number, ): Handler

{ diff --git a/packages/toolpad-utils/src/hooks/useStorageState.ts b/packages/toolpad-utils/src/hooks/useStorageState.ts new file mode 100644 index 00000000000..1ba5ae74f5c --- /dev/null +++ b/packages/toolpad-utils/src/hooks/useStorageState.ts @@ -0,0 +1,133 @@ +import * as React from 'react'; +import { Emitter } from '../events'; + +// storage events only work across windows, we'll use an event emitter to announce within the window +const emitter = new Emitter>(); +// local cache, needed for getSnapshot +const cache = new Map(); + +function subscribe(area: Storage, key: string, cb: () => void): () => void { + const onKeyChange = () => { + // invalidate local cache + cache.delete(key); + cb(); + }; + const storageHandler = (event: StorageEvent) => { + if (event.storageArea === area && event.key === key) { + onKeyChange(); + } + }; + window.addEventListener('storage', storageHandler); + emitter.on(key, onKeyChange); + return () => { + window.removeEventListener('storage', storageHandler); + emitter.off(key, onKeyChange); + }; +} + +function getSnapshot(area: Storage, key: string): string | null { + let value = cache.get(key) ?? null; + if (!value) { + const item = area.getItem(key); + value = item; + if (value === null) { + cache.delete(key); + } else { + cache.set(key, value); + } + } + return value; +} + +function setValue(area: Storage, key: string, value: string | null) { + if (typeof window !== 'undefined') { + if (value === null) { + cache.delete(key); + area.removeItem(key); + } else { + cache.set(key, value); + area.setItem(key, String(value)); + } + emitter.emit(key, null); + } +} + +type Initializer = () => T; + +type UseStorageStateHookResult = [T, React.Dispatch>]; + +function useStorageStateServer( + kind: 'session' | 'local', + key: string, + initializer: string | Initializer, +): UseStorageStateHookResult; +function useStorageStateServer( + kind: 'session' | 'local', + key: string, + initializer?: string | null | Initializer, +): UseStorageStateHookResult; +function useStorageStateServer( + kind: 'session' | 'local', + key: string, + initializer: string | null | Initializer = null, +): UseStorageStateHookResult | UseStorageStateHookResult { + const [initialValue] = React.useState(initializer); + return [initialValue, () => {}]; +} + +/** + * Sync state to local/session storage so that it persists through a page refresh. Usage is + * similar to useState except we pass in a storage key so that we can default + * to that value on page load instead of the specified initial value. + * + * Since the storage API isn't available in server-rendering environments, we + * return initialValue during SSR and hydration. + * + * Things this hook does different from existing solutions: + * - SSR-capable: it shows initial value during SSR and hydration, but immediately + * initializes when clientside mounted. + * - Sync state across tabs: When another tab changes the value in the storage area, the + * current tab follows suit. + */ +function useStorageStateBrowser( + kind: 'session' | 'local', + key: string, + initializer: string | Initializer, +): UseStorageStateHookResult; +function useStorageStateBrowser( + kind: 'session' | 'local', + key: string, + initializer?: string | null | Initializer, +): UseStorageStateHookResult; +function useStorageStateBrowser( + kind: 'session' | 'local', + key: string, + initializer: string | null | Initializer = null, +): UseStorageStateHookResult | UseStorageStateHookResult { + const [initialValue] = React.useState(initializer); + const area = kind === 'session' ? window.sessionStorage : window.localStorage; + const subscribeKey = React.useCallback((cb: () => void) => subscribe(area, key, cb), [area, key]); + const getKeySnapshot = React.useCallback( + () => getSnapshot(area, key) ?? initialValue, + [area, initialValue, key], + ); + const getKeyServerSnapshot = React.useCallback(() => initialValue, [initialValue]); + + const storedValue = React.useSyncExternalStore( + subscribeKey, + getKeySnapshot, + getKeyServerSnapshot, + ); + + const setStoredValue = React.useCallback( + (value: React.SetStateAction) => { + const valueToStore = value instanceof Function ? value(storedValue) : value; + setValue(area, key, valueToStore); + }, + [area, key, storedValue], + ); + + return [storedValue, setStoredValue]; +} + +export default typeof window === 'undefined' ? useStorageStateServer : useStorageStateBrowser; diff --git a/packages/toolpad-utils/src/http.ts b/packages/toolpad-utils/src/http.ts index 2954385ac69..d5862ca0775 100644 --- a/packages/toolpad-utils/src/http.ts +++ b/packages/toolpad-utils/src/http.ts @@ -4,8 +4,8 @@ import invariant from 'invariant'; /** * A Promise wrapper for server.listen */ -export async function listen(handler: http.RequestListener, port?: number) { - const server = http.createServer(handler); +export async function listen(handler: http.RequestListener | http.Server, port?: number) { + const server = typeof handler === 'function' ? http.createServer(handler) : handler; let app: http.Server | undefined; await new Promise((resolve, reject) => { app = server.listen(port); @@ -18,7 +18,7 @@ export async function listen(handler: http.RequestListener, port?: number) { return { port: address.port, - async stopServer() { + async close() { await new Promise((resolve, reject) => { if (app) { app.close((err) => { diff --git a/packages/toolpad-utils/src/strings.ts b/packages/toolpad-utils/src/strings.ts index 31b4ac2452a..1b5e02e439e 100644 --- a/packages/toolpad-utils/src/strings.ts +++ b/packages/toolpad-utils/src/strings.ts @@ -164,3 +164,10 @@ export function prependLines(text: string, prefix: string): string { export function indent(text: string, length = 2): string { return prependLines(text, ' '.repeat(length)); } + +/** + * Returns true if the string is a valid javascript identifier + */ +export function isValidJsIdentifier(base: string): boolean { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(base); +} diff --git a/test/integration/rest-basic/index.spec.ts b/test/integration/rest-basic/index.spec.ts index d937f68fa8e..475da3a68c4 100644 --- a/test/integration/rest-basic/index.spec.ts +++ b/test/integration/rest-basic/index.spec.ts @@ -27,7 +27,7 @@ test.beforeAll(async ({ localApp }) => { }); test.afterAll(async () => { - testServer?.stopServer(); + testServer?.close(); }); test('rest runtime basics', async ({ page, localApp }) => { diff --git a/test/visual/components/index.spec.ts b/test/visual/components/index.spec.ts index 3a4ae42fa28..d661c9b4739 100644 --- a/test/visual/components/index.spec.ts +++ b/test/visual/components/index.spec.ts @@ -36,12 +36,10 @@ test('rendering components in the app editor', async ({ page, argosScreenshot }) test('showing grid while resizing elements', async ({ page, argosScreenshot }) => { const editorModel = new ToolpadEditor(page); - await editorModel.goto(); + await editorModel.goToPageById('5YDOftB'); await editorModel.waitForOverlay(); - await editorModel.goToPage('rows'); - const firstText = editorModel.appCanvas.getByText('text').first(); await clickCenter(page, firstText);