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

feat: file actions on dashboard list #680

Merged
merged 25 commits into from
Sep 15, 2023
Merged
Show file tree
Hide file tree
Changes from 24 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
4 changes: 0 additions & 4 deletions quadratic-api/src/routes/files/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,6 @@ files_router.post(
},
data: {
name: req.body.name,
updated_date: new Date(),
times_updated: {
increment: 1,
},
},
});
}
Expand Down
99 changes: 99 additions & 0 deletions src/components/QDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Close } from '@mui/icons-material';
import { Box, Dialog, DialogActions, DialogContent, IconButton, Paper, Typography, useTheme } from '@mui/material';
import * as React from 'react';

/**
* This is a component for use everywhere in the app where you need a dialog
* with content. It helps give us a consistent UI/X for dialogs, rather than
* each place using it's own implementation of MUI's <Dialog>.
*
* Usage:
*
* <QDialog {...props}>
* <QDialog.Title>Title here</Dialog.Title>
* <QDialog.Content>
* <div>Your content here</div>
* </Dialog.Content>
* <QDialog.Actions>
* <Button>Save</Button>
* <Button>Cancel</Button>
* </Dialog.Actions>
* </QDialog>
*/

interface DialogProps {
children: React.ReactNode;
onClose: () => void;
}

interface DialogTitleProps {
children: React.ReactNode;
}

interface DialogContentProps {
children: React.ReactNode;
}

interface DialogActionsProps {
children: React.ReactNode;
}

const QDialog: React.FC<DialogProps> & {
Title: React.FC<DialogTitleProps>;
Content: React.FC<DialogContentProps>;
Actions: React.FC<DialogActionsProps>;
} = ({ children, onClose }) => {
const theme = useTheme();

return (
<Dialog open={true} onClose={onClose} fullWidth maxWidth={'sm'}>
<Paper elevation={12}>
{children}
<IconButton onClick={onClose} sx={{ position: 'absolute', top: theme.spacing(1), right: theme.spacing(3) }}>
<Close fontSize="small" />
</IconButton>
</Paper>
</Dialog>
);
};

const Title: React.FC<DialogTitleProps> = ({ children }) => {
const theme = useTheme();

return (
<Box sx={{ px: theme.spacing(3), py: theme.spacing(1.5) }}>
<Typography
variant="subtitle1"
sx={{
fontWeight: '600',
display: 'block',
textOverflow: 'ellipsis',
textWrap: 'nowrap',
overflow: 'hidden',
marginRight: theme.spacing(6),
}}
>
{children}
</Typography>
</Box>
);
};

const Content: React.FC<DialogContentProps> = ({ children }) => {
return <DialogContent dividers>{children}</DialogContent>;
};

const Actions: React.FC<DialogActionsProps> = ({ children }) => {
const theme = useTheme();
return (
<DialogActions sx={{ alignItems: 'center', px: theme.spacing(3), py: theme.spacing(1.5) }}>
{children}
</DialogActions>
);
};

QDialog.Title = Title;
QDialog.Content = Content;
QDialog.Actions = Actions;

export { QDialog };
269 changes: 269 additions & 0 deletions src/components/ShareFileMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import { Public } from '@mui/icons-material';
import { Alert, Avatar, Button, Skeleton, SkeletonProps, Stack, Typography, useTheme } from '@mui/material';
import * as Sentry from '@sentry/react';
import { useEffect } from 'react';
import { ActionFunctionArgs, LoaderFunctionArgs, useFetcher } from 'react-router-dom';
import { isOwner as isOwnerTest } from '../actions';
import { apiClient } from '../api/apiClient';
import { ApiTypes, Permission, PublicLinkAccess } from '../api/types';
import { ROUTES } from '../constants/routes';
import ConditionalWrapper from '../ui/components/ConditionalWrapper';
import { useGlobalSnackbar } from './GlobalSnackbarProvider';
import { QDialog } from './QDialog';
import { ShareFileMenuPopover } from './ShareFileMenuPopover';

type LoaderData = {
ok: boolean;
data?: ApiTypes['/v0/files/:uuid/sharing.GET.response'];
};
export const loader = async ({ params }: LoaderFunctionArgs): Promise<LoaderData> => {
const { uuid } = params as { uuid: string };

try {
const data = await apiClient.getFileSharing(uuid);
return { ok: true, data };
} catch (e) {
return { ok: false };
}
};

type Action = {
response: { ok: boolean } | null;
'request.update-public-link-access': {
action: 'update-public-link-access';
uuid: string;
public_link_access: PublicLinkAccess;
};
// In the future, we'll have other updates here like updating an individual
// user's permissions for the file
request: Action['request.update-public-link-access'];
};
export const action = async ({ request, params }: ActionFunctionArgs): Promise<Action['response']> => {
const json: Action['request'] = await request.json();
const { action, uuid } = json;

if (action === 'update-public-link-access') {
const { public_link_access } = json as Action['request.update-public-link-access'];
try {
await apiClient.updateFileSharing(uuid, { public_link_access });
return { ok: true };
} catch (e) {
return { ok: false };
}
}

return null;
};

export function ShareFileMenu({
onClose,
uuid,
permission,
fileName,
}: {
onClose: () => void;
permission: Permission;
uuid: string;
fileName?: string;
}) {
const theme = useTheme();
const { addGlobalSnackbar } = useGlobalSnackbar();
const fetcher = useFetcher<LoaderData>();

// On the initial mount, load the data
useEffect(() => {
if (fetcher.state === 'idle' && !fetcher.data) {
fetcher.load(`/api/files/${uuid}/sharing`);
}
}, [fetcher, uuid]);

const showSkeletons = Boolean(!fetcher.data?.ok);
const animation = fetcher.state !== 'idle' ? 'pulse' : false;
const owner = fetcher.data?.data?.owner;
const publicLinkAccess = fetcher.data?.data?.public_link_access;
const isShared = publicLinkAccess && publicLinkAccess !== 'NOT_SHARED';
const isOwner = isOwnerTest(permission);
const isDisabledCopyShareLink = showSkeletons ? true : !isShared;
const showLoadingError = fetcher.state === 'idle' && fetcher.data && !fetcher.data.ok;

const handleCopyShareLink = () => {
const shareLink = window.location.origin + ROUTES.FILE(uuid);
navigator.clipboard
.writeText(shareLink)
.then(() => {
addGlobalSnackbar('Link copied to clipboard.');
})
.catch((e) => {
Sentry.captureEvent({
message: 'Failed to copy share link to user’s clipboard.',
level: Sentry.Severity.Info,
});
addGlobalSnackbar('Failed to copy link.', { severity: 'error' });
});
};

return (
<QDialog onClose={onClose}>
<QDialog.Title>Share{fileName && `: “${fileName}”`}</QDialog.Title>
<QDialog.Content>
<Stack gap={theme.spacing(1)} direction="column">
{showLoadingError && (
<Alert
severity="error"
action={
<Button
color="inherit"
size="small"
onClick={() => {
fetcher.load(`/api/files/${uuid}/sharing`);
}}
>
Reload
</Button>
}
sx={{
// Align the alert so it's icon/button match each row item
px: theme.spacing(3),
mx: theme.spacing(-3),
}}
>
Failed to retrieve sharing info. Try reloading.
</Alert>
)}

<Row>
<PublicLink
showSkeletons={showSkeletons}
animation={animation}
publicLinkAccess={publicLinkAccess}
isOwner={isOwner}
uuid={uuid}
/>
</Row>
<Row>
<ConditionalWrapper condition={showSkeletons} Wrapper={SkeletonWrapper({ animation, variant: 'circular' })}>
<Avatar alt={owner?.name} src={owner?.picture} sx={{ width: 24, height: 24 }} />
</ConditionalWrapper>
<ConditionalWrapper condition={showSkeletons} Wrapper={SkeletonWrapper({ animation, width: 160 })}>
<Typography variant="body2">
{owner?.name}
{isOwner && ' (You)'}
</Typography>
</ConditionalWrapper>
<ConditionalWrapper condition={showSkeletons} Wrapper={SkeletonWrapper({ animation })}>
<ShareFileMenuPopover
value={'1'}
disabled
options={[{ label: 'Owner', value: '1' }]}
setValue={() => {}}
/>
</ConditionalWrapper>
</Row>
</Stack>
</QDialog.Content>

<QDialog.Actions>
<Typography variant="caption" color="text.secondary" sx={{ mr: 'auto' }}>
View access also allows sharing & duplicating.
</Typography>
<Button
variant="outlined"
size="small"
onClick={handleCopyShareLink}
sx={{ mt: theme.spacing(0), flexShrink: '0' }}
disabled={isDisabledCopyShareLink}
>
Copy share link
</Button>
</QDialog.Actions>
</QDialog>
);
}

const shareOptions: Array<{
label: string;
value: PublicLinkAccess;
disabled?: boolean;
}> = [
{ label: 'Cannot view', value: 'NOT_SHARED' },
{ label: 'Can view', value: 'READONLY' },
{ label: 'Can edit (coming soon)', value: 'EDIT', disabled: true },
];

function PublicLink({
showSkeletons,
animation,
publicLinkAccess,
isOwner,
uuid,
}: {
showSkeletons: boolean;
animation: SkeletonProps['animation'];
publicLinkAccess: PublicLinkAccess | undefined;
isOwner: boolean;
uuid: string;
}) {
const fetcher = useFetcher();

// If we don’t have the value, assume 'not shared' by default because we need
// _some_ value for the popover
let public_link_access = publicLinkAccess ? publicLinkAccess : 'NOT_SHARED';
// If we're updating, optimistically show the next value
if (fetcher.json) {
public_link_access = (fetcher.json as Action['request.update-public-link-access']).public_link_access;
}

const setPublicLinkAccess = async (newValue: PublicLinkAccess) => {
const data: Action['request.update-public-link-access'] = {
action: 'update-public-link-access',
uuid: uuid,
public_link_access: newValue,
};
fetcher.submit(data, {
method: 'POST',
action: `/api/files/${uuid}/sharing`,
encType: 'application/json',
});
};

return (
<>
<Public />
<Stack>
<Typography variant="body2">Anyone with the link</Typography>
{fetcher.state === 'idle' && fetcher.data && !fetcher.data.ok && !showSkeletons && (
<Typography variant="caption" color="error">
Failed to update
</Typography>
)}
</Stack>

<ConditionalWrapper condition={showSkeletons} Wrapper={SkeletonWrapper({ animation })}>
<ShareFileMenuPopover
value={public_link_access}
disabled={!isOwner}
options={shareOptions}
setValue={setPublicLinkAccess}
/>
</ConditionalWrapper>
</>
);
}

function SkeletonWrapper({ ...skeltonProps }: SkeletonProps) {
return ({ children }: { children: React.ReactNode }) => <Skeleton {...skeltonProps}>{children}</Skeleton>;
}

function Row({ children, sx }: { children: React.ReactNode; sx?: any }) {
const theme = useTheme();
return (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(1.5)}
sx={{ '> :last-child': { marginLeft: 'auto' }, ...sx }}
>
{children}
</Stack>
);
}
Loading
Loading