mirror of
https://github.com/outline/outline.git
synced 2025-06-29 16:21:02 +00:00
* Update Switch component onChange handler to use boolean callback - Remove synthetic event handling from Switch component - Update onChange signature to (checked: boolean) => void - Update all call points across the codebase: - PublicAccess.tsx: Updated handlers for indexing, showLastModified, and published switches - Security.tsx: Created individual handlers for all security preferences - Preferences.tsx: Created individual handlers for user preferences - CollectionForm.tsx: Added createSwitchRegister helper for react-hook-form compatibility - TemplatizeDialog: Updated publish handler - DocumentMenu.tsx: Added handlers for embeds and full-width toggles - Search.tsx: Updated SearchTitlesFilter handler - Application.tsx: Added createSwitchRegister helper - Details.tsx: Updated public branding handler - Features.tsx: Created individual handlers for seamless edit and commenting - OAuthClientForm.tsx: Added createSwitchRegister helper - Maintained backward compatibility with react-hook-form - Improved type safety and code clarity * Fix ESLint floating promise errors - Add void operator to onChange call in CollectionForm.tsx - Add void operator to document.save call in DocumentMenu.tsx These changes fix the @typescript-eslint/no-floating-promises errors while maintaining the existing functionality. * Fix Switch component onChange handlers in remaining files - Updated DocumentCopy.tsx handlers to use boolean parameter - Updated Notifications.tsx to use closure pattern for event types - Updated SlackListItem.tsx to use closure pattern for event names - All TypeScript errors resolved * Refactor createSwitchRegister into utils/forms - Created shared utility function in app/utils/forms.ts - Removed duplicate implementations from CollectionForm, Application, and OAuthClientForm - Updated all usage points to use the shared utility - Improved TypeScript typing with Record<string, unknown> - Fixed imports and removed unused variables - Maintained existing functionality while reducing code duplication * Fix TypeScript errors in createSwitchRegister utility - Updated generic constraint from Record<string, unknown> to FieldValues - Added Path import from react-hook-form for proper type safety - Fixed parameter type from keyof TFormData to Path<TFormData> - Improved type compatibility with react-hook-form's UseFormRegister Resolves TypeScript compilation errors in CI pipeline. * Remove unnecessary handlePublishChange callbacks - Removed handlePublishChange wrapper in DocumentCopy.tsx - Removed handlePublishChange wrapper in TemplatizeDialog/index.tsx - Updated Switch components to use setPublish directly - Simplified code by leveraging boolean callback from Switch component Since Switch now passes boolean directly, no need for intermediate callbacks. * Address review feedback: simplify callbacks and fix fullWidth behavior 1. DocumentCopy.tsx: - Remove handleRecursiveChange callback wrapper - Use setRecursive directly with Switch component 2. DocumentMenu.tsx: - Add void user.save() to persist user preference - Add document.fullWidth = checked for optimistic update behavior Both changes leverage the boolean callback from Switch component properly. * Update Security.tsx * i18n --------- Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com> Co-authored-by: Tom Moor <tom@getoutline.com>
531 lines
15 KiB
TypeScript
531 lines
15 KiB
TypeScript
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||
import capitalize from "lodash/capitalize";
|
||
import isEmpty from "lodash/isEmpty";
|
||
import noop from "lodash/noop";
|
||
import { observer } from "mobx-react";
|
||
import {
|
||
EditIcon,
|
||
InputIcon,
|
||
RestoreIcon,
|
||
SearchIcon,
|
||
ShapesIcon,
|
||
} from "outline-icons";
|
||
import * as React from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useHistory } from "react-router-dom";
|
||
import { MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||
import { toast } from "sonner";
|
||
import styled from "styled-components";
|
||
import breakpoint from "styled-components-breakpoint";
|
||
import { s } from "@shared/styles";
|
||
import { SubscriptionType, UserPreference } from "@shared/types";
|
||
import { getEventFiles } from "@shared/utils/files";
|
||
import Document from "~/models/Document";
|
||
import ContextMenu from "~/components/ContextMenu";
|
||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||
import Separator from "~/components/ContextMenu/Separator";
|
||
import Template from "~/components/ContextMenu/Template";
|
||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||
import Switch from "~/components/Switch";
|
||
import { actionToMenuItem } from "~/actions";
|
||
import {
|
||
pinDocument,
|
||
createTemplateFromDocument,
|
||
subscribeDocument,
|
||
unsubscribeDocument,
|
||
moveDocument,
|
||
deleteDocument,
|
||
permanentlyDeleteDocument,
|
||
downloadDocument,
|
||
importDocument,
|
||
starDocument,
|
||
unstarDocument,
|
||
duplicateDocument,
|
||
archiveDocument,
|
||
openDocumentHistory,
|
||
openDocumentInsights,
|
||
publishDocument,
|
||
unpublishDocument,
|
||
printDocument,
|
||
openDocumentComments,
|
||
createDocumentFromTemplate,
|
||
createNestedDocument,
|
||
shareDocument,
|
||
copyDocument,
|
||
searchInDocument,
|
||
leaveDocument,
|
||
moveTemplate,
|
||
} from "~/actions/definitions/documents";
|
||
import useActionContext from "~/hooks/useActionContext";
|
||
import useBoolean from "~/hooks/useBoolean";
|
||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||
import { useMenuState } from "~/hooks/useMenuState";
|
||
import useMobile from "~/hooks/useMobile";
|
||
import usePolicy from "~/hooks/usePolicy";
|
||
import useRequest from "~/hooks/useRequest";
|
||
import useStores from "~/hooks/useStores";
|
||
import { useTemplateMenuItems } from "~/hooks/useTemplateMenuItems";
|
||
import { MenuItem, MenuItemButton } from "~/types";
|
||
import { documentEditPath } from "~/utils/routeHelpers";
|
||
import { MenuContext, useMenuContext } from "./MenuContext";
|
||
|
||
type Props = {
|
||
/** Document for which the menu is to be shown */
|
||
document: Document;
|
||
isRevision?: boolean;
|
||
/** Pass true if the document is currently being displayed */
|
||
showDisplayOptions?: boolean;
|
||
/** Whether to display menu as a modal */
|
||
modal?: boolean;
|
||
/** Whether to include the option of toggling embeds as menu item */
|
||
showToggleEmbeds?: boolean;
|
||
showPin?: boolean;
|
||
/** Label for menu button */
|
||
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
|
||
/** Invoked when the "Find and replace" menu item is clicked */
|
||
onFindAndReplace?: () => void;
|
||
onSelectTemplate?: (template: Document) => 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;
|
||
};
|
||
|
||
type MenuTriggerProps = {
|
||
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
|
||
onTrigger: () => void;
|
||
};
|
||
|
||
const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
|
||
const { t } = useTranslation();
|
||
|
||
const { subscriptions, pins } = useStores();
|
||
const { model: document, menuState } = useMenuContext<Document>();
|
||
|
||
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]);
|
||
|
||
return label ? (
|
||
<MenuButton
|
||
{...menuState}
|
||
onPointerEnter={handlePointerEnter}
|
||
onClick={onTrigger}
|
||
>
|
||
{label}
|
||
</MenuButton>
|
||
) : (
|
||
<OverflowMenuButton
|
||
aria-label={t("Show document menu")}
|
||
onPointerEnter={handlePointerEnter}
|
||
onClick={onTrigger}
|
||
{...menuState}
|
||
/>
|
||
);
|
||
};
|
||
|
||
type MenuContentProps = {
|
||
onOpen?: () => void;
|
||
onClose?: () => void;
|
||
onFindAndReplace?: () => void;
|
||
onSelectTemplate?: (template: Document) => void;
|
||
onRename?: () => void;
|
||
showDisplayOptions?: boolean;
|
||
showToggleEmbeds?: boolean;
|
||
};
|
||
|
||
const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||
onOpen,
|
||
onClose,
|
||
onFindAndReplace,
|
||
onSelectTemplate,
|
||
onRename,
|
||
showDisplayOptions,
|
||
showToggleEmbeds,
|
||
}) {
|
||
const user = useCurrentUser();
|
||
const { model: document, menuState } = useMenuContext<Document>();
|
||
const can = usePolicy(document);
|
||
const { t } = useTranslation();
|
||
const { policies, collections } = useStores();
|
||
|
||
const collection = document.collectionId
|
||
? collections.get(document.collectionId)
|
||
: undefined;
|
||
|
||
const context = useActionContext({
|
||
isContextMenu: true,
|
||
activeDocumentId: document.id,
|
||
activeCollectionId: document.collectionId ?? undefined,
|
||
});
|
||
|
||
const isMobile = useMobile();
|
||
|
||
const handleRestore = React.useCallback(
|
||
async (
|
||
ev: React.SyntheticEvent,
|
||
options?: {
|
||
collectionId: string;
|
||
}
|
||
) => {
|
||
await document.restore(options);
|
||
toast.success(
|
||
t("{{ documentName }} restored", {
|
||
documentName: capitalize(document.noun),
|
||
})
|
||
);
|
||
},
|
||
[t, document]
|
||
);
|
||
|
||
const restoreItems = React.useMemo(
|
||
() => [
|
||
...collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
|
||
const can = policies.abilities(collection.id);
|
||
|
||
if (can.createDocument) {
|
||
filtered.push({
|
||
type: "button",
|
||
onClick: (ev) =>
|
||
handleRestore(ev, {
|
||
collectionId: collection.id,
|
||
}),
|
||
icon: <CollectionIcon collection={collection} />,
|
||
title: collection.name,
|
||
});
|
||
}
|
||
|
||
return filtered;
|
||
}, []),
|
||
],
|
||
[collections.orderedData, handleRestore, policies]
|
||
);
|
||
|
||
const templateMenuItems = useTemplateMenuItems({
|
||
document,
|
||
onSelectTemplate,
|
||
});
|
||
|
||
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]
|
||
);
|
||
|
||
return !isEmpty(can) ? (
|
||
<ContextMenu
|
||
{...menuState}
|
||
aria-label={t("Document options")}
|
||
onOpen={onOpen}
|
||
onClose={onClose}
|
||
>
|
||
<Template
|
||
{...menuState}
|
||
items={[
|
||
{
|
||
type: "button",
|
||
title: t("Restore"),
|
||
visible:
|
||
!!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||
!!(can.restore || can.unarchive),
|
||
onClick: (ev) => handleRestore(ev),
|
||
icon: <RestoreIcon />,
|
||
},
|
||
{
|
||
type: "submenu",
|
||
title: t("Restore"),
|
||
visible:
|
||
!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||
!!(can.restore || can.unarchive) &&
|
||
restoreItems.length !== 0,
|
||
style: {
|
||
left: -170,
|
||
position: "relative",
|
||
top: -40,
|
||
},
|
||
icon: <RestoreIcon />,
|
||
hover: true,
|
||
items: [
|
||
{
|
||
type: "heading",
|
||
title: t("Choose a collection"),
|
||
},
|
||
...restoreItems,
|
||
],
|
||
},
|
||
actionToMenuItem(starDocument, context),
|
||
actionToMenuItem(unstarDocument, context),
|
||
{
|
||
...actionToMenuItem(subscribeDocument, context),
|
||
disabled: collection?.isSubscribed,
|
||
tooltip: collection?.isSubscribed
|
||
? t("Subscription inherited from collection")
|
||
: undefined,
|
||
} as MenuItemButton,
|
||
{
|
||
...actionToMenuItem(unsubscribeDocument, context),
|
||
disabled: collection?.isSubscribed,
|
||
tooltip: collection?.isSubscribed
|
||
? t("Subscription inherited from collection")
|
||
: undefined,
|
||
} as MenuItemButton,
|
||
{
|
||
type: "button",
|
||
title: `${t("Find and replace")}…`,
|
||
visible: !!onFindAndReplace && isMobile,
|
||
onClick: () => onFindAndReplace?.(),
|
||
icon: <SearchIcon />,
|
||
},
|
||
{
|
||
type: "separator",
|
||
},
|
||
{
|
||
type: "route",
|
||
title: t("Edit"),
|
||
to: documentEditPath(document),
|
||
visible:
|
||
!!can.update && user.separateEditMode && !document.template,
|
||
icon: <EditIcon />,
|
||
},
|
||
{
|
||
type: "button",
|
||
title: `${t("Rename")}…`,
|
||
visible: !!can.update && !user.separateEditMode && !!onRename,
|
||
onClick: () => onRename?.(),
|
||
icon: <InputIcon />,
|
||
},
|
||
actionToMenuItem(shareDocument, context),
|
||
actionToMenuItem(createNestedDocument, context),
|
||
actionToMenuItem(importDocument, context),
|
||
actionToMenuItem(createTemplateFromDocument, context),
|
||
actionToMenuItem(duplicateDocument, context),
|
||
actionToMenuItem(publishDocument, context),
|
||
actionToMenuItem(unpublishDocument, context),
|
||
actionToMenuItem(archiveDocument, context),
|
||
actionToMenuItem(moveDocument, context),
|
||
actionToMenuItem(moveTemplate, context),
|
||
{
|
||
type: "submenu",
|
||
title: t("Apply template"),
|
||
icon: <ShapesIcon />,
|
||
items: templateMenuItems,
|
||
},
|
||
actionToMenuItem(pinDocument, context),
|
||
actionToMenuItem(createDocumentFromTemplate, context),
|
||
{
|
||
type: "separator",
|
||
},
|
||
actionToMenuItem(openDocumentComments, context),
|
||
actionToMenuItem(openDocumentHistory, context),
|
||
actionToMenuItem(openDocumentInsights, context),
|
||
actionToMenuItem(downloadDocument, context),
|
||
actionToMenuItem(copyDocument, context),
|
||
actionToMenuItem(printDocument, context),
|
||
actionToMenuItem(searchInDocument, context),
|
||
{
|
||
type: "separator",
|
||
},
|
||
actionToMenuItem(deleteDocument, context),
|
||
actionToMenuItem(permanentlyDeleteDocument, context),
|
||
actionToMenuItem(leaveDocument, context),
|
||
]}
|
||
/>
|
||
{(showDisplayOptions || showToggleEmbeds) && can.update && (
|
||
<>
|
||
<Separator />
|
||
<DisplayOptions>
|
||
{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>
|
||
</>
|
||
)}
|
||
</ContextMenu>
|
||
) : null;
|
||
});
|
||
|
||
function DocumentMenu({
|
||
document,
|
||
modal = true,
|
||
showToggleEmbeds,
|
||
showDisplayOptions,
|
||
onSelectTemplate,
|
||
label,
|
||
onRename,
|
||
onOpen,
|
||
onClose,
|
||
}: Props) {
|
||
const { collections, documents } = useStores();
|
||
const menuState = useMenuState({
|
||
modal,
|
||
unstable_preventOverflow: true,
|
||
unstable_fixed: true,
|
||
unstable_flip: true,
|
||
});
|
||
const history = useHistory();
|
||
|
||
const { t } = useTranslation();
|
||
const [isMenuVisible, showMenu] = useBoolean(false);
|
||
const file = React.useRef<HTMLInputElement>(null);
|
||
|
||
const collection = document.collectionId
|
||
? collections.get(document.collectionId)
|
||
: undefined;
|
||
|
||
const stopPropagation = React.useCallback((ev: React.SyntheticEvent) => {
|
||
ev.stopPropagation();
|
||
}, []);
|
||
|
||
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;
|
||
}
|
||
|
||
if (!collection) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const file = files[0];
|
||
const importedDocument = await documents.import(
|
||
file,
|
||
document.id,
|
||
collection.id,
|
||
{
|
||
publish: true,
|
||
}
|
||
);
|
||
history.push(importedDocument.url);
|
||
} catch (err) {
|
||
toast.error(err.message);
|
||
throw err;
|
||
} finally {
|
||
ev.target.value = "";
|
||
}
|
||
},
|
||
[history, collection, documents, document.id]
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<VisuallyHidden.Root>
|
||
<label>
|
||
{t("Import document")}
|
||
<input
|
||
type="file"
|
||
ref={file}
|
||
onChange={handleFilePicked}
|
||
onClick={stopPropagation}
|
||
accept={documents.importFileTypes.join(", ")}
|
||
tabIndex={-1}
|
||
/>
|
||
</label>
|
||
</VisuallyHidden.Root>
|
||
<MenuContext.Provider value={{ model: document, menuState }}>
|
||
<MenuTrigger label={label} onTrigger={showMenu} />
|
||
{isMenuVisible ? (
|
||
<MenuContent
|
||
onOpen={onOpen}
|
||
onClose={onClose}
|
||
onRename={onRename}
|
||
onSelectTemplate={onSelectTemplate}
|
||
showDisplayOptions={showDisplayOptions}
|
||
showToggleEmbeds={showToggleEmbeds}
|
||
/>
|
||
) : null}
|
||
</MenuContext.Provider>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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);
|