diff --git a/packages/toolpad-app/src/api.ts b/packages/toolpad-app/src/api.ts index 46b8dd06199..b82a9cabd35 100644 --- a/packages/toolpad-app/src/api.ts +++ b/packages/toolpad-app/src/api.ts @@ -86,6 +86,10 @@ interface ApiClient { key: K, params?: Parameters, ) => Promise; + invalidateQueries: ( + key: K, + params?: Parameters, + ) => Promise; } function createClient>(endpoint: string): ApiClient { @@ -112,6 +116,9 @@ function createClient>(endpoint: string): ApiClient refetchQueries(key, params?) { return queryClient.refetchQueries(params ? [key, params] : [key]); }, + invalidateQueries(key, params?) { + return queryClient.invalidateQueries(params ? [key, params] : [key]); + }, }; } diff --git a/packages/toolpad-app/src/components/DefinitionList.tsx b/packages/toolpad-app/src/components/DefinitionList.tsx new file mode 100644 index 00000000000..ef7115dc887 --- /dev/null +++ b/packages/toolpad-app/src/components/DefinitionList.tsx @@ -0,0 +1,14 @@ +import { styled } from '@mui/material'; + +export default styled('dl')(({ theme }) => ({ + ...theme.typography.body1, + display: 'grid', + gridTemplateColumns: 'fit-content(50%) 1fr', + '& > dt': { + fontWeight: theme.typography.fontWeightBold, + textAlign: 'right', + }, + '& > dd': { + marginLeft: theme.spacing(1), + }, +})); diff --git a/packages/toolpad-app/src/components/Home.tsx b/packages/toolpad-app/src/components/Home.tsx index c6652a25ef4..d4ac21c7018 100644 --- a/packages/toolpad-app/src/components/Home.tsx +++ b/packages/toolpad-app/src/components/Home.tsx @@ -88,7 +88,7 @@ function AppDeleteDialog({ app, onClose }: AppDeleteDialogProps) { if (app) { await deleteAppMutation.mutateAsync([app.id]); } - await client.refetchQueries('getApps'); + await client.invalidateQueries('getApps'); onClose(); }, [app, deleteAppMutation, onClose]); @@ -187,7 +187,7 @@ function AppCard({ app, activeDeployment, onDelete }: AppCardProps) { if (app?.id) { try { await client.mutation.updateApp(app.id, name); - await client.refetchQueries('getApps'); + await client.invalidateQueries('getApps'); } catch (err) { setShowAppRenameErrorDialog(true); } diff --git a/packages/toolpad-app/src/components/Release.tsx b/packages/toolpad-app/src/components/Release.tsx index cc80d7af201..7b459a952f0 100644 --- a/packages/toolpad-app/src/components/Release.tsx +++ b/packages/toolpad-app/src/components/Release.tsx @@ -1,31 +1,134 @@ -import { Button, Container, Toolbar, Typography, Box } from '@mui/material'; -import { DataGridPro, GridActionsCellItem, GridColumns, GridRowParams } from '@mui/x-data-grid-pro'; +import { + Button, + Container, + Toolbar, + Typography, + Paper, + Breadcrumbs, + Link as MuiLink, + Stack, + Dialog, + DialogTitle, + DialogActions, + DialogContent, +} from '@mui/material'; import * as React from 'react'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; -import { useParams } from 'react-router-dom'; -import { NodeId } from '@mui/toolpad-core'; +import { Link, useParams } from 'react-router-dom'; import client from '../api'; -import * as appDom from '../appDom'; import ToolpadAppShell from './ToolpadAppShell'; +import DefinitionList from './DefinitionList'; +import useLatest from '../utils/useLatest'; +import useDialog from '../utils/useDialog'; -interface NavigateToReleaseActionProps { +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 cancel = () => onClose(false); + + const data = useLatest(dataProp); + + if (!data) { + return null; + } + + return ( + + Confirm deploy + + Press "Deploy" to change the canonical url of your application to + version "{data.version}". + + + + + + + ); +} + +interface ActiveReleaseMessageProps { appId: string; - version?: number; - pageNodeId: NodeId; + version: number; + activeVersion?: number; } -function NavigateToReleaseAction({ appId, version, pageNodeId }: NavigateToReleaseActionProps) { +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 ( - } - // @ts-expect-error https://github.com/mui/mui-x/issues/4654 - component="a" - href={`/app/${appId}/${version}/pages/${pageNodeId}`} - target="_blank" - label="Open" - disabled={!version} - /> + + {msg} + + + + + {element} + ); } @@ -39,69 +142,40 @@ export default function Release() { const releaseQuery = client.useQuery('getRelease', [appId, version]); - const { - data: dom, - isLoading, - error, - } = client.useQuery('loadReleaseDom', version ? [appId, version] : null); - const app = dom ? appDom.getApp(dom) : null; - const { pages = [] } = dom && app ? appDom.getChildNodes(dom, app) : {}; - - const deployReleaseMutation = client.useMutation('createDeployment'); const activeDeploymentQuery = client.useQuery('findActiveDeployment', [appId]); - const columns: GridColumns = React.useMemo( - () => [ - { field: 'name' }, - { field: 'title', flex: 1 }, - { - field: 'actions', - type: 'actions', - getActions: (params: GridRowParams) => [ - , - ], - }, - ], - [appId, version], - ); - - const handleDeployClick = React.useCallback(async () => { - if (version) { - await deployReleaseMutation.mutateAsync([appId, version]); - activeDeploymentQuery.refetch(); - } - }, [appId, activeDeploymentQuery, deployReleaseMutation, version]); - - const isActiveDeployment = activeDeploymentQuery.data?.release.version === version; - - const canDeploy = - deployReleaseMutation.isIdle && activeDeploymentQuery.isSuccess && !isActiveDeployment; + const permaLink = String(new URL(`/app/${appId}/${version}`, window.location.href)); return ( - - Release "{version}" - {releaseQuery?.data?.description} - - - - - {isActiveDeployment ? `Release "${version}" is currently deployed` : null} - - - - + + + + + Releases + + Version "{version}" + + + Version "{version}" + + +
Created:
+
{releaseQuery?.data?.createdAt.toLocaleString('short')}
+
Description:
+
{releaseQuery?.data?.description}
+
Permalink:
+
+ {permaLink} +
+
+ +
+
); diff --git a/packages/toolpad-app/src/components/Releases.tsx b/packages/toolpad-app/src/components/Releases.tsx index daf44bab1e9..ea09729dfa3 100644 --- a/packages/toolpad-app/src/components/Releases.tsx +++ b/packages/toolpad-app/src/components/Releases.tsx @@ -1,9 +1,24 @@ -import { Container, Typography, Box } from '@mui/material'; +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 './DefinitionList'; interface ReleaseRow { createdAt: Date; @@ -11,6 +26,82 @@ interface ReleaseRow { 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(); @@ -52,18 +143,31 @@ export default function Releases() { return ( - - Releases - - row.version} - loading={isLoading} - error={(error as any)?.message} - onRowClick={({ row }) => navigate(`/app/${appId}/releases/${row.version}`)} - /> - + + + + 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/utils/useDialog.tsx b/packages/toolpad-app/src/utils/useDialog.tsx new file mode 100644 index 00000000000..65ecc5d03e9 --- /dev/null +++ b/packages/toolpad-app/src/utils/useDialog.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; + +interface DialogProps { + data?: D; + open: boolean; + onClose: (result?: R) => void; +} + +interface UseDialog { + element: React.ReactNode; + show: (props: D) => Promise; +} + +interface DialogState { + data: D; + resolve: (result?: R) => void; +} + +export default function useDialog( + Component: React.ComponentType>, +): UseDialog { + const [state, setState] = React.useState | null>(null); + + const show = React.useCallback(async (data: D) => { + return new Promise((resolve) => { + setState({ + data, + resolve, + }); + }); + }, []); + + const handleClose = React.useCallback( + (result?: R) => { + if (!state) { + return; + } + const { resolve } = state; + resolve(result); + setState(null); + }, + [state], + ); + + return { show, element: }; +}