Files
outline/app/menus/DocumentMenu.tsx
T
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00

246 lines
6.5 KiB
TypeScript

import { noop } from "es-toolkit/compat";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { SubscriptionType, UserPreference } from "@shared/types";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import Switch from "~/components/Switch";
import { ActionContextProvider } from "~/hooks/useActionContext";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { MenuSeparator } from "~/components/primitives/components/Menu";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
type Props = {
/** Document for which the menu is to be shown */
document: Document;
/** Alignment w.r.t trigger - defaults to start */
align?: "start" | "end";
/** Trigger's variant - renders nude variant if unset */
neutral?: boolean;
/** Pass true if the document is currently being displayed */
showDisplayOptions?: boolean;
/** Whether to include the option of toggling embeds as menu item */
showToggleEmbeds?: boolean;
/** Invoked when the "Find and replace" menu item is clicked */
onFindAndReplace?: () => void;
/** Callback when a template is selected to apply its content to the document */
onSelectTemplate?: (template: Template) => void;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
/** Invoked when menu is opened */
onOpen?: () => void;
/** Invoked when menu is closed */
onClose?: () => void;
};
function DocumentMenu({
document,
align,
neutral,
showToggleEmbeds,
showDisplayOptions,
onSelectTemplate,
onRename,
onOpen,
onClose,
onFindAndReplace,
}: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const isMobile = useMobile();
const can = usePolicy(document);
const { userMemberships, groupMemberships, subscriptions, pins } =
useStores();
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
groupMemberships.getByDocumentId(document.id)
);
const {
loading: auxDataLoading,
loaded: auxDataLoaded,
request: auxDataRequest,
} = useRequest(() =>
Promise.all([
subscriptions.fetchOne({
documentId: document.id,
event: SubscriptionType.Document,
}),
document.collectionId
? subscriptions.fetchOne({
collectionId: document.collectionId,
event: SubscriptionType.Document,
})
: noop,
pins.fetchOne({
documentId: document.id,
collectionId: document.collectionId ?? null,
}),
])
);
const handlePointerEnter = React.useCallback(() => {
if (!auxDataLoading && !auxDataLoaded) {
void auxDataRequest();
void document.loadRelations();
}
}, [auxDataLoading, auxDataLoaded, auxDataRequest, document]);
const handleEmbedsToggle = React.useCallback(
(checked: boolean) => {
if (checked) {
document.enableEmbeds();
} else {
document.disableEmbeds();
}
},
[document]
);
const handleFullWidthToggle = React.useCallback(
(checked: boolean) => {
user.setPreference(UserPreference.FullWidthDocuments, checked);
void user.save();
document.fullWidth = checked;
void document.save({ fullWidth: checked });
},
[user, document]
);
const handleInsightsToggle = React.useCallback(
(checked: boolean) => {
void document.save({ insightsEnabled: checked });
},
[document]
);
const rootAction = useDocumentMenuAction({
documentId: document.id,
onFindAndReplace,
onRename,
onSelectTemplate,
});
const toggleSwitches = React.useMemo<React.ReactNode>(() => {
if (!can.update || !(showDisplayOptions || showToggleEmbeds)) {
return;
}
return (
<>
<MenuSeparator />
<DisplayOptions>
{can.updateInsights && (
<Style>
<ToggleMenuItem
width={26}
height={14}
label={t("Enable viewer insights")}
labelPosition="left"
checked={document.insightsEnabled}
onChange={handleInsightsToggle}
/>
</Style>
)}
{showToggleEmbeds && (
<Style>
<ToggleMenuItem
width={26}
height={14}
label={t("Enable embeds")}
labelPosition="left"
checked={!document.embedsDisabled}
onChange={handleEmbedsToggle}
/>
</Style>
)}
{showDisplayOptions && !isMobile && (
<Style>
<ToggleMenuItem
width={26}
height={14}
label={t("Full width")}
labelPosition="left"
checked={document.fullWidth}
onChange={handleFullWidthToggle}
/>
</Style>
)}
</DisplayOptions>
</>
);
}, [
t,
can.update,
can.updateInsights,
document.embedsDisabled,
document.fullWidth,
document.insightsEnabled,
isMobile,
showDisplayOptions,
showToggleEmbeds,
handleEmbedsToggle,
handleFullWidthToggle,
handleInsightsToggle,
]);
return (
<ActionContextProvider
value={{
activeModels: [
document,
...(!isShared && document.collection ? [document.collection] : []),
],
}}
>
<DropdownMenu
action={rootAction}
align={align}
onOpen={onOpen}
onClose={onClose}
ariaLabel={t("Document options")}
append={toggleSwitches}
>
<OverflowMenuButton
neutral={neutral}
onPointerEnter={handlePointerEnter}
/>
</DropdownMenu>
</ActionContextProvider>
);
}
const ToggleMenuItem = styled(Switch)`
* {
font-weight: normal;
color: ${s("textSecondary")};
}
`;
const DisplayOptions = styled.div`
padding: 8px 0 0;
`;
const Style = styled.div`
padding: 12px;
${breakpoint("tablet")`
padding: 4px 12px;
font-size: 14px;
`};
`;
export default observer(DocumentMenu);