Individual document sharing with permissions (#5814)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Apoorv Mishra
2024-01-31 07:18:22 +05:30
committed by GitHub
parent 717c9b5d64
commit 1490c3a14b
91 changed files with 4004 additions and 1166 deletions

View File

@@ -24,6 +24,7 @@ import Collections from "./components/Collections";
import DragPlaceholder from "./components/DragPlaceholder";
import HistoryNavigation from "./components/HistoryNavigation";
import Section from "./components/Section";
import SharedWithMe from "./components/SharedWithMe";
import SidebarAction from "./components/SidebarAction";
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
import SidebarLink from "./components/SidebarLink";
@@ -122,6 +123,9 @@ function AppSidebar() {
/>
)}
</Section>
<Section>
<SharedWithMe />
</Section>
<Section>
<Starred />
</Section>

View File

@@ -26,6 +26,7 @@ import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Folder from "./Folder";
import Relative from "./Relative";
import { useSharedContext } from "./SharedContext";
import SidebarLink, { DragObject } from "./SidebarLink";
import { useStarredContext } from "./StarredContext";
@@ -64,12 +65,19 @@ function InnerDocumentLink(
const [isEditing, setIsEditing] = React.useState(false);
const editableTitleRef = React.useRef<RefHandle>(null);
const inStarredSection = useStarredContext();
const inSharedSection = useSharedContext();
React.useEffect(() => {
if (isActiveDocument && hasChildDocuments) {
if (isActiveDocument && (hasChildDocuments || inSharedSection)) {
void fetchChildDocuments(node.id);
}
}, [fetchChildDocuments, node.id, hasChildDocuments, isActiveDocument]);
}, [
fetchChildDocuments,
node.id,
hasChildDocuments,
inSharedSection,
isActiveDocument,
]);
const pathToNode = React.useMemo(
() => collection?.pathToDocument(node.id).map((entry) => entry.id),

View File

@@ -0,0 +1,7 @@
import * as React from "react";
const SharedContext = React.createContext<boolean | undefined>(undefined);
export const useSharedContext = () => React.useContext(SharedContext);
export default SharedContext;

View File

@@ -0,0 +1,89 @@
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Pagination } from "@shared/constants";
import UserMembership from "~/models/UserMembership";
import DelayedMount from "~/components/DelayedMount";
import Flex from "~/components/Flex";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
import useStores from "~/hooks/useStores";
import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SharedContext from "./SharedContext";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarLink from "./SidebarLink";
import { useDropToReorderUserMembership } from "./useDragAndDrop";
function SharedWithMe() {
const { userMemberships } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
const { loading, next, end, error, page } =
usePaginatedRequest<UserMembership>(userMemberships.fetchPage, {
limit: Pagination.sidebarLimit,
});
// Drop to reorder document
const [reorderMonitor, dropToReorderRef] = useDropToReorderUserMembership(
() => fractionalIndex(null, user.memberships[0].index)
);
React.useEffect(() => {
if (error) {
toast.error(t("Could not load shared documents"));
}
}, [error, t]);
if (!user.memberships.length) {
return null;
}
return (
<SharedContext.Provider value={true}>
<Flex column>
<Header id="shared" title={t("Shared with me")}>
<Relative>
{reorderMonitor.isDragging && (
<DropCursor
isActiveDrop={reorderMonitor.isOverCursor}
innerRef={dropToReorderRef}
position="top"
/>
)}
{user.memberships
.slice(0, page * Pagination.sidebarLimit)
.map((membership) => (
<SharedWithMeLink
key={membership.id}
userMembership={membership}
/>
))}
{!end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={loading}
depth={0}
/>
)}
{loading && (
<Flex column>
<DelayedMount>
<PlaceholderCollections />
</DelayedMount>
</Flex>
)}
</Relative>
</Header>
</Flex>
</SharedContext.Provider>
);
}
export default observer(SharedWithMe);

View File

@@ -0,0 +1,158 @@
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import UserMembership from "~/models/UserMembership";
import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
import SidebarLink from "./SidebarLink";
import {
useDragUserMembership,
useDropToReorderUserMembership,
} from "./useDragAndDrop";
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
type Props = {
userMembership: UserMembership;
};
function SharedWithMeLink({ userMembership }: Props) {
const { ui, collections, documents } = useStores();
const { fetchChildDocuments } = documents;
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId } = userMembership;
const isActiveDocument = documentId === ui.activeDocumentId;
const [expanded, setExpanded] = React.useState(
userMembership.documentId === ui.activeDocumentId
);
React.useEffect(() => {
if (userMembership.documentId === ui.activeDocumentId) {
setExpanded(true);
}
}, [userMembership.documentId, ui.activeDocumentId]);
React.useEffect(() => {
if (documentId) {
void documents.fetch(documentId);
}
}, [documentId, documents]);
React.useEffect(() => {
if (isActiveDocument && userMembership.documentId) {
void fetchChildDocuments(userMembership.documentId);
}
}, [fetchChildDocuments, isActiveDocument, userMembership.documentId]);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);
},
[]
);
const { icon } = useSidebarLabelAndIcon(userMembership);
const [{ isDragging }, draggableRef] = useDragUserMembership(userMembership);
const getIndex = () => {
const next = userMembership?.next();
return fractionalIndex(userMembership?.index || null, next?.index || null);
};
const [reorderMonitor, dropToReorderRef] =
useDropToReorderUserMembership(getIndex);
const displayChildDocuments = expanded && !isDragging;
if (documentId) {
const document = documents.get(documentId);
if (!document) {
return null;
}
const { emoji } = document;
const label = emoji
? document.title.replace(emoji, "")
: document.titleWithDefault;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const node = document.asNavigationNode;
const childDocuments = node.children;
const hasChildDocuments = childDocuments.length > 0;
return (
<>
<Draggable
key={userMembership.id}
ref={draggableRef}
$isDragging={isDragging}
>
<SidebarLink
depth={0}
to={{
pathname: document.url,
state: { starred: true },
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
icon={icon}
label={label}
exact={false}
showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</Draggable>
<Relative>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={documents.active}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
{reorderMonitor.isDragging && (
<DropCursor
isActiveDrop={reorderMonitor.isOverCursor}
innerRef={dropToReorderRef}
/>
)}
</Relative>
</>
);
}
return null;
}
const Draggable = styled.div<{ $isDragging?: boolean }>`
position: relative;
transition: opacity 250ms ease;
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
`;
export default observer(SharedWithMeLink);

View File

@@ -1,10 +1,11 @@
import fractionalIndex from "fractional-index";
import { Location } from "history";
import { observer } from "mobx-react";
import { StarredIcon } from "outline-icons";
import * as React from "react";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import styled from "styled-components";
import styled, { useTheme } from "styled-components";
import Star from "~/models/Star";
import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
@@ -22,7 +23,7 @@ import {
useDropToCreateStar,
useDropToReorderStar,
} from "./useDragAndDrop";
import { useStarLabelAndIcon } from "./useStarLabelAndIcon";
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
type Props = {
star: Star;
@@ -36,6 +37,7 @@ function useLocationStateStarred() {
}
function StarredLink({ star }: Props) {
const theme = useTheme();
const { ui, collections, documents } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId, collectionId } = star;
@@ -70,7 +72,10 @@ function StarredLink({ star }: Props) {
const next = star?.next();
return fractionalIndex(star?.index || null, next?.index || null);
};
const { label, icon } = useStarLabelAndIcon(star);
const { label, icon } = useSidebarLabelAndIcon(
star,
<StarredIcon color={theme.yellow} />
);
const [{ isDragging }, draggableRef] = useDragStar(star);
const [reorderStarMonitor, dropToReorderRef] = useDropToReorderStar(getIndex);
const [createStarMonitor, dropToStarRef] = useDropToCreateStar(getIndex);

View File

@@ -1,11 +1,15 @@
import fractionalIndex from "fractional-index";
import { StarredIcon } from "outline-icons";
import * as React from "react";
import { ConnectDragSource, useDrag, useDrop } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend";
import { useTheme } from "styled-components";
import Star from "~/models/Star";
import UserMembership from "~/models/UserMembership";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { DragObject } from "./SidebarLink";
import { useStarLabelAndIcon } from "./useStarLabelAndIcon";
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
/**
* Hook for shared logic that allows dragging a Starred item
@@ -16,7 +20,11 @@ export function useDragStar(
star: Star
): [{ isDragging: boolean }, ConnectDragSource] {
const id = star.id;
const { label: title, icon } = useStarLabelAndIcon(star);
const theme = useTheme();
const { label: title, icon } = useSidebarLabelAndIcon(
star,
<StarredIcon color={theme.yellow} />
);
const [{ isDragging }, draggableRef, preview] = useDrag({
type: "star",
item: () => ({ id, title, icon }),
@@ -81,3 +89,53 @@ export function useDropToReorderStar(getIndex?: () => string) {
}),
});
}
export function useDragUserMembership(
userMembership: UserMembership
): [{ isDragging: boolean }, ConnectDragSource] {
const id = userMembership.id;
const { label: title, icon } = useSidebarLabelAndIcon(userMembership);
const [{ isDragging }, draggableRef, preview] = useDrag({
type: "userMembership",
item: () => ({
id,
title,
icon,
}),
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
canDrag: () => true,
});
React.useEffect(() => {
preview(getEmptyImage(), { captureDraggingState: true });
}, [preview]);
return [{ isDragging }, draggableRef];
}
/**
* Hook for shared logic that allows dropping user memberships to reorder
*
* @param getIndex A function to get the index of the current item where the membership should be inserted.
*/
export function useDropToReorderUserMembership(getIndex?: () => string) {
const { userMemberships } = useStores();
const user = useCurrentUser();
return useDrop({
accept: "userMembership",
drop: async (item: DragObject) => {
const userMembership = userMemberships.get(item.id);
void userMembership?.save({
index: getIndex?.() ?? fractionalIndex(null, user.memberships[0].index),
});
},
collect: (monitor) => ({
isOverCursor: !!monitor.isOver(),
isDragging: monitor.getItemType() === "userMembership",
}),
});
}

View File

@@ -1,25 +1,27 @@
import { StarredIcon } from "outline-icons";
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import { useTheme } from "styled-components";
import Star from "~/models/Star";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useStores from "~/hooks/useStores";
export function useStarLabelAndIcon({ documentId, collectionId }: Star) {
interface SidebarItem {
documentId?: string;
collectionId?: string;
}
export function useSidebarLabelAndIcon(
{ documentId, collectionId }: SidebarItem,
defaultIcon?: React.ReactNode
) {
const { collections, documents } = useStores();
const theme = useTheme();
const icon = defaultIcon ?? <DocumentIcon />;
if (documentId) {
const document = documents.get(documentId);
if (document) {
return {
label: document.titleWithDefault,
icon: document.emoji ? (
<EmojiIcon emoji={document.emoji} />
) : (
<StarredIcon color={theme.yellow} />
),
icon: document.emoji ? <EmojiIcon emoji={document.emoji} /> : icon,
};
}
}
@@ -36,6 +38,6 @@ export function useStarLabelAndIcon({ documentId, collectionId }: Star) {
return {
label: "",
icon: <StarredIcon color={theme.yellow} />,
icon,
};
}