Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

App card enhancements #591

Merged
merged 12 commits into from
Jun 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/toolpad-app/src/components/EditableText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as React from 'react';
import { Skeleton, TextField, Typography, TypographyVariant } from '@mui/material';

interface EditableTextProps {
defaultValue?: string;
loading: boolean;
editing: boolean;
isError: boolean;
errorText?: string;
onKeyUp: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onBlur: (event: React.FocusEvent<HTMLInputElement>) => void;
variant?: TypographyVariant;
size?: 'small' | 'medium';
}

const EditableText = React.forwardRef<HTMLInputElement, EditableTextProps>(
({ defaultValue, onKeyUp, onBlur, variant, size, editing, loading, isError, errorText }, ref) => {
return editing ? (
<TextField
variant={'standard'}
size={size ?? 'small'}
inputRef={ref}
sx={{ paddingBottom: '4px' }}
InputProps={{ sx: { fontSize: '1.5rem', height: '1.5em' } }}
onKeyUp={onKeyUp ?? (() => {})}
onBlur={onBlur ?? (() => {})}
defaultValue={defaultValue}
error={isError}
helperText={isError ? errorText : ''}
/>
) : (
<Typography gutterBottom variant={variant ?? 'body1'} component="div">
{loading ? <Skeleton /> : defaultValue}
</Typography>
);
},
);

export default EditableText;
185 changes: 84 additions & 101 deletions packages/toolpad-app/src/components/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Button,
Card,
CardHeader,
CardContent,
CardActions,
Container,
Dialog,
Expand Down Expand Up @@ -31,6 +32,8 @@ import DialogForm from './DialogForm';
import type { App, Deployment } from '../../prisma/generated/client';
import useLatest from '../utils/useLatest';
import ToolpadShell from './ToolpadShell';
import getReadableDuration from '../utils/readableDuration';
import EditableText from './EditableText';

export interface CreateAppDialogProps {
open: boolean;
Expand All @@ -39,39 +42,56 @@ export interface CreateAppDialogProps {

function CreateAppDialog({ onClose, ...props }: CreateAppDialogProps) {
const [name, setName] = React.useState('');
const createAppMutation = client.useMutation('createApp');
const createAppMutation = client.useMutation('createApp', {
onSuccess: (app) => {
window.location.href = `/_toolpad/app/${app.id}/editor`;
},
});

return (
<Dialog {...props} onClose={onClose}>
<DialogForm
onSubmit={async (event) => {
event.preventDefault();

const app = await createAppMutation.mutateAsync([name]);
window.location.href = `/_toolpad/app/${app.id}/editor`;
}}
>
<DialogTitle>Create a new MUI Toolpad App</DialogTitle>
<DialogContent>
<TextField
sx={{ my: 1 }}
autoFocus
fullWidth
label="name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button color="inherit" variant="text" onClick={onClose}>
Cancel
</Button>
<LoadingButton type="submit" loading={createAppMutation.isLoading} disabled={!name}>
Create
</LoadingButton>
</DialogActions>
</DialogForm>
</Dialog>
<React.Fragment>
<Dialog {...props} onClose={onClose}>
<DialogForm
onSubmit={(event) => {
event.preventDefault();
createAppMutation.mutate([name]);
}}
>
<DialogTitle>Create a new MUI Toolpad App</DialogTitle>
<DialogContent>
<TextField
sx={{ my: 1 }}
autoFocus
fullWidth
label="name"
value={name}
error={createAppMutation.isError}
helperText={createAppMutation.isError ? `An app named "${name}" already exists` : ''}
onChange={(event) => {
createAppMutation.reset();
setName(event.target.value);
}}
/>
</DialogContent>
<DialogActions>
<Button
color="inherit"
variant="text"
onClick={() => {
setName('');
createAppMutation.reset();
onClose();
}}
>
Cancel
</Button>
<LoadingButton type="submit" loading={createAppMutation.isLoading} disabled={!name}>
Create
</LoadingButton>
</DialogActions>
</DialogForm>
</Dialog>
</React.Fragment>
);
}

Expand Down Expand Up @@ -114,39 +134,6 @@ function AppDeleteDialog({ app, onClose }: AppDeleteDialogProps) {
);
}

export interface AppRenameErrorDialogProps {
open: boolean;
currentName: string | undefined;
newName: string | undefined;
onContinue: () => void;
onDiscard: () => void;
}

function AppRenameErrorDialog({
open,
currentName,
newName,
onContinue,
onDiscard,
}: AppRenameErrorDialogProps) {
return (
<Dialog open={open} onClose={onDiscard}>
<DialogForm>
<DialogTitle>Can&apos;t rename app &quot;{currentName}&quot; </DialogTitle>
<DialogContent>An app with the name &quot;{newName}&quot; already exists.</DialogContent>
<DialogActions>
<Button onClick={onDiscard} color={'error'}>
Discard
</Button>
<Button color="inherit" variant="text" onClick={onContinue}>
Keep editing
</Button>
</DialogActions>
</DialogForm>
</Dialog>
);
}

interface AppCardProps {
app?: App;
activeDeployment?: Deployment;
Expand All @@ -155,7 +142,7 @@ interface AppCardProps {

function AppCard({ app, activeDeployment, onDelete }: AppCardProps) {
const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null);
const [showAppRenameErrorDialog, setShowAppRenameErrorDialog] = React.useState<boolean>(false);
const [showAppRenameError, setShowAppRenameError] = React.useState<boolean>(false);
const [editingTitle, setEditingTitle] = React.useState<boolean>(false);
const [appTitle, setAppTitle] = React.useState<string | undefined>(app?.name);
const appTitleInput = React.useRef<HTMLInputElement | null>(null);
Expand Down Expand Up @@ -189,7 +176,8 @@ function AppCard({ app, activeDeployment, onDelete }: AppCardProps) {
await client.mutation.updateApp(app.id, name);
await client.invalidateQueries('getApps');
} catch (err) {
setShowAppRenameErrorDialog(true);
setShowAppRenameError(true);
setEditingTitle(true);
}
}
},
Expand All @@ -207,6 +195,7 @@ function AppCard({ app, activeDeployment, onDelete }: AppCardProps) {
const handleAppTitleInput = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
setAppTitle((event.target as HTMLInputElement).value);
setShowAppRenameError(false);
if (event.key === 'Escape') {
if (appTitleInput.current?.value && app?.name) {
setAppTitle(app.name);
Expand Down Expand Up @@ -252,7 +241,15 @@ function AppCard({ app, activeDeployment, onDelete }: AppCardProps) {

return (
<React.Fragment>
<Card sx={{ gridColumn: 'span 1' }} role="article">
<Card
role="article"
sx={{
gridColumn: 'span 1',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
>
<CardHeader
action={
<IconButton
Expand All @@ -266,31 +263,31 @@ function AppCard({ app, activeDeployment, onDelete }: AppCardProps) {
</IconButton>
}
disableTypography
title={
editingTitle ? (
<TextField
variant="standard"
size="small"
inputRef={appTitleInput}
sx={{ paddingBottom: '4px' }}
InputProps={{ sx: { fontSize: '1.5rem', height: '1.5em' } }}
onKeyUp={handleAppTitleInput}
onBlur={handleAppTitleBlur}
defaultValue={appTitle}
/>
) : (
<Typography gutterBottom variant="h5" component="div">
{app ? appTitle : <Skeleton />}
</Typography>
)
}
subheader={
<Typography variant="body2" color="text.secondary">
{app ? `Edited: ${app.editedAt.toLocaleString('short')}` : <Skeleton />}
{app ? (
<Tooltip title={app.editedAt.toLocaleString('short')}>
<span>Edited {getReadableDuration(app.editedAt)}</span>
</Tooltip>
) : (
<Skeleton />
)}
</Typography>
}
/>

<CardContent sx={{ flexGrow: 1 }}>
<EditableText
onBlur={handleAppTitleBlur}
onKeyUp={handleAppTitleInput}
editing={editingTitle}
isError={showAppRenameError}
errorText={`An app named "${appTitle}" already exists`}
loading={Boolean(!app)}
defaultValue={appTitle}
variant={'h5'}
ref={appTitleInput}
/>
</CardContent>
<CardActions>
<Button
size="small"
Expand Down Expand Up @@ -326,20 +323,6 @@ function AppCard({ app, activeDeployment, onDelete }: AppCardProps) {
<ListItemText>Delete</ListItemText>
</MenuItem>
</Menu>
<AppRenameErrorDialog
open={showAppRenameErrorDialog}
currentName={app?.name}
newName={appTitle}
onDiscard={() => {
setEditingTitle(false);
setAppTitle(app?.name);
setShowAppRenameErrorDialog(false);
}}
onContinue={() => {
setEditingTitle(true);
setShowAppRenameErrorDialog(false);
}}
/>
</React.Fragment>
);
}
Expand Down
29 changes: 29 additions & 0 deletions packages/toolpad-app/src/utils/readableDuration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const getReadableDuration = (editedAt: Date) => {
const duration = new Date().getTime() - editedAt.getTime();
const delta = Math.floor(duration / 1000);

const minute = 60;
const hour = minute * 60;
const day = hour * 24;

let readableDuration = '';

if (delta < 30) {
readableDuration = 'just now';
} else if (delta < minute) {
readableDuration = `${delta} seconds ago`;
} else if (delta < 2 * minute) {
readableDuration = 'a minute ago';
} else if (delta < hour) {
readableDuration = `${Math.floor(delta / minute)} minutes ago`;
} else if (Math.floor(delta / hour) === 1) {
readableDuration = '1 hour ago';
} else if (delta < day) {
readableDuration = `${Math.floor(delta / hour)} hours ago`;
} else {
readableDuration = editedAt.toLocaleDateString('short');
}
return readableDuration;
};

export default getReadableDuration;