mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
feat: Add context menus to sidebar items (#10181)
* Add context menu to sidebar document link * tsc * tsc * Add context menu for sidebar collections * fix * Starred document context menu
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import {
|
||||
AlphabeticalReverseSortIcon,
|
||||
AlphabeticalSortIcon,
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
ManualSortIcon,
|
||||
NewDocumentIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
@@ -26,6 +30,7 @@ import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createActionV2WithChildren,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
@@ -36,6 +41,8 @@ import {
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import ExportDialog from "~/components/ExportDialog";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import history from "~/utils/history";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
@@ -140,6 +147,129 @@ export const editCollectionPermissions = createActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createActionV2({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ImportIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return !!stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypes.join(", ");
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
const document = await documents.import(
|
||||
file,
|
||||
null,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
},
|
||||
});
|
||||
|
||||
export const sortCollection = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Sort in sidebar"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
!!stores.policies.abilities(activeCollectionId).update,
|
||||
icon: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
const sortAlphabetical = collection?.sort.field === "title";
|
||||
const sortDir = collection?.sort.direction;
|
||||
|
||||
return sortAlphabetical ? (
|
||||
sortDir === "asc" ? (
|
||||
<AlphabeticalSortIcon />
|
||||
) : (
|
||||
<AlphabeticalReverseSortIcon />
|
||||
)
|
||||
) : (
|
||||
<ManualSortIcon />
|
||||
);
|
||||
},
|
||||
children: [
|
||||
createActionV2({
|
||||
name: ({ t }) => t("A-Z sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "asc"
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Z-A sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return (
|
||||
collection?.sort.field === "title" &&
|
||||
collection?.sort.direction === "desc"
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "title",
|
||||
direction: "desc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Manual sort"),
|
||||
section: ActiveCollectionSection,
|
||||
selected: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.sort.field !== "title";
|
||||
},
|
||||
perform: ({ activeCollectionId, stores }) => {
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
return collection?.save({
|
||||
sort: {
|
||||
field: "index",
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const searchInCollection = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Search in collection"),
|
||||
analyticsName: "Search collection",
|
||||
|
||||
@@ -861,7 +861,7 @@ export const importDocument = createActionV2({
|
||||
}
|
||||
|
||||
if (activeCollectionId) {
|
||||
return !!stores.policies.abilities(activeCollectionId).update;
|
||||
return !!stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -874,7 +874,6 @@ export const importDocument = createActionV2({
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
|
||||
@@ -94,7 +94,7 @@ function DocumentListItem(
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ document });
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
|
||||
@@ -11,7 +11,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action: ActionV2WithChildren;
|
||||
action?: ActionV2WithChildren;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** ARIA label for the menu */
|
||||
@@ -35,10 +35,10 @@ export const ContextMenu = observer(
|
||||
return [];
|
||||
}
|
||||
|
||||
return (action.children as ActionV2Variant[]).map((childAction) =>
|
||||
actionV2ToMenuItem(childAction, actionContext)
|
||||
return ((action?.children as ActionV2Variant[]) ?? []).map(
|
||||
(childAction) => actionV2ToMenuItem(childAction, actionContext)
|
||||
);
|
||||
}, [open, action.children, actionContext]);
|
||||
}, [open, action?.children, actionContext]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
@@ -68,7 +68,7 @@ export const ContextMenu = observer(
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
if (isMobile || !action || menuItems.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import DropToImport from "./DropToImport";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -109,8 +111,12 @@ const CollectionLink: React.FC<Props> = ({
|
||||
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
|
||||
);
|
||||
|
||||
const contextMenuAction = useCollectionMenuAction({
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
|
||||
<Relative ref={mergeRefs([parentRef, dropRef])}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
@@ -122,6 +128,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
expanded={expanded}
|
||||
onDisclosureClick={onDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
contextAction={contextMenuAction}
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
@@ -189,7 +196,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
@@ -316,8 +318,14 @@ function InnerDocumentLink(
|
||||
]
|
||||
);
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: node.id,
|
||||
}}
|
||||
>
|
||||
<Relative ref={parentRef}>
|
||||
<Draggable
|
||||
key={node.id}
|
||||
@@ -334,6 +342,7 @@ function InnerDocumentLink(
|
||||
expanded={hasChildren ? isExpanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
contextAction={contextMenuAction}
|
||||
to={toPath}
|
||||
icon={iconElement}
|
||||
label={
|
||||
@@ -425,7 +434,7 @@ function InnerDocumentLink(
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ import useClickIntent from "~/hooks/useClickIntent";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
import Disclosure from "./Disclosure";
|
||||
import NavLink, { Props as NavLinkProps } from "./NavLink";
|
||||
import { ActionV2WithChildren } from "~/types";
|
||||
import { ContextMenu } from "~/components/Menu/ContextMenu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
|
||||
type Props = Omit<NavLinkProps, "to"> & {
|
||||
to?: LocationDescriptor;
|
||||
@@ -32,6 +36,7 @@ type Props = Omit<NavLinkProps, "to"> & {
|
||||
isDraft?: boolean;
|
||||
depth?: number;
|
||||
scrollIntoViewIfNeeded?: boolean;
|
||||
contextAction?: ActionV2WithChildren;
|
||||
};
|
||||
|
||||
const activeDropStyle = {
|
||||
@@ -62,10 +67,12 @@ function SidebarLink(
|
||||
onDisclosureClick,
|
||||
disabled,
|
||||
unreadBadge,
|
||||
contextAction,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { handleMouseEnter, handleMouseLeave } = useClickIntent(onClickIntent);
|
||||
const style = React.useMemo(
|
||||
@@ -84,41 +91,58 @@ function SidebarLink(
|
||||
[theme.text, theme.sidebarActiveBackground, style]
|
||||
);
|
||||
|
||||
const hoverStyle = React.useMemo(
|
||||
() => ({
|
||||
color: theme.text,
|
||||
...style,
|
||||
}),
|
||||
[theme.text, style]
|
||||
);
|
||||
|
||||
const [openContextMenu, setOpen, setClosed] = useBoolean(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
$isActiveDrop={isActiveDrop}
|
||||
$isDraft={isDraft}
|
||||
$disabled={disabled}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
// @ts-expect-error exact does not exist on div
|
||||
exact={exact !== false}
|
||||
to={to}
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
className={className}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
<ContextMenu
|
||||
action={contextAction}
|
||||
ariaLabel={t("Link options")}
|
||||
onOpen={setOpen}
|
||||
onClose={setClosed}
|
||||
>
|
||||
<Content>
|
||||
{expanded !== undefined && (
|
||||
<Disclosure
|
||||
expanded={expanded}
|
||||
onMouseDown={onDisclosureClick}
|
||||
onClick={preventDefault}
|
||||
root={depth === 0}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
{unreadBadge && <UnreadBadge />}
|
||||
</Content>
|
||||
</Link>
|
||||
<Link
|
||||
$isActiveDrop={isActiveDrop}
|
||||
$isDraft={isDraft}
|
||||
$disabled={disabled}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
style={openContextMenu ? hoverStyle : active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
// @ts-expect-error exact does not exist on div
|
||||
exact={exact !== false}
|
||||
to={to}
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
className={className}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
>
|
||||
<Content>
|
||||
{expanded !== undefined && (
|
||||
<Disclosure
|
||||
expanded={expanded}
|
||||
onMouseDown={onDisclosureClick}
|
||||
onClick={preventDefault}
|
||||
root={depth === 0}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
{unreadBadge && <UnreadBadge />}
|
||||
</Content>
|
||||
</Link>
|
||||
</ContextMenu>
|
||||
{menu && <Actions showActions={showActions}>{menu}</Actions>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -28,11 +28,173 @@ import SidebarContext, {
|
||||
starredSidebarContext,
|
||||
} from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import { type ConnectDragSource } from "react-dnd";
|
||||
|
||||
type Props = {
|
||||
star: Star;
|
||||
};
|
||||
|
||||
type StarredDocumentLinkProps = {
|
||||
star: Star;
|
||||
documentId: string;
|
||||
expanded: boolean;
|
||||
sidebarContext: SidebarContextType;
|
||||
isDragging: boolean;
|
||||
handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
handlePrefetch: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
menuOpen: boolean;
|
||||
handleMenuOpen: () => void;
|
||||
handleMenuClose: () => void;
|
||||
draggableRef: ConnectDragSource;
|
||||
cursor: React.ReactNode;
|
||||
};
|
||||
|
||||
type StarredCollectionLinkProps = {
|
||||
star: Star;
|
||||
collection: any;
|
||||
expanded: boolean;
|
||||
sidebarContext: SidebarContextType;
|
||||
isDragging: boolean;
|
||||
handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
draggableRef: ConnectDragSource;
|
||||
cursor: React.ReactNode;
|
||||
displayChildDocuments: boolean;
|
||||
reorderStarProps: any;
|
||||
};
|
||||
|
||||
function StarredDocumentLink({
|
||||
star,
|
||||
documentId,
|
||||
expanded,
|
||||
sidebarContext,
|
||||
isDragging,
|
||||
handleDisclosureClick,
|
||||
handlePrefetch,
|
||||
icon,
|
||||
label,
|
||||
menuOpen,
|
||||
handleMenuOpen,
|
||||
handleMenuClose,
|
||||
draggableRef,
|
||||
cursor,
|
||||
}: StarredDocumentLinkProps) {
|
||||
const { collections, documents } = useStores();
|
||||
|
||||
const document = documents.get(documentId);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const documentCollection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const childDocuments = documentCollection
|
||||
? documentCollection.getChildrenForDocument(documentId)
|
||||
: [];
|
||||
const hasChildDocuments = childDocuments.length > 0;
|
||||
const displayChildDocuments = expanded && !isDragging;
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document.id,
|
||||
}}
|
||||
>
|
||||
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
<SidebarLink
|
||||
depth={0}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
contextAction={contextMenuAction}
|
||||
icon={icon}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={label}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
menu={
|
||||
document && !isDragging ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Draggable>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={documentCollection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function StarredCollectionLink({
|
||||
star,
|
||||
collection,
|
||||
sidebarContext,
|
||||
isDragging,
|
||||
handleDisclosureClick,
|
||||
draggableRef,
|
||||
cursor,
|
||||
displayChildDocuments,
|
||||
reorderStarProps,
|
||||
}: StarredCollectionLinkProps) {
|
||||
const { documents } = useStores();
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
<CollectionLink
|
||||
collection={collection}
|
||||
expanded={isDragging ? undefined : displayChildDocuments}
|
||||
activeDocument={documents.active}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
isDraggingAnyCollection={reorderStarProps.isDragging}
|
||||
/>
|
||||
</Draggable>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function StarredLink({ star }: Props) {
|
||||
const theme = useTheme();
|
||||
const { ui, collections, documents } = useStores();
|
||||
@@ -123,95 +285,40 @@ function StarredLink({ star }: Props) {
|
||||
);
|
||||
|
||||
if (documentId) {
|
||||
const document = documents.get(documentId);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const documentCollection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const childDocuments = documentCollection
|
||||
? documentCollection.getChildrenForDocument(documentId)
|
||||
: [];
|
||||
const hasChildDocuments = childDocuments.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
<SidebarLink
|
||||
depth={0}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={icon}
|
||||
isActive={(
|
||||
match,
|
||||
location: Location<{ sidebarContext?: SidebarContextType }>
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={label}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
menu={
|
||||
document && !isDragging ? (
|
||||
<Fade>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Draggable>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={documentCollection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
</>
|
||||
<StarredDocumentLink
|
||||
star={star}
|
||||
documentId={documentId}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
handlePrefetch={handlePrefetch}
|
||||
icon={icon}
|
||||
label={label}
|
||||
menuOpen={menuOpen}
|
||||
handleMenuOpen={handleMenuOpen}
|
||||
handleMenuClose={handleMenuClose}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
return (
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
<CollectionLink
|
||||
collection={collection}
|
||||
expanded={isDragging ? undefined : displayChildDocuments}
|
||||
activeDocument={documents.active}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
isDraggingAnyCollection={reorderStarProps.isDragging}
|
||||
/>
|
||||
</Draggable>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
<StarredCollectionLink
|
||||
star={star}
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
displayChildDocuments={displayChildDocuments}
|
||||
reorderStarProps={reorderStarProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMenuAction } from "./useMenuAction";
|
||||
import { ActionV2Separator, createActionV2 } from "~/actions";
|
||||
import {
|
||||
deleteCollection,
|
||||
editCollection,
|
||||
editCollectionPermissions,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
searchInCollection,
|
||||
createTemplate,
|
||||
archiveCollection,
|
||||
restoreCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
createDocument,
|
||||
exportCollection,
|
||||
importDocument,
|
||||
sortCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import { ActiveCollectionSection } from "~/actions/sections";
|
||||
import { InputIcon } from "outline-icons";
|
||||
import usePolicy from "./usePolicy";
|
||||
import useStores from "./useStores";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
/** Collection ID for which the actions are generated */
|
||||
collectionId: string;
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
};
|
||||
|
||||
export function useCollectionMenuAction({ collectionId, onRename }: Props) {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const collection = collections.get(collectionId);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
restoreCollection,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
ActionV2Separator,
|
||||
createDocument,
|
||||
importDocument,
|
||||
ActionV2Separator,
|
||||
createActionV2({
|
||||
name: `${t("Rename")}…`,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <InputIcon />,
|
||||
visible: !!can.update && !!onRename,
|
||||
perform: () => requestAnimationFrame(() => onRename?.()),
|
||||
}),
|
||||
editCollection,
|
||||
editCollectionPermissions,
|
||||
createTemplate,
|
||||
sortCollection,
|
||||
exportCollection,
|
||||
archiveCollection,
|
||||
searchInCollection,
|
||||
ActionV2Separator,
|
||||
deleteCollection,
|
||||
],
|
||||
[t, can.createDocument, can.update, onRename]
|
||||
);
|
||||
|
||||
return useMenuAction(actions);
|
||||
}
|
||||
@@ -43,8 +43,8 @@ import { useTemplateMenuActions } from "./useTemplateMenuActions";
|
||||
import { useMenuAction } from "./useMenuAction";
|
||||
|
||||
type Props = {
|
||||
/** Document for which the actions are generated */
|
||||
document: Document;
|
||||
/** Document ID for which the actions are generated */
|
||||
documentId: string;
|
||||
/** Invoked when the "Find and replace" menu item is clicked */
|
||||
onFindAndReplace?: () => void;
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
@@ -54,7 +54,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export function useDocumentMenuAction({
|
||||
document,
|
||||
documentId,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
onSelectTemplate,
|
||||
@@ -62,11 +62,10 @@ export function useDocumentMenuAction({
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const can = usePolicy(document);
|
||||
const can = usePolicy(documentId);
|
||||
|
||||
const templateMenuActions = useTemplateMenuActions({
|
||||
document,
|
||||
documentId,
|
||||
onSelectTemplate,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useComputed } from "./useComputed";
|
||||
|
||||
type Props = {
|
||||
/** The document to which the templates will be applied */
|
||||
document: Document;
|
||||
documentId: string;
|
||||
/** Callback to handle when a template is selected */
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
};
|
||||
@@ -33,10 +33,14 @@ type Props = {
|
||||
* @returns An array of Action objects representing templates that can be applied
|
||||
* to the current document. Returns an empty array if no callback is provided.
|
||||
*/
|
||||
export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
|
||||
export function useTemplateMenuActions({
|
||||
documentId,
|
||||
onSelectTemplate,
|
||||
}: Props) {
|
||||
const user = useCurrentUser();
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const document = documents.get(documentId);
|
||||
|
||||
const templateToAction = useCallback(
|
||||
(template: Document): ActionV2 =>
|
||||
@@ -70,7 +74,7 @@ export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
|
||||
.filter(
|
||||
(template) =>
|
||||
!template.isWorkspaceTemplate &&
|
||||
template.collectionId === document.collectionId
|
||||
template.collectionId === document?.collectionId
|
||||
)
|
||||
.map(templateToAction);
|
||||
|
||||
|
||||
@@ -1,47 +1,14 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
ImportIcon,
|
||||
AlphabeticalSortIcon,
|
||||
AlphabeticalReverseSortIcon,
|
||||
ManualSortIcon,
|
||||
InputIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import Collection from "~/models/Collection";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import {
|
||||
ActionV2Separator,
|
||||
createActionV2,
|
||||
createActionV2WithChildren,
|
||||
} from "~/actions";
|
||||
import {
|
||||
deleteCollection,
|
||||
editCollection,
|
||||
editCollectionPermissions,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
searchInCollection,
|
||||
createTemplate,
|
||||
archiveCollection,
|
||||
restoreCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
createDocument,
|
||||
exportCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ActiveCollectionSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -60,10 +27,8 @@ function CollectionMenu({
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { documents, subscriptions } = useStores();
|
||||
const { subscriptions } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const file = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
loading: subscriptionLoading,
|
||||
@@ -82,161 +47,13 @@ function CollectionMenu({
|
||||
}
|
||||
}, [subscriptionLoading, subscriptionLoaded, loadSubscription]);
|
||||
|
||||
const stopPropagation = React.useCallback((ev: React.SyntheticEvent) => {
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleImportDocument = React.useCallback(() => {
|
||||
// simulate a click on the file upload input element
|
||||
if (file.current) {
|
||||
file.current.click();
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
const handleFilePicked = React.useCallback(
|
||||
async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = getEventFiles(ev);
|
||||
|
||||
// Because this is the onChange handler it's possible for the change to be
|
||||
// from previously selecting a file to not selecting a file – aka empty
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
const document = await documents.import(file, null, collection.id, {
|
||||
publish: true,
|
||||
});
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
ev.target.value = "";
|
||||
}
|
||||
},
|
||||
[history, collection.id, documents]
|
||||
);
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(field: string, direction = "asc") =>
|
||||
collection.save({
|
||||
sort: {
|
||||
field,
|
||||
direction,
|
||||
},
|
||||
}),
|
||||
[collection]
|
||||
);
|
||||
|
||||
const can = usePolicy(collection);
|
||||
const sortAlphabetical = collection.sort.field === "title";
|
||||
const sortDir = collection.sort.direction;
|
||||
|
||||
const sortAction = React.useMemo(
|
||||
() =>
|
||||
createActionV2WithChildren({
|
||||
name: t("Sort in sidebar"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: can.update,
|
||||
icon: sortAlphabetical ? (
|
||||
sortDir === "asc" ? (
|
||||
<AlphabeticalSortIcon />
|
||||
) : (
|
||||
<AlphabeticalReverseSortIcon />
|
||||
)
|
||||
) : (
|
||||
<ManualSortIcon />
|
||||
),
|
||||
children: [
|
||||
createActionV2({
|
||||
name: t("A-Z sort"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: can.update,
|
||||
selected: sortAlphabetical && sortDir === "asc",
|
||||
perform: () => handleChangeSort("title", "asc"),
|
||||
}),
|
||||
createActionV2({
|
||||
name: t("Z-A sort"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: can.update,
|
||||
selected: sortAlphabetical && sortDir === "desc",
|
||||
perform: () => handleChangeSort("title", "desc"),
|
||||
}),
|
||||
createActionV2({
|
||||
name: t("Manual sort"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: can.update,
|
||||
selected: !sortAlphabetical,
|
||||
perform: () => handleChangeSort("index"),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
[t, can.update, sortAlphabetical, sortDir, handleChangeSort]
|
||||
);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
restoreCollection,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
ActionV2Separator,
|
||||
createDocument,
|
||||
createActionV2({
|
||||
name: t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ImportIcon />,
|
||||
visible: can.createDocument,
|
||||
perform: handleImportDocument,
|
||||
}),
|
||||
ActionV2Separator,
|
||||
createActionV2({
|
||||
name: `${t("Rename")}…`,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <InputIcon />,
|
||||
visible: !!can.update && !!onRename,
|
||||
perform: () => requestAnimationFrame(() => onRename?.()),
|
||||
}),
|
||||
editCollection,
|
||||
editCollectionPermissions,
|
||||
createTemplate,
|
||||
sortAction,
|
||||
exportCollection,
|
||||
archiveCollection,
|
||||
searchInCollection,
|
||||
ActionV2Separator,
|
||||
deleteCollection,
|
||||
],
|
||||
[
|
||||
t,
|
||||
can.createDocument,
|
||||
can.update,
|
||||
sortAction,
|
||||
handleImportDocument,
|
||||
onRename,
|
||||
]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
const rootAction = useCollectionMenuAction({
|
||||
collectionId: collection.id,
|
||||
onRename,
|
||||
});
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
|
||||
<VisuallyHidden.Root>
|
||||
<label>
|
||||
{t("Import document")}
|
||||
<input
|
||||
type="file"
|
||||
ref={file}
|
||||
onChange={handleFilePicked}
|
||||
onClick={stopPropagation}
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</label>
|
||||
</VisuallyHidden.Root>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align={align}
|
||||
|
||||
@@ -126,7 +126,7 @@ function DocumentMenu({
|
||||
);
|
||||
|
||||
const rootAction = useDocumentMenuAction({
|
||||
document,
|
||||
documentId: document.id,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
onSelectTemplate,
|
||||
|
||||
@@ -18,7 +18,10 @@ type Props = {
|
||||
|
||||
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const allActions = useTemplateMenuActions({ onSelectTemplate, document });
|
||||
const allActions = useTemplateMenuActions({
|
||||
onSelectTemplate,
|
||||
documentId: document.id,
|
||||
});
|
||||
const rootAction = useMenuAction(allActions);
|
||||
|
||||
if (!allActions.length) {
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
"Permissions": "Permissions",
|
||||
"Collection permissions": "Collection permissions",
|
||||
"Share this collection": "Share this collection",
|
||||
"Import document": "Import document",
|
||||
"Sort in sidebar": "Sort in sidebar",
|
||||
"A-Z sort": "A-Z sort",
|
||||
"Z-A sort": "Z-A sort",
|
||||
"Manual sort": "Manual sort",
|
||||
"Search in collection": "Search in collection",
|
||||
"Star": "Star",
|
||||
"Unstar": "Unstar",
|
||||
@@ -83,7 +88,6 @@
|
||||
"Search in document": "Search in document",
|
||||
"Print": "Print",
|
||||
"Print document": "Print document",
|
||||
"Import document": "Import document",
|
||||
"Templatize": "Templatize",
|
||||
"Create template": "Create template",
|
||||
"Open random document": "Open random document",
|
||||
@@ -206,6 +210,8 @@
|
||||
"Move document": "Move document",
|
||||
"Moving": "Moving",
|
||||
"Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.": "Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.",
|
||||
"More options": "More options",
|
||||
"Submenu": "Submenu",
|
||||
"Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
|
||||
"Start view": "Start view",
|
||||
"Install now": "Install now",
|
||||
@@ -423,6 +429,7 @@
|
||||
"Could not load shared documents": "Could not load shared documents",
|
||||
"Shared with me": "Shared with me",
|
||||
"Show more": "Show more",
|
||||
"Link options": "Link options",
|
||||
"Could not load starred documents": "Could not load starred documents",
|
||||
"Starred": "Starred",
|
||||
"Up to date": "Up to date",
|
||||
@@ -477,7 +484,7 @@
|
||||
"Keep as link": "Keep as link",
|
||||
"Mention": "Mention",
|
||||
"Embed": "Embed",
|
||||
"More options": "More options",
|
||||
"Rename": "Rename",
|
||||
"Insert after": "Insert after",
|
||||
"Insert before": "Insert before",
|
||||
"Move up": "Move up",
|
||||
@@ -561,7 +568,6 @@
|
||||
"Video": "Video",
|
||||
"None": "None",
|
||||
"Delete embed": "Delete embed",
|
||||
"Rename": "Rename",
|
||||
"Could not import file": "Could not import file",
|
||||
"Unsubscribed from document": "Unsubscribed from document",
|
||||
"Unsubscribed from collection": "Unsubscribed from collection",
|
||||
@@ -579,10 +585,6 @@
|
||||
"Integrations": "Integrations",
|
||||
"API key": "API key",
|
||||
"Show path to document": "Show path to document",
|
||||
"Sort in sidebar": "Sort in sidebar",
|
||||
"A-Z sort": "A-Z sort",
|
||||
"Z-A sort": "Z-A sort",
|
||||
"Manual sort": "Manual sort",
|
||||
"Collection menu": "Collection menu",
|
||||
"Comment options": "Comment options",
|
||||
"Enable viewer insights": "Enable viewer insights",
|
||||
@@ -1280,4 +1282,4 @@
|
||||
"Caption": "Caption",
|
||||
"Open": "Open",
|
||||
"Error loading data": "Error loading data"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user