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:
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
7
app/components/Sidebar/components/SharedContext.ts
Normal file
7
app/components/Sidebar/components/SharedContext.ts
Normal 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;
|
||||
89
app/components/Sidebar/components/SharedWithMe.tsx
Normal file
89
app/components/Sidebar/components/SharedWithMe.tsx
Normal 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);
|
||||
158
app/components/Sidebar/components/SharedWithMeLink.tsx
Normal file
158
app/components/Sidebar/components/SharedWithMeLink.tsx
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user