Skip to content

Commit

Permalink
(FE) Add Document Sharing (#89)
Browse files Browse the repository at this point in the history
* Generate share url

* Add document sharing

* Add permission checking to note page

* Fix lint
  • Loading branch information
devleejb authored Jan 23, 2024
1 parent 692bdfc commit 7b8a387
Show file tree
Hide file tree
Showing 15 changed files with 319 additions and 56 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsDate } from "class-validator";
import { ShareRoleEnum } from "src/utils/constants/share-role";

export class CreateWorkspaceDocumentShareTokenDto {
Expand All @@ -9,6 +8,5 @@ export class CreateWorkspaceDocumentShareTokenDto {

@ApiProperty({ type: Date, description: "Share link expiration date" })
@Type(() => Date)
@IsDate()
expiredAt: Date;
}
23 changes: 23 additions & 0 deletions frontend/src/components/common/ShareButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { IconButton } from "@mui/material";
import ShareIcon from "@mui/icons-material/Share";
import ShareModal from "../modals/ShareModal";
import { useState } from "react";

function ShareButton() {
const [shareModalOpen, setShareModalOpen] = useState(false);

const handleShareModalOpen = () => {
setShareModalOpen((prev) => !prev);
};

return (
<>
<IconButton onClick={handleShareModalOpen} color="inherit">
<ShareIcon />
</IconButton>
<ShareModal open={shareModalOpen} onClose={handleShareModalOpen} />
</>
);
}

export default ShareButton;
9 changes: 7 additions & 2 deletions frontend/src/components/editor/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import MarkdownPreview from "@uiw/react-markdown-preview";
import { useCurrentTheme } from "../../hooks/useCurrentTheme";
import { useSelector } from "react-redux";
import { selectEditor } from "../../store/editorSlice";
import { CircularProgress } from "@mui/material";
import { CircularProgress, Stack } from "@mui/material";
import { useEffect, useState } from "react";
import "./editor.css";

Expand All @@ -26,7 +26,12 @@ function Preview() {
};
}, [editorStore.doc]);

if (!editorStore?.doc) return <CircularProgress sx={{ marginX: "auto", mt: 4 }} />;
if (!editorStore?.doc)
return (
<Stack direction="row" justifyContent="center">
<CircularProgress sx={{ mt: 2 }} />
</Stack>
);

return (
<MarkdownPreview
Expand Down
73 changes: 36 additions & 37 deletions frontend/src/components/headers/EditorHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
AppBar,
IconButton,
Paper,
Stack,
ToggleButton,
Expand All @@ -11,62 +10,62 @@ import {
import EditIcon from "@mui/icons-material/Edit";
import VerticalSplitIcon from "@mui/icons-material/VerticalSplit";
import VisibilityIcon from "@mui/icons-material/Visibility";
import AddIcon from "@mui/icons-material/Add";
import { useDispatch, useSelector } from "react-redux";
import { EditorModeType, selectEditor, setMode } from "../../store/editorSlice";
import ThemeButton from "../common/ThemeButton";
import { createDocumentKey } from "../../utils/document";
import { useNavigate } from "react-router-dom";
import ShareButton from "../common/ShareButton";
import { useEffect } from "react";

function EditorHeader() {
const dispatch = useDispatch();
const editorState = useSelector(selectEditor);
const navigate = useNavigate();

useEffect(() => {
if (editorState.shareRole === "READ") {
dispatch(setMode("read"));
}
}, [dispatch, editorState.shareRole]);

const handleChangeMode = (newMode: EditorModeType) => {
dispatch(setMode(newMode));
};

const handleCreateNewDocument = () => {
navigate(`/${createDocumentKey()}`);
};

return (
<AppBar position="static" sx={{ zIndex: 100 }}>
<Toolbar>
<Stack width="100%" direction="row" justifyContent="space-between">
<Stack direction="row" spacing={1}>
<Paper>
<ToggleButtonGroup
value={editorState.mode}
exclusive
onChange={(_, newMode) => handleChangeMode(newMode)}
size="small"
>
<ToggleButton value="edit" aria-label="edit">
<Tooltip title="Edit Mode">
<EditIcon />
</Tooltip>
</ToggleButton>
<ToggleButton value="both" aria-label="both">
<Tooltip title="Both Mode">
<VerticalSplitIcon />
</Tooltip>
</ToggleButton>
<ToggleButton value="read" aria-label="read">
<Tooltip title="Read Mode">
<VisibilityIcon />
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
{editorState.shareRole !== "READ" && (
<ToggleButtonGroup
value={editorState.mode}
exclusive
onChange={(_, newMode) => handleChangeMode(newMode)}
size="small"
>
<ToggleButton value="edit" aria-label="edit">
<Tooltip title="Edit Mode">
<EditIcon />
</Tooltip>
</ToggleButton>
<ToggleButton value="both" aria-label="both">
<Tooltip title="Both Mode">
<VerticalSplitIcon />
</Tooltip>
</ToggleButton>
<ToggleButton value="read" aria-label="read">
<Tooltip title="Read Mode">
<VisibilityIcon />
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
)}
</Paper>
<Tooltip title="Create New Note">
<IconButton color="inherit" onClick={handleCreateNewDocument}>
<AddIcon />
</IconButton>
</Tooltip>
</Stack>
<ThemeButton />
<Stack direction="row" alignItems="center" gap={1}>
{!editorState.shareRole && <ShareButton />}
<ThemeButton />
</Stack>
</Stack>
</Toolbar>
</AppBar>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/modals/MemberModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { useMemo, useState } from "react";
import { User } from "../../hooks/api/types/user";
import InfiniteScroll from "react-infinite-scroller";
import { FormContainer, SelectElement } from "react-hook-form-mui";
import { invitationExpiredStringList } from "../../utils/invitation";
import { invitationExpiredStringList } from "../../utils/expire";
import moment, { unitOfTime } from "moment";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import clipboard from "clipboardy";
Expand Down
152 changes: 152 additions & 0 deletions frontend/src/components/modals/ShareModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import {
Button,
FormControl,
IconButton,
Modal,
ModalProps,
Paper,
Stack,
Tooltip,
Typography,
} from "@mui/material";
import { FormContainer, SelectElement } from "react-hook-form-mui";
import { invitationExpiredStringList } from "../../utils/expire";
import { useState } from "react";
import moment, { unitOfTime } from "moment";
import { useParams } from "react-router";
import { useGetDocumentQuery } from "../../hooks/api/document";
import { useCreateWorkspaceSharingTokenMutation } from "../../hooks/api/workspaceDocument";
import { ShareRole } from "../../utils/share";
import clipboard from "clipboardy";
import { useSnackbar } from "notistack";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import CloseIcon from "@mui/icons-material/Close";
import { useSearchParams } from "react-router-dom";

interface ShareModalProps extends Omit<ModalProps, "children"> {}

function ShareModal(props: ShareModalProps) {
const { ...modalProps } = props;
const params = useParams();
const [searchParams] = useSearchParams();
const [shareUrl, setShareUrl] = useState<string | null>(null);
const { data: document } = useGetDocumentQuery(
searchParams.get("token") ? null : params.documentSlug
);
const { mutateAsync: createWorkspaceSharingToken } = useCreateWorkspaceSharingTokenMutation(
document?.workspaceId || "",
document?.id || ""
);
const { enqueueSnackbar } = useSnackbar();

const handleCreateShareUrl = async (data: { expiredString: string; role: ShareRole }) => {
let addedTime: Date | null;

if (data.expiredString === invitationExpiredStringList[0]) {
addedTime = null;
} else {
const [num, unit] = data.expiredString.split(" ");
addedTime = moment()
.add(Number(num), unit as unitOfTime.DurationConstructor)
.toDate();
}

const { sharingToken } = await createWorkspaceSharingToken({
role: data.role,
expiredAt: addedTime,
});

setShareUrl(
`${window.location.origin}/document/${params.documentSlug}?token=${sharingToken}`
);
};

const handleCopyShareUrl = async () => {
if (!shareUrl) return;

await clipboard.write(shareUrl);
enqueueSnackbar("URL Copied!", { variant: "success" });
};

return (
<Modal disableAutoFocus {...modalProps}>
<Paper
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
p: 4,
width: 400,
}}
>
<IconButton
sx={{
position: "absolute",
top: 28,
right: 28,
}}
onClick={(e) => props.onClose?.(e, "backdropClick")}
>
<CloseIcon />
</IconButton>
<Stack gap={1}>
<Typography variant="subtitle1">Share Link</Typography>
<FormControl>
<FormContainer
defaultValues={{
expiredString: invitationExpiredStringList[0],
role: Object.values(ShareRole)[0],
}}
onSuccess={handleCreateShareUrl}
>
<Stack gap={2}>
<SelectElement
label="Role"
name="role"
options={Object.values(ShareRole).map((role) => ({
id: role,
label: role,
}))}
size="small"
sx={{
width: 1,
}}
variant="filled"
/>
<SelectElement
label="Expired Date"
name="expiredString"
options={invitationExpiredStringList.map((expiredString) => ({
id: expiredString,
label: expiredString,
}))}
size="small"
sx={{
width: 1,
}}
variant="filled"
/>
<Button type="submit" variant="contained">
Generate
</Button>
</Stack>
</FormContainer>
</FormControl>
{Boolean(shareUrl) && (
<Stack direction="row" alignItems="center" gap={2}>
<Typography variant="body1">{shareUrl}</Typography>
<Tooltip title="Copy URL">
<IconButton onClick={handleCopyShareUrl}>
<ContentCopyIcon />
</IconButton>
</Tooltip>
</Stack>
)}
</Stack>
</Paper>
</Modal>
);
}

export default ShareModal;
30 changes: 27 additions & 3 deletions frontend/src/hooks/api/document.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { GetDocumentResponse } from "./types/document";
import { GetDocumentBySharingTokenResponse, GetDocumentResponse } from "./types/document";

export const generateGetDocumentQueryKey = (documentSlug: string) => {
return ["documents", documentSlug];
};

export const useGetDocumentQuery = (documentSlug: string) => {
export const generateGetDocumentBySharingTokenQueryKey = (sharingToken: string) => {
return ["documents", "share", sharingToken];
};

export const useGetDocumentQuery = (documentSlug?: string | null) => {
const query = useQuery({
queryKey: generateGetDocumentQueryKey(documentSlug || ""),
enabled: Boolean(documentSlug),
Expand All @@ -15,7 +19,27 @@ export const useGetDocumentQuery = (documentSlug: string) => {
return res.data;
},
meta: {
errorMessage: "This is a non-existent or unauthorized Workspace.",
errorMessage: "This is a non-existent or unauthorized document.",
},
});

return query;
};

export const useGetDocumentBySharingTokenQuery = (sharingToken?: string | null) => {
const query = useQuery({
queryKey: generateGetDocumentQueryKey(sharingToken || ""),
enabled: Boolean(sharingToken),
queryFn: async () => {
const res = await axios.get<GetDocumentBySharingTokenResponse>("/documents/share", {
params: {
token: sharingToken,
},
});
return res.data;
},
meta: {
errorMessage: "This is a non-existent or expired document.",
},
});

Expand Down
7 changes: 7 additions & 0 deletions frontend/src/hooks/api/types/document.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { ShareRole } from "../../../utils/share";

export class Document {
id: string;
workspaceId: string;
yorkieDocumentId: string;
title: string;
slug: string;
Expand All @@ -9,3 +12,7 @@ export class Document {
}

export class GetDocumentResponse extends Document {}

export class GetDocumentBySharingTokenResponse extends Document {
role: ShareRole;
}
Loading

0 comments on commit 7b8a387

Please sign in to comment.