diff --git a/packages/toolpad-app/pages/api/rpc.ts b/packages/toolpad-app/pages/api/rpc.ts index 3a651d858f0..a6622a1e5e4 100644 --- a/packages/toolpad-app/pages/api/rpc.ts +++ b/packages/toolpad-app/pages/api/rpc.ts @@ -18,6 +18,8 @@ import { findActiveDeployment, findLastRelease, deleteApp, + deploy, + getDeployments, } from '../../src/server/data'; import { hasOwnProperty } from '../../src/utils/collections'; @@ -117,6 +119,9 @@ const rpcServer = { getActiveDeployments: createMethod((params) => { return getActiveDeployments(...params); }), + getDeployments: createMethod((params) => { + return getDeployments(...params); + }), getApp: createMethod((params) => { return getApp(...params); }), @@ -155,6 +160,9 @@ const rpcServer = { createDeployment: createMethod((params) => { return createDeployment(...params); }), + deploy: createMethod((params) => { + return deploy(...params); + }), saveDom: createMethod((params) => { return saveDom(...params); }), diff --git a/packages/toolpad-app/src/server/data.ts b/packages/toolpad-app/src/server/data.ts index c3b0f63eae5..2a144bd3334 100644 --- a/packages/toolpad-app/src/server/data.ts +++ b/packages/toolpad-app/src/server/data.ts @@ -1,12 +1,6 @@ import { NodeId, BindableAttrValue } from '@mui/toolpad-core'; import * as _ from 'lodash-es'; -import { - App, - DomNodeAttributeType, - PrismaClient, - Release, - Prisma, -} from '../../prisma/generated/client'; +import * as prisma from '../../prisma/generated/client'; import { ServerDataSource, ApiResult, VersionOrPreview } from '../types'; import serverDataSources from '../toolpadDataSources/server'; import * as appDom from '../appDom'; @@ -14,43 +8,30 @@ import { omit } from '../utils/immutability'; import { asArray } from '../utils/collections'; import { decryptSecret, encryptSecret } from './secrets'; import applyTransform from './applyTransform'; +import { excludeFields } from '../utils/prisma'; -// See https://github.com/prisma/prisma/issues/5042#issuecomment-1104679760 -function excludeFields( - fields: T, - excluded: K, -): Record, boolean> { - const result = {} as Record, boolean>; - for (const key of Object.keys(fields)) { - if (!excluded.includes(key as any)) { - result[key as Exclude] = true; - } - } - return result; -} +const SELECT_RELEASE_META = excludeFields(prisma.Prisma.ReleaseScalarFieldEnum, ['snapshot']); +const SELECT_APP_META = excludeFields(prisma.Prisma.AppScalarFieldEnum, ['dom']); -const SELECT_RELEASE_META = excludeFields(Prisma.ReleaseScalarFieldEnum, ['snapshot']); -const SELECT_APP_META = excludeFields(Prisma.AppScalarFieldEnum, ['dom']); +export type AppMeta = Omit; -export type AppMeta = Omit; - -function getPrismaClient(): PrismaClient { +function getPrismaClient(): prisma.PrismaClient { if (process.env.NODE_ENV === 'production') { - return new PrismaClient(); + return new prisma.PrismaClient(); } // avoid Next.js dev server from creating too many prisma clients // See https://github.com/prisma/prisma/issues/1983 if (!(globalThis as any).prisma) { - (globalThis as any).prisma = new PrismaClient(); + (globalThis as any).prisma = new prisma.PrismaClient(); } return (globalThis as any).prisma; } -const prisma = getPrismaClient(); +const prismaClient = getPrismaClient(); -function deserializeValue(dbValue: string, type: DomNodeAttributeType): unknown { +function deserializeValue(dbValue: string, type: prisma.DomNodeAttributeType): unknown { const serialized = type === 'secret' ? decryptSecret(dbValue) : dbValue; return serialized.length <= 0 ? undefined : JSON.parse(serialized); } @@ -90,7 +71,7 @@ function decryptSecrets(dom: appDom.AppDom): appDom.AppDom { } export async function saveDom(appId: string, app: appDom.AppDom): Promise { - await prisma.app.update({ + await prismaClient.app.update({ where: { id: appId, }, @@ -100,7 +81,7 @@ export async function saveDom(appId: string, app: appDom.AppDom): Promise } async function loadPreviewDomLegacy(appId: string): Promise { - const dbNodes = await prisma.domNode.findMany({ + const dbNodes = await prismaClient.domNode.findMany({ where: { appId }, include: { attributes: true }, }); @@ -146,7 +127,7 @@ async function loadPreviewDomLegacy(appId: string): Promise { } async function loadPreviewDom(appId: string): Promise { - const { dom } = await prisma.app.findUniqueOrThrow({ + const { dom } = await prismaClient.app.findUniqueOrThrow({ where: { id: appId }, }); @@ -158,7 +139,7 @@ async function loadPreviewDom(appId: string): Promise { } export async function getApps(): Promise { - return prisma.app.findMany({ + return prismaClient.app.findMany({ orderBy: { editedAt: 'desc', }, @@ -167,14 +148,14 @@ export async function getApps(): Promise { } export async function getActiveDeployments() { - return prisma.deployment.findMany({ + return prismaClient.deployment.findMany({ distinct: ['appId'], orderBy: { createdAt: 'desc' }, }); } export async function getApp(id: string): Promise { - return prisma.app.findUnique({ where: { id }, select: SELECT_APP_META }); + return prismaClient.app.findUnique({ where: { id }, select: SELECT_APP_META }); } function createDefaultDom(): appDom.AppDom { @@ -198,9 +179,9 @@ export interface CreateAppOptions { dom?: appDom.AppDom | null; } -export async function createApp(name: string, opts: CreateAppOptions = {}): Promise { - return prisma.$transaction(async () => { - const app = await prisma.app.create({ +export async function createApp(name: string, opts: CreateAppOptions = {}): Promise { + return prismaClient.$transaction(async () => { + const app = await prismaClient.app.create({ data: { name }, }); @@ -213,7 +194,7 @@ export async function createApp(name: string, opts: CreateAppOptions = {}): Prom } export async function updateApp(appId: string, name: string): Promise { - await prisma.app.update({ + await prismaClient.app.update({ where: { id: appId, }, @@ -226,7 +207,7 @@ export async function updateApp(appId: string, name: string): Promise { } export async function deleteApp(id: string): Promise { - await prisma.app.delete({ + await prismaClient.app.delete({ where: { id }, select: { // Only return the id to reduce amount of data returned from the db @@ -235,19 +216,21 @@ export async function deleteApp(id: string): Promise { }); } -interface CreateReleaseParams { +export interface CreateReleaseParams { description: string; } +export type ReleaseMeta = Pick; + async function findLastReleaseInternal(appId: string) { - return prisma.release.findFirst({ + return prismaClient.release.findFirst({ where: { appId }, orderBy: { version: 'desc' }, }); } -export async function findLastRelease(appId: string) { - return prisma.release.findFirst({ +export async function findLastRelease(appId: string): Promise { + return prismaClient.release.findFirst({ where: { appId }, orderBy: { version: 'desc' }, select: SELECT_RELEASE_META, @@ -257,14 +240,14 @@ export async function findLastRelease(appId: string) { export async function createRelease( appId: string, { description }: CreateReleaseParams, -): Promise> { +): Promise { const currentDom = await loadPreviewDom(appId); const snapshot = Buffer.from(JSON.stringify(currentDom), 'utf-8'); const lastRelease = await findLastReleaseInternal(appId); const versionNumber = lastRelease ? lastRelease.version + 1 : 1; - const release = await prisma.release.create({ + const release = await prismaClient.release.create({ select: SELECT_RELEASE_META, data: { appId, @@ -277,8 +260,8 @@ export async function createRelease( return release; } -export async function getReleases(appId: string) { - return prisma.release.findMany({ +export async function getReleases(appId: string): Promise { + return prismaClient.release.findMany({ where: { appId }, select: SELECT_RELEASE_META, orderBy: { @@ -287,15 +270,31 @@ export async function getReleases(appId: string) { }); } -export async function getRelease(appId: string, version: number) { - return prisma.release.findUnique({ +export async function getRelease(appId: string, version: number): Promise { + return prismaClient.release.findUnique({ where: { release_app_constraint: { appId, version } }, select: SELECT_RELEASE_META, }); } -export async function createDeployment(appId: string, version: number) { - return prisma.deployment.create({ +export type Deployment = prisma.Deployment & { + release: ReleaseMeta; +}; + +export function getDeployments(appId: string): Promise { + return prismaClient.deployment.findMany({ + where: { appId }, + orderBy: { createdAt: 'desc' }, + include: { + release: { + select: SELECT_RELEASE_META, + }, + }, + }); +} + +export async function createDeployment(appId: string, version: number): Promise { + return prismaClient.deployment.create({ data: { app: { connect: { id: appId }, @@ -304,11 +303,25 @@ export async function createDeployment(appId: string, version: number) { connect: { release_app_constraint: { appId, version } }, }, }, + include: { + release: { + select: SELECT_RELEASE_META, + }, + }, }); } -export async function findActiveDeployment(appId: string) { - return prisma.deployment.findFirst({ +export async function deploy( + appId: string, + releaseInput: CreateReleaseParams, +): Promise { + const release = await createRelease(appId, releaseInput); + const deployment = await createDeployment(appId, release.version); + return deployment; +} + +export async function findActiveDeployment(appId: string): Promise { + return prismaClient.deployment.findFirst({ where: { appId }, orderBy: { createdAt: 'desc' }, include: { @@ -320,7 +333,7 @@ export async function findActiveDeployment(appId: string) { } export async function loadReleaseDom(appId: string, version: number): Promise { - const release = await prisma.release.findUnique({ + const release = await prismaClient.release.findUnique({ where: { release_app_constraint: { appId, version } }, }); if (!release) { diff --git a/packages/toolpad-app/src/toolpad/AppEditor/AppEditorShell.tsx b/packages/toolpad-app/src/toolpad/AppEditor/AppEditorShell.tsx index 4e69033aa81..3a6b3dd28df 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/AppEditorShell.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/AppEditorShell.tsx @@ -8,7 +8,6 @@ import { DialogActions, DialogContent, DialogTitle, - IconButton, Stack, TextField, Tooltip, @@ -17,14 +16,16 @@ import { import CloudDoneIcon from '@mui/icons-material/CloudDone'; import * as React from 'react'; import { useForm } from 'react-hook-form'; -import { Outlet, useNavigate } from 'react-router-dom'; +import { Outlet } from 'react-router-dom'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; +import invariant from 'invariant'; import DialogForm from '../../components/DialogForm'; import { useDomLoader } from '../DomLoader'; -import ToolpadAppShell from '../ToolpadAppShell'; +import ToolpadShell from '../ToolpadShell'; import PagePanel from './PagePanel'; import client from '../../api'; +import useBoolean from '../../utils/useBoolean'; interface CreateReleaseDialogProps { appId: string; @@ -33,8 +34,6 @@ interface CreateReleaseDialogProps { } function CreateReleaseDialog({ appId, open, onClose }: CreateReleaseDialogProps) { - const navigate = useNavigate(); - const lastRelease = client.useQuery('findLastRelease', [appId]); const { handleSubmit, register, formState, reset } = useForm({ @@ -43,32 +42,32 @@ function CreateReleaseDialog({ appId, open, onClose }: CreateReleaseDialogProps) }, }); - const createReleaseMutation = client.useMutation('createRelease'); + React.useEffect(() => { + if (open) { + reset(); + } + }, [reset, open]); + + const deployMutation = client.useMutation('deploy'); const doSubmit = handleSubmit(async (releaseParams) => { - const newRelease = await createReleaseMutation.mutateAsync([appId, releaseParams]); - reset(); - navigate(`/app/${appId}/releases/${newRelease.version}`); + await deployMutation.mutateAsync([appId, releaseParams]); + const url = new URL(`/deploy/${appId}/pages`, window.location.href); + const deploymentWindow = window.open(url, '_blank'); + invariant(deploymentWindow, 'window failed to open'); + deploymentWindow.focus(); + onClose(); }); return ( - Create new release + Deploy application {lastRelease.isSuccess ? ( - You are about to create a snapshot of your application under a unique url. You will - be able to verify whether everything is working correctly before deploying this - release to production. - - - The new version to be created is " - {lastRelease.data ? lastRelease.data.version + 1 : 1}". - - - Please summarize the changes you have made to the application since the last - release: + You are about to deploy your application to production. Please summarize the changes + you have made to the application since the last release: ) : null} - {createReleaseMutation.isError ? ( - {(createReleaseMutation.error as Error).message} + {deployMutation.isError ? ( + {(deployMutation.error as Error).message} ) : null} @@ -93,10 +92,10 @@ function CreateReleaseDialog({ appId, open, onClose }: CreateReleaseDialogProps) - Create + Deploy @@ -114,22 +113,25 @@ function getSaveStateMessage(isSaving: boolean, hasUnsavedChanges: boolean): str return 'All changes saved!'; } -export interface ToolpadAppShellProps { +export interface ToolpadShellProps { appId: string; actions?: React.ReactNode; } -export default function AppEditorShell({ appId, ...props }: ToolpadAppShellProps) { +export default function AppEditorShell({ appId, ...props }: ToolpadShellProps) { const domLoader = useDomLoader(); - const [createReleaseDialogOpen, setCreateReleaseDialogOpen] = React.useState(false); + const { + value: createReleaseDialogOpen, + setTrue: handleCreateReleasDialogOpen, + setFalse: handleCreateReleasDialogClose, + } = useBoolean(false); const hasUnsavedChanges = domLoader.unsavedChanges > 0; const isSaving = domLoader.saving; return ( - @@ -144,15 +146,9 @@ export default function AppEditorShell({ appId, ...props }: ToolpadAppShellProps )} - setCreateReleaseDialogOpen(true)} - > - - + } {...props} @@ -193,9 +197,9 @@ export default function AppEditorShell({ appId, ...props }: ToolpadAppShellProps setCreateReleaseDialogOpen(false)} + onClose={handleCreateReleasDialogClose} /> - + ); } diff --git a/packages/toolpad-app/src/toolpad/Release.tsx b/packages/toolpad-app/src/toolpad/Release.tsx deleted file mode 100644 index 4ad7f8a65cf..00000000000 --- a/packages/toolpad-app/src/toolpad/Release.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { - Button, - Container, - Toolbar, - Typography, - Paper, - Breadcrumbs, - Link as MuiLink, - Stack, -} from '@mui/material'; -import * as React from 'react'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; -import { Link, useParams } from 'react-router-dom'; -import client from '../api'; -import ToolpadAppShell from './ToolpadAppShell'; -import DefinitionList from '../components/DefinitionList'; -import useLatest from '../utils/useLatest'; -import useDialog from '../utils/useDialog'; -import { ConfirmDialog } from '../components/SystemDialogs'; - -function getDeploymentStatusMessage(version: number, activeVersion?: number): React.ReactNode { - if (typeof activeVersion === 'undefined') { - return App is not deployed; - } - - return version === activeVersion ? ( - This is the deployed release - ) : ( - - - Version "{activeVersion}" - {' '} - is currently deployed - - ); -} - -interface ConfirmDeployDialogProps { - data?: { - version: number; - }; - open: boolean; - onClose: (confirm: boolean) => void; -} - -function ConfirmDeployDialog({ open, onClose, data: dataProp }: ConfirmDeployDialogProps) { - const data = useLatest(dataProp); - - return data ? ( - - Press "Deploy" to change the canonical URL of your application to - version "{data.version}". - - ) : null; -} - -interface ActiveReleaseMessageProps { - appId: string; - version: number; - activeVersion?: number; -} - -function DeploymentStatus({ appId, activeVersion, version }: ActiveReleaseMessageProps) { - const msg: React.ReactNode = getDeploymentStatusMessage(version, activeVersion); - const isActiveDeployment = activeVersion === version; - - const deployReleaseMutation = client.useMutation('createDeployment'); - - const { element, show: showConfirmDialog } = useDialog(ConfirmDeployDialog); - - const handleDeployClick = React.useCallback(async () => { - const ok = await showConfirmDialog({ version }); - - if (!ok) { - return; - } - - if (version) { - await deployReleaseMutation.mutateAsync([appId, version]); - client.invalidateQueries('findActiveDeployment', [appId]); - } - }, [appId, deployReleaseMutation, showConfirmDialog, version]); - - const canDeploy = deployReleaseMutation.isIdle && !isActiveDeployment; - const isNewerVersion = version >= (activeVersion ?? -Infinity); - - return ( - - {msg} - - - - - {element} - - ); -} - -export default function Release() { - const { version: rawVersion, appId } = useParams(); - const version = Number(rawVersion); - - if (!appId) { - throw new Error(`Missing queryParam "appId"`); - } - - const releaseQuery = client.useQuery('getRelease', [appId, version]); - - const activeDeploymentQuery = client.useQuery('findActiveDeployment', [appId]); - - const permaLink = String(new URL(`/app/${appId}/${version}`, window.location.href)); - - return ( - - - - - - Releases - - Version "{version}" - - - Version "{version}" - - -
Created:
-
{releaseQuery?.data?.createdAt.toLocaleString('short')}
-
Description:
-
{releaseQuery?.data?.description}
-
Permalink:
-
- {permaLink} -
-
- -
-
-
-
- ); -} diff --git a/packages/toolpad-app/src/toolpad/Releases.tsx b/packages/toolpad-app/src/toolpad/Releases.tsx deleted file mode 100644 index eccb8680f57..00000000000 --- a/packages/toolpad-app/src/toolpad/Releases.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { - Container, - Typography, - Box, - Paper, - Skeleton, - Button, - styled, - Breadcrumbs, - Stack, -} from '@mui/material'; -import { DataGridPro, GridColumns } from '@mui/x-data-grid-pro'; -import * as React from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; -import type { Deployment } from '../../prisma/generated/client'; -import client from '../api'; -import { Maybe } from '../utils/types'; -import ToolpadAppShell from './ToolpadAppShell'; -import DefinitionList from '../components/DefinitionList'; - -interface ReleaseRow { - createdAt: Date; - version: number; - description: string; -} - -const DeploymentActions = styled('div')(({ theme }) => ({ - marginTop: theme.spacing(1), -})); - -type ActiveDeploymentProps = { - value?: Maybe; -}; - -function ActiveDeployment({ value }: ActiveDeploymentProps) { - const url = value ? String(new URL(`/deploy/${value.appId}`, window.location.href)) : null; - - return ( - - - {value ? ( - Version "{value.version}" - ) : ( - - )} - - Currently active deployment - -
Deployed:
-
{value ? value.createdAt.toLocaleString('short') : }
-
- - - - -
- ); -} - -interface NoActiveDeploymentProps { - appId: string; - releases?: ReleaseRow[]; -} - -function NoActiveDeployment({ appId, releases = [] }: NoActiveDeploymentProps) { - const latestRelease = releases.length > 0 ? releases[0] : null; - - const deployReleaseMutation = client.useMutation('createDeployment'); - - const handleDeployClick = React.useCallback(async () => { - if (latestRelease) { - await deployReleaseMutation.mutateAsync([appId, latestRelease.version]); - client.invalidateQueries('findActiveDeployment', [appId]); - } - }, [appId, deployReleaseMutation, latestRelease]); - - return ( - - App not deployed yet - - - {latestRelease ? ( - - ) : ( - There are no releases to deploy - )} - - - ); -} - -export default function Releases() { - const { appId } = useParams(); - - if (!appId) { - throw new Error(`Missing queryParam "appId"`); - } - - const navigate = useNavigate(); - const { data: releases = [], isLoading, error } = client.useQuery('getReleases', [appId]); - - const activeDeploymentQuery = client.useQuery('findActiveDeployment', [appId]); - const activeVersion = activeDeploymentQuery.data?.release.version; - - const columns = React.useMemo>( - () => [ - { - field: 'version', - headerName: 'Version', - }, - { - field: 'description', - headerName: 'Description', - flex: 1, - }, - { - field: 'active', - headerName: 'Deployed', - type: 'boolean', - valueGetter: (params) => params.row.version === activeVersion, - }, - { - field: 'createdAt', - headerName: 'Created', - type: 'date', - }, - ], - [activeVersion], - ); - - return ( - - - - - Releases - - - - {activeDeploymentQuery.isLoading || activeDeploymentQuery.data ? ( - - ) : ( - - )} - - - - row.version} - loading={isLoading} - error={(error as any)?.message} - onRowClick={({ row }) => navigate(`/app/${appId}/releases/${row.version}`)} - /> - - - - - ); -} diff --git a/packages/toolpad-app/src/toolpad/Toolpad.tsx b/packages/toolpad-app/src/toolpad/Toolpad.tsx index f0bf5bb9222..b463aaf0d76 100644 --- a/packages/toolpad-app/src/toolpad/Toolpad.tsx +++ b/packages/toolpad-app/src/toolpad/Toolpad.tsx @@ -2,8 +2,6 @@ import * as React from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { Box, CircularProgress, NoSsr, styled } from '@mui/material'; import { ErrorBoundary } from 'react-error-boundary'; -import Release from './Release'; -import Releases from './Releases'; import AppEditor from './AppEditor'; import Home from './Home'; import ErrorAlert from './AppEditor/PageEditor/ErrorAlert'; @@ -40,8 +38,6 @@ function AppWorkspace() { } /> - } /> - } /> ); diff --git a/packages/toolpad-app/src/toolpad/ToolpadAppShell.tsx b/packages/toolpad-app/src/toolpad/ToolpadAppShell.tsx deleted file mode 100644 index 70725cc45a1..00000000000 --- a/packages/toolpad-app/src/toolpad/ToolpadAppShell.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from 'react'; -import { Button } from '@mui/material'; -import { Link } from 'react-router-dom'; -import ToolpadShell from './ToolpadShell'; - -export interface ToolpadAppShellProps { - appId: string; - actions?: React.ReactNode; - children?: React.ReactNode; -} - -export default function ToolpadAppShell({ appId, ...props }: ToolpadAppShellProps) { - return ( - - - - - } - {...props} - /> - ); -} diff --git a/packages/toolpad-app/src/utils/prisma.ts b/packages/toolpad-app/src/utils/prisma.ts new file mode 100644 index 00000000000..d967ca43605 --- /dev/null +++ b/packages/toolpad-app/src/utils/prisma.ts @@ -0,0 +1,13 @@ +// See https://github.com/prisma/prisma/issues/5042#issuecomment-1104679760 +export function excludeFields( + fields: T, + excluded: K, +): Record, boolean> { + const result = {} as Record, boolean>; + for (const key of Object.keys(fields)) { + if (!excluded.includes(key as any)) { + result[key as Exclude] = true; + } + } + return result; +}