diff --git a/packages/toolpad-app/pages/_document.tsx b/packages/toolpad-app/pages/_document.tsx index 688ac4e1282..2e616ac3f60 100644 --- a/packages/toolpad-app/pages/_document.tsx +++ b/packages/toolpad-app/pages/_document.tsx @@ -100,13 +100,13 @@ export default class MyDocument extends Document { strategy="afterInteractive" dangerouslySetInnerHTML={{ __html: ` - window.dataLayer = window.dataLayer || []; - function gtag(){dataLayer.push(arguments);} - gtag('js', new Date()); - gtag('config', '${config.gaId}', { - page_path: window.location.pathname, - }); - `, + window.dataLayer = window.dataLayer || []; + function gtag(){dataLayer.push(arguments);} + gtag('js', new Date()); + gtag('config', '${config.gaId}', { + page_path: window.location.pathname, + }); + `, }} /> diff --git a/packages/toolpad-app/pages/app-canvas/[[...path]].tsx b/packages/toolpad-app/pages/app-canvas/[[...path]].tsx index 333a68c6e54..03401f8e8ec 100644 --- a/packages/toolpad-app/pages/app-canvas/[[...path]].tsx +++ b/packages/toolpad-app/pages/app-canvas/[[...path]].tsx @@ -1,6 +1,17 @@ -import type { NextPage } from 'next'; +import { asArray } from '@mui/toolpad-core/utils/collections'; +import type { GetServerSideProps, NextPage } from 'next'; import * as React from 'react'; import AppCanvas, { AppCanvasProps } from '../../src/canvas'; -const App: NextPage = () => ; +export const getServerSideProps: GetServerSideProps = async ({ query }) => { + const [appId] = asArray(query.path); + return { + props: { + basename: `/app-canvas/${appId}`, + }, + }; +}; + +const App: NextPage = (props) => ; + export default App; diff --git a/packages/toolpad-app/src/appDom/index.ts b/packages/toolpad-app/src/appDom/index.ts index fac3365c7ce..0b0aee7cfa4 100644 --- a/packages/toolpad-app/src/appDom/index.ts +++ b/packages/toolpad-app/src/appDom/index.ts @@ -1093,3 +1093,20 @@ export function deref(nodeRef: Maybe): NodeId | null { } return null; } + +export function createDefaultDom(): AppDom { + let dom = createDom(); + const appNode = getApp(dom); + + // Create default page + const newPageNode = createNode(dom, 'page', { + name: 'Page 1', + attributes: { + title: createConst('Page 1'), + }, + }); + + dom = addNode(dom, newPageNode, appNode, 'pages'); + + return dom; +} diff --git a/packages/toolpad-app/src/canvas/index.tsx b/packages/toolpad-app/src/canvas/index.tsx index fddf2f7efa4..1c5374f1b97 100644 --- a/packages/toolpad-app/src/canvas/index.tsx +++ b/packages/toolpad-app/src/canvas/index.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import invariant from 'invariant'; import { throttle } from 'lodash-es'; import { CanvasEventsContext } from '@mui/toolpad-core/runtime'; +import { ToolpadComponent } from '@mui/toolpad-core'; import ToolpadApp from '../runtime'; import { NodeHashes, RuntimeState } from '../types'; import getPageViewState from './getPageViewState'; @@ -22,11 +23,13 @@ const handleScreenUpdate = throttle( ); export interface AppCanvasProps { + initialState?: AppCanvasState | null; basename: string; + catalog?: Record; } -export default function AppCanvas({ basename }: AppCanvasProps) { - const [state, setState] = React.useState(null); +export default function AppCanvas({ catalog, basename, initialState = null }: AppCanvasProps) { + const [state, setState] = React.useState(initialState); const appRootRef = React.useRef(); const appRootCleanupRef = React.useRef<() => void>(); @@ -130,9 +133,10 @@ export default function AppCanvas({ basename }: AppCanvasProps) { diff --git a/packages/toolpad-app/src/createRuntimeState.tsx b/packages/toolpad-app/src/createRuntimeState.tsx index 8b1e52fbf17..adac991ddaa 100644 --- a/packages/toolpad-app/src/createRuntimeState.tsx +++ b/packages/toolpad-app/src/createRuntimeState.tsx @@ -6,11 +6,13 @@ function compileModules(dom: appDom.AppDom): Record { const result: Record = {}; const root = appDom.getApp(dom); const { codeComponents = [], pages = [] } = appDom.getChildNodes(dom, root); + for (const node of codeComponents) { const src = node.attributes.code.value; const name = `codeComponents/${node.id}`; result[name] = compileModule(src, name); } + for (const node of pages) { const src = node.attributes.module?.value; if (src) { diff --git a/packages/toolpad-app/src/runtime/ComponentsContext.tsx b/packages/toolpad-app/src/runtime/ComponentsContext.tsx index 4735f2a8536..4a6d871db21 100644 --- a/packages/toolpad-app/src/runtime/ComponentsContext.tsx +++ b/packages/toolpad-app/src/runtime/ComponentsContext.tsx @@ -33,14 +33,23 @@ function isToolpadComponent(maybeComponent: unknown): maybeComponent is ToolpadC } interface ComponentsContextProps { + catalog?: Record; dom: appDom.AppDom; children?: React.ReactNode; } -export default function ComponentsContext({ dom, children }: ComponentsContextProps) { +export default function ComponentsContext({ + catalog: componentsCatalog, + dom, + children, +}: ComponentsContextProps) { const modules = useAppModules(); const components = React.useMemo(() => { + if (componentsCatalog) { + return componentsCatalog; + } + const catalog = getToolpadComponents(dom); const result: Record> = {}; @@ -77,7 +86,7 @@ export default function ComponentsContext({ dom, children }: ComponentsContextPr } return result; - }, [dom, modules]); + }, [componentsCatalog, dom, modules]); return {children}; } diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index 691bacd92ad..557f0641b58 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -930,6 +930,7 @@ const queryClient = new QueryClient({ export interface ToolpadAppProps { rootRef?: React.Ref; + catalog?: Record; hidePreviewBanner?: boolean; basename: string; version: VersionOrPreview; @@ -938,6 +939,7 @@ export interface ToolpadAppProps { export default function ToolpadApp({ rootRef, + catalog, basename, version, hidePreviewBanner, @@ -969,7 +971,7 @@ export default function ToolpadApp({ }> - + diff --git a/packages/toolpad-app/src/server/config.ts b/packages/toolpad-app/src/server/config.ts index 58ba56a314e..e14060c8873 100644 --- a/packages/toolpad-app/src/server/config.ts +++ b/packages/toolpad-app/src/server/config.ts @@ -11,7 +11,7 @@ type BasicAuthConfig = }; export type ServerConfig = { - databaseUrl: string; + databaseUrl?: string; googleSheetsClientId?: string; googleSheetsClientSecret?: string; encryptionKeys: string[]; @@ -28,10 +28,6 @@ function readConfig(): ServerConfig & typeof sharedConfig { throw new Error(`Serverside config can't be loaded on the client side`); } - if (!process.env.TOOLPAD_DATABASE_URL) { - throw new Error(`App started without config env variable TOOLPAD_DATABASE_URL`); - } - // Whitespace separated, do not use spaces in your keys const encryptionKeys: string[] = process.env.TOOLPAD_ENCRYPTION_KEYS?.split(/\s+/).filter(Boolean) ?? []; diff --git a/packages/toolpad-app/src/server/data.ts b/packages/toolpad-app/src/server/data.ts index 87c4d1f42e3..e45365c3b8f 100644 --- a/packages/toolpad-app/src/server/data.ts +++ b/packages/toolpad-app/src/server/data.ts @@ -23,7 +23,11 @@ const SELECT_APP_META = excludeFields(prisma.Prisma.AppScalarFieldEnum, ['dom']) export type AppMeta = Omit; -function getPrismaClient(): prisma.PrismaClient { +function createPrismaClient(): prisma.PrismaClient { + if (!process.env.TOOLPAD_DATABASE_URL) { + throw new Error(`App started without config env variable TOOLPAD_DATABASE_URL`); + } + if (process.env.NODE_ENV === 'production') { return new prisma.PrismaClient(); } @@ -37,7 +41,13 @@ function getPrismaClient(): prisma.PrismaClient { return (globalThis as any).prisma; } -const prismaClient = getPrismaClient(); +let clientInstance: prisma.PrismaClient | undefined; +function getPrismaClient(): prisma.PrismaClient { + if (!clientInstance) { + clientInstance = createPrismaClient(); + } + return clientInstance; +} function deserializeValue(dbValue: string, type: prisma.DomNodeAttributeType): unknown { const serialized = type === 'secret' ? decryptSecret(dbValue) : dbValue; @@ -79,6 +89,7 @@ function decryptSecrets(dom: appDom.AppDom): appDom.AppDom { } export async function saveDom(appId: string, app: appDom.AppDom): Promise { + const prismaClient = getPrismaClient(); await prismaClient.app.update({ where: { id: appId, @@ -89,6 +100,7 @@ export async function saveDom(appId: string, app: appDom.AppDom): Promise } async function loadPreviewDomLegacy(appId: string): Promise { + const prismaClient = getPrismaClient(); const dbNodes = await prismaClient.domNode.findMany({ where: { appId }, include: { attributes: true }, @@ -137,6 +149,7 @@ async function loadPreviewDomLegacy(appId: string): Promise { } async function loadPreviewDom(appId: string): Promise { + const prismaClient = getPrismaClient(); const { dom } = await prismaClient.app.findUniqueOrThrow({ where: { id: appId }, }); @@ -153,6 +166,7 @@ async function loadPreviewDom(appId: string): Promise { } export async function getApps(): Promise { + const prismaClient = getPrismaClient(); if (config.isDemo) { return []; } @@ -166,6 +180,7 @@ export async function getApps(): Promise { } export async function getActiveDeployments() { + const prismaClient = getPrismaClient(); return prismaClient.deployment.findMany({ distinct: ['appId'], orderBy: { createdAt: 'desc' }, @@ -173,26 +188,10 @@ export async function getActiveDeployments() { } export async function getApp(id: string): Promise { + const prismaClient = getPrismaClient(); return prismaClient.app.findUnique({ where: { id }, select: SELECT_APP_META }); } -function createDefaultDom(): appDom.AppDom { - let dom = appDom.createDom(); - const appNode = appDom.getApp(dom); - - // Create default page - const newPageNode = appDom.createNode(dom, 'page', { - name: 'Page 1', - attributes: { - title: appDom.createConst('Page 1'), - }, - }); - - dom = appDom.addNode(dom, newPageNode, appNode, 'pages'); - - return dom; -} - export type CreateAppOptions = { from?: | { @@ -210,6 +209,7 @@ export type CreateAppOptions = { }; export async function createApp(name: string, opts: CreateAppOptions = {}): Promise { + const prismaClient = getPrismaClient(); const { from } = opts; if (config.recaptchaV3SecretKey) { @@ -259,7 +259,7 @@ export async function createApp(name: string, opts: CreateAppOptions = {}): Prom } if (!dom) { - dom = createDefaultDom(); + dom = appDom.createDefaultDom(); } await saveDom(app.id, dom); @@ -274,6 +274,7 @@ interface AppUpdates { } export async function updateApp(appId: string, updates: AppUpdates): Promise { + const prismaClient = getPrismaClient(); await prismaClient.app.update({ where: { id: appId, @@ -287,6 +288,7 @@ export async function updateApp(appId: string, updates: AppUpdates): Promise { + const prismaClient = getPrismaClient(); await prismaClient.app.delete({ where: { id }, select: { @@ -303,6 +305,7 @@ export interface CreateReleaseParams { export type ReleaseMeta = Pick; async function findLastReleaseInternal(appId: string) { + const prismaClient = getPrismaClient(); return prismaClient.release.findFirst({ where: { appId }, orderBy: { version: 'desc' }, @@ -310,6 +313,7 @@ async function findLastReleaseInternal(appId: string) { } export async function findLastRelease(appId: string): Promise { + const prismaClient = getPrismaClient(); return prismaClient.release.findFirst({ where: { appId }, orderBy: { version: 'desc' }, @@ -321,6 +325,7 @@ export async function createRelease( appId: string, { description }: CreateReleaseParams, ): Promise { + const prismaClient = getPrismaClient(); const currentDom = await loadPreviewDom(appId); const snapshot = Buffer.from(JSON.stringify(currentDom), 'utf-8'); @@ -341,6 +346,7 @@ export async function createRelease( } export async function getReleases(appId: string): Promise { + const prismaClient = getPrismaClient(); return prismaClient.release.findMany({ where: { appId }, select: SELECT_RELEASE_META, @@ -351,6 +357,7 @@ export async function getReleases(appId: string): Promise { } export async function getRelease(appId: string, version: number): Promise { + const prismaClient = getPrismaClient(); return prismaClient.release.findUnique({ where: { release_app_constraint: { appId, version } }, select: SELECT_RELEASE_META, @@ -362,6 +369,7 @@ export type Deployment = prisma.Deployment & { }; export function getDeployments(appId: string): Promise { + const prismaClient = getPrismaClient(); return prismaClient.deployment.findMany({ where: { appId }, orderBy: { createdAt: 'desc' }, @@ -374,6 +382,7 @@ export function getDeployments(appId: string): Promise { } export async function createDeployment(appId: string, version: number): Promise { + const prismaClient = getPrismaClient(); return prismaClient.deployment.create({ data: { app: { @@ -401,6 +410,7 @@ export async function deploy( } export async function findActiveDeployment(appId: string): Promise { + const prismaClient = getPrismaClient(); return prismaClient.deployment.findFirst({ where: { appId }, orderBy: { createdAt: 'desc' }, @@ -417,6 +427,7 @@ function parseSnapshot(snapshot: Buffer): appDom.AppDom { } async function loadReleaseDom(appId: string, version: number): Promise { + const prismaClient = getPrismaClient(); const release = await prismaClient.release.findUnique({ where: { release_app_constraint: { appId, version } }, }); @@ -548,6 +559,7 @@ export async function loadRuntimeState( } export async function duplicateApp(id: string, name: string): Promise { + const prismaClient = getPrismaClient(); const dom = await loadPreviewDom(id); const appFromDom: CreateAppOptions = { from: { diff --git a/packages/toolpad-app/src/toolpad/AppEditor/AppEditorShell.tsx b/packages/toolpad-app/src/toolpad/AppEditor/AppEditorShell.tsx index 5a6bce5024e..652faab1052 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/AppEditorShell.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/AppEditorShell.tsx @@ -21,7 +21,6 @@ import SyncProblemIcon from '@mui/icons-material/SyncProblem'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; - import * as React from 'react'; import { useForm } from 'react-hook-form'; import invariant from 'invariant'; @@ -136,16 +135,15 @@ function getSaveState(domLoader: DomLoader): React.ReactNode { ); } -export interface ToolpadShellProps { +interface DeployMenuProps { appId: string; - actions?: React.ReactNode; - children: React.ReactNode; } -export default function AppEditorShell({ appId, children, ...props }: ToolpadShellProps) { - const domLoader = useDomLoader(); +export function DeployMenu({ appId }: DeployMenuProps) { const release = client.useQuery('findLastRelease', [appId]); + const { buttonProps, menuProps } = useMenu(); + const { value: createReleaseDialogOpen, setTrue: handleCreateReleaseDialogOpen, @@ -154,7 +152,57 @@ export default function AppEditorShell({ appId, children, ...props }: ToolpadShe const isDeployed = Boolean(release?.data); - const { buttonProps, menuProps } = useMenu(); + return ( + + + + {isDeployed ? ( + + + + {release.error ? ( + {errorFrom(release.error).message} + ) : ( + + Open current deployed version + + )} + + + ) : null} + + + + + ); +} + +export interface ToolpadShellProps { + appId: string; + actions?: React.ReactNode; + children: React.ReactNode; +} + +export default function AppEditorShell({ appId, children, ...props }: ToolpadShellProps) { + const domLoader = useDomLoader(); return ( Preview - - - {isDeployed ? ( - - - - {release.error ? ( - {errorFrom(release.error).message} - ) : ( - - Open current deployed version - - )} - - - ) : null} - + } status={getSaveState(domLoader)} @@ -231,12 +249,6 @@ export default function AppEditorShell({ appId, children, ...props }: ToolpadShe > {children} - - ); diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PagePanel.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PagePanel.tsx index ab66bb90eaf..948a8830c1b 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PagePanel.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PagePanel.tsx @@ -44,7 +44,8 @@ export default function PagePanel({ appId, className, sx }: ComponentPanelProps) ) : ( )} - {app ? : null} + + diff --git a/packages/toolpad-app/src/toolpad/AppEditor/index.tsx b/packages/toolpad-app/src/toolpad/AppEditor/index.tsx index 1a89989423c..42d5334d233 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/index.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/index.tsx @@ -75,13 +75,18 @@ export interface EditorContentProps { appId: string; } -function EditorContent({ appId }: EditorContentProps) { +export function EditorContent({ appId }: EditorContentProps) { return ( - - - + + + + + + + ); } + export default function Editor() { const { appId } = useParams(); @@ -89,11 +94,5 @@ export default function Editor() { throw new Error(`Missing queryParam "appId"`); } - return ( - - - - - - ); + return ; } diff --git a/packages/toolpad-app/src/toolpad/AppOptions/index.tsx b/packages/toolpad-app/src/toolpad/AppOptions/index.tsx index edfb1b88e23..160080a99fe 100644 --- a/packages/toolpad-app/src/toolpad/AppOptions/index.tsx +++ b/packages/toolpad-app/src/toolpad/AppOptions/index.tsx @@ -7,6 +7,7 @@ import ContentCopyOutlinedIcon from '@mui/icons-material/ContentCopyOutlined'; import SettingsIcon from '@mui/icons-material/Settings'; import DeleteIcon from '@mui/icons-material/Delete'; import CodeIcon from '@mui/icons-material/Code'; +import invariant from 'invariant'; import useMenu from '../../utils/useMenu'; import type { AppMeta } from '../../server/data'; import useBoolean from '../../utils/useBoolean'; @@ -16,23 +17,25 @@ import AppDeleteDialog from './AppDeleteDialog'; import AppDuplicateDialog from './AppDuplicateDialog'; interface AppOptionsProps { - app: AppMeta; - onRename: () => void; + app?: AppMeta | null; + onRenameRequest: () => void; dom?: any; redirectOnDelete?: boolean; } -function AppOptions({ app, onRename, dom, redirectOnDelete }: AppOptionsProps) { +function AppOptions({ app, onRenameRequest: onRename, dom, redirectOnDelete }: AppOptionsProps) { const { buttonProps, menuProps, onMenuClose } = useMenu(); const [deletedApp, setDeletedApp] = React.useState(null); const [duplicateApp, setDuplicateApp] = React.useState(null); const onDuplicate = React.useCallback(() => { + invariant(app, "This action shouln't be enabled when no app is available"); setDuplicateApp(app); }, [app]); const onDelete = React.useCallback(() => { + invariant(app, "This action shouln't be enabled when no app is available"); setDeletedApp(app); }, [app]); @@ -75,7 +78,7 @@ function AppOptions({ app, onRename, dom, redirectOnDelete }: AppOptionsProps) { return ( - + @@ -85,13 +88,13 @@ function AppOptions({ app, onRename, dom, redirectOnDelete }: AppOptionsProps) { Rename - + Duplicate - + @@ -106,7 +109,7 @@ function AppOptions({ app, onRename, dom, redirectOnDelete }: AppOptionsProps) { View DOM ) : null} - + @@ -124,11 +127,13 @@ function AppOptions({ app, onRename, dom, redirectOnDelete }: AppOptionsProps) { onClose={() => setDeletedApp(null)} redirectOnDelete={redirectOnDelete} /> - setDuplicateApp(null)} - /> + {app ? ( + setDuplicateApp(null)} + /> + ) : null} ); } diff --git a/packages/toolpad-app/src/toolpad/Apps/index.tsx b/packages/toolpad-app/src/toolpad/Apps/index.tsx index 97706b109ac..e70e23e6adb 100644 --- a/packages/toolpad-app/src/toolpad/Apps/index.tsx +++ b/packages/toolpad-app/src/toolpad/Apps/index.tsx @@ -515,7 +515,7 @@ function AppCard({ app, activeDeployment, existingAppNames }: AppCardProps) { }} > : null} + action={app ? : null} disableTypography subheader={ @@ -585,7 +585,7 @@ function AppRow({ app, activeDeployment, existingAppNames }: AppRowProps) { - + ) : null} diff --git a/yarn.lock b/yarn.lock index eea092a5f96..5335b06d363 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11564,7 +11564,7 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -rimraf@^4.0.5, rimraf@^4.1.1: +rimraf@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.1.1.tgz#ec29817863e5d82d22bca82f9dc4325be2f1e72b" integrity sha512-Z4Y81w8atcvaJuJuBB88VpADRH66okZAuEm+Jtaufa+s7rZmIz+Hik2G53kGaNytE7lsfXyWktTmfVz0H9xuDg==