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:
Tom Moor
2025-10-02 12:58:05 +02:00
committed by GitHub
parent fce02996f9
commit 9b95a58822
15 changed files with 508 additions and 335 deletions
+130
View File
@@ -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",
+1 -2
View File
@@ -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 {
+1 -1
View File
@@ -94,7 +94,7 @@ function DocumentListItem(
currentContext: locationSidebarContext,
});
const contextMenuAction = useDocumentMenuAction({ document });
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
return (
<ActionContextProvider
+5 -5
View File
@@ -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>}
</>
);
+190 -83
View File
@@ -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}
/>
);
}
+72
View File
@@ -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);
}
+5 -6
View File
@@ -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,
});
+7 -3
View File
@@ -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);
+6 -189
View File
@@ -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}
+1 -1
View File
@@ -126,7 +126,7 @@ function DocumentMenu({
);
const rootAction = useDocumentMenuAction({
document,
documentId: document.id,
onFindAndReplace,
onRename,
onSelectTemplate,
+4 -1
View File
@@ -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) {
+10 -8
View File
@@ -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"
}
}