mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66ae221aae | |||
| 2b8f6752c0 | |||
| adfca1e5ca | |||
| 6ca3c25d35 | |||
| 05a2c6ae1e | |||
| 234915f4a0 | |||
| 538a1274ab | |||
| 63422373ac | |||
| 708bd8a544 | |||
| 120191d4d7 | |||
| 6a2ab299a8 | |||
| 74dc7094e1 | |||
| 5dd993adf5 | |||
| 41832bbaf1 | |||
| f448be5830 | |||
| f0fcb26b50 | |||
| ad237a619c | |||
| 5f49938267 | |||
| 68a469daa7 | |||
| 3d5a167f7f | |||
| b58671cbd1 | |||
| b3a3b0763f | |||
| a4becd66bd | |||
| 3437bd3a6c | |||
| 86cfd62afa | |||
| 85b62d3146 | |||
| 1fa0a5ea98 | |||
| 2b4c8d981c | |||
| ce55719626 | |||
| b9f0f67fb2 | |||
| 02aa4c2928 | |||
| 77e8dbefd6 | |||
| 1e5d281870 | |||
| 9b68e6835e | |||
| f17926f912 | |||
| 2397196be8 | |||
| 133db9c22c | |||
| 0dd14cdf1a | |||
| cc8ec28a39 | |||
| c8cbb9ef9c | |||
| 4af07ab6c4 | |||
| 742c138b3d | |||
| ec1eacaeea | |||
| 8b15cc45b0 | |||
| e89c32424f | |||
| a458690bfc | |||
| df03a6da8c | |||
| 6dfe7d707a | |||
| c063709f1c | |||
| dd8f6a987c | |||
| fa117870a2 | |||
| 40b1e3c8c6 | |||
| e3b0f7db86 | |||
| 6fddb29ff6 | |||
| 569a7876ae | |||
| bea56159ec | |||
| 908f053920 | |||
| 033c298bff | |||
| 22f02ad713 | |||
| 92b1c578f6 | |||
| a738ea97b5 | |||
| 7fbe442863 | |||
| 2db7690e27 | |||
| 06b89635be | |||
| 1ff23756ac | |||
| a00b677076 | |||
| 6c1e4a5b40 | |||
| 59078704c8 | |||
| f1a20b27fd | |||
| 313b046e4e | |||
| 1154432924 | |||
| e8bddbe104 | |||
| dddb12027c | |||
| 5cb3da82bc | |||
| 7a6f75c34f |
@@ -53,9 +53,13 @@ export const resolveCommentFactory = ({
|
||||
perform: async ({ t }) => {
|
||||
await comment.resolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: null,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onResolve();
|
||||
@@ -81,9 +85,13 @@ export const unresolveCommentFactory = ({
|
||||
perform: async () => {
|
||||
await comment.unresolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: null,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onUnresolve();
|
||||
|
||||
@@ -732,7 +732,6 @@ export const importDocument = createAction({
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,18 +8,16 @@ import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
|
||||
type Props = {
|
||||
type Props = React.PropsWithChildren<{
|
||||
items: MenuInternalLink[];
|
||||
max?: number;
|
||||
highlightFirstItem?: boolean;
|
||||
};
|
||||
}>;
|
||||
|
||||
function Breadcrumb({
|
||||
items,
|
||||
highlightFirstItem,
|
||||
children,
|
||||
max = 2,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
function Breadcrumb(
|
||||
{ items, highlightFirstItem, children, max = 2 }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const totalItems = items.length;
|
||||
const topLevelItems: MenuInternalLink[] = [...items];
|
||||
let overflowItems;
|
||||
@@ -37,9 +35,13 @@ function Breadcrumb({
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex justify="flex-start" align="center">
|
||||
<Flex justify="flex-start" align="center" ref={ref}>
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment key={String(item.to) || index}>
|
||||
<React.Fragment
|
||||
key={
|
||||
(typeof item.to === "string" ? item.to : item.to.pathname) || index
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
{item.to ? (
|
||||
<Item
|
||||
@@ -67,6 +69,8 @@ const Slash = styled(GoToIcon)`
|
||||
|
||||
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
${ellipsis()}
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
@@ -76,7 +80,6 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
height: 24px;
|
||||
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
|
||||
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
@@ -87,4 +90,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default Breadcrumb;
|
||||
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
|
||||
|
||||
@@ -18,6 +18,8 @@ import useStores from "~/hooks/useStores";
|
||||
type Props = {
|
||||
/** The document to display live collaborators for */
|
||||
document: Document;
|
||||
/** The maximum number of collaborators to display, defaults to 6 */
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -25,6 +27,7 @@ type Props = {
|
||||
* and presence status.
|
||||
*/
|
||||
function Collaborators(props: Props) {
|
||||
const { limit = 6 } = props;
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const currentUserId = user?.id;
|
||||
@@ -75,8 +78,6 @@ function Collaborators(props: Props) {
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const limit = 8;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
@@ -88,6 +89,7 @@ function Collaborators(props: Props) {
|
||||
>
|
||||
<Facepile
|
||||
limit={limit}
|
||||
overflow={collaborators.length - limit}
|
||||
users={collaborators}
|
||||
renderAvatar={(collaborator) => {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
|
||||
@@ -8,8 +8,8 @@ import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the dialog is submitted */
|
||||
onSubmit: () => Promise<void> | void;
|
||||
/** Callback when the dialog is submitted. Return false to prevent closing. */
|
||||
onSubmit: () => Promise<void | boolean> | void;
|
||||
/** Text to display on the submit button */
|
||||
submitText?: string;
|
||||
/** Text to display while the form is saving */
|
||||
@@ -38,7 +38,10 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSubmit();
|
||||
const res = await onSubmit();
|
||||
if (res === false) {
|
||||
return;
|
||||
}
|
||||
dialogs.closeAllModals();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
|
||||
@@ -109,6 +109,8 @@ const Title = styled.div`
|
||||
${ellipsis()}
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
type MenuAnchorProps = {
|
||||
|
||||
@@ -8,15 +8,11 @@ import Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Icon from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import {
|
||||
archivePath,
|
||||
collectionPath,
|
||||
settingsPath,
|
||||
trashPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -57,14 +53,14 @@ function useCategory(document: Document): MenuInternalLink | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
document,
|
||||
children,
|
||||
onlyText,
|
||||
}: Props) => {
|
||||
function DocumentBreadcrumb(
|
||||
{ document, children, onlyText }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
@@ -81,7 +77,10 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
to: {
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
};
|
||||
} else if (document.isCollectionDeleted) {
|
||||
collectionNode = {
|
||||
@@ -115,11 +114,14 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
) : (
|
||||
node.title
|
||||
),
|
||||
to: node.url,
|
||||
to: {
|
||||
pathname: node.url,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}, [path, category, collectionNode]);
|
||||
}, [path, category, sidebarContext, collectionNode]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
@@ -140,11 +142,11 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb items={items} highlightFirstItem>
|
||||
<Breadcrumb items={items} ref={ref} highlightFirstItem>
|
||||
{children}
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const StyledIcon = styled(Icon)`
|
||||
margin-right: 2px;
|
||||
@@ -160,4 +162,4 @@ const SmallSlash = styled(GoToIcon)`
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
export default observer(DocumentBreadcrumb);
|
||||
export default observer(React.forwardRef(DocumentBreadcrumb));
|
||||
|
||||
@@ -144,12 +144,7 @@ function DocumentCard(props: Props) {
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
<Clock size={18} />
|
||||
<Time
|
||||
dateTime={document.updatedAt}
|
||||
tooltipDelay={500}
|
||||
addSuffix
|
||||
shorten
|
||||
/>
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</DocumentMeta>
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
@@ -15,6 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -25,7 +26,6 @@ import InputSearch from "~/components/InputSearch";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { ancestors, descendants } from "~/utils/tree";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -21,9 +21,11 @@ import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { hover } from "~/styles";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -50,6 +52,7 @@ function DocumentListItem(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
@@ -78,6 +81,12 @@ function DocumentListItem(
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
|
||||
const sidebarContext = determineSidebarContext({
|
||||
document,
|
||||
user,
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
@@ -89,6 +98,7 @@ function DocumentListItem(
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
@@ -111,11 +121,7 @@ function DocumentListItem(
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip
|
||||
content={t("Only visible to you")}
|
||||
delay={500}
|
||||
placement="top"
|
||||
>
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -185,9 +185,9 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
{t("in")}
|
||||
<strong>
|
||||
<Strong>
|
||||
<DocumentBreadcrumb document={document} onlyText />
|
||||
</strong>
|
||||
</Strong>
|
||||
</span>
|
||||
)}
|
||||
{showParentDocuments && nestedDocumentsCount > 0 && (
|
||||
@@ -210,6 +210,10 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Strong = styled.strong`
|
||||
font-weight: 550;
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
@@ -19,6 +19,7 @@ import Event from "~/models/Event";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
import { hover } from "~/styles";
|
||||
@@ -35,6 +36,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { revisions } = useStores();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const opts = {
|
||||
userName: event.actor.name,
|
||||
};
|
||||
@@ -66,7 +68,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
);
|
||||
to = {
|
||||
pathname: documentHistoryPath(document, event.modelId || "latest"),
|
||||
state: { retainScrollPosition: true },
|
||||
state: {
|
||||
sidebarContext,
|
||||
retainScrollPosition: true,
|
||||
},
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -140,7 +145,6 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
title={
|
||||
<Time
|
||||
dateTime={event.createdAt}
|
||||
tooltipDelay={500}
|
||||
format={{
|
||||
en_US: "MMM do, h:mm a",
|
||||
fr_FR: "'Le 'd MMMM 'à' H:mm",
|
||||
|
||||
+56
-36
@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
|
||||
import { MenuIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
@@ -10,27 +11,35 @@ import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TooltipProvider } from "./TooltipContext";
|
||||
|
||||
export const HEADER_HEIGHT = 64;
|
||||
|
||||
type Props = {
|
||||
left?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
actions?:
|
||||
| ((props: { isCompact: boolean }) => React.ReactNode)
|
||||
| React.ReactNode;
|
||||
hasSidebar?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Header({ left, title, actions, hasSidebar, className }: Props) {
|
||||
function Header(
|
||||
{ left, title, actions, hasSidebar, className }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const { ui } = useStores();
|
||||
const isMobile = useMobile();
|
||||
const hasMobileSidebar = hasSidebar && isMobile;
|
||||
|
||||
const internalRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const breadcrumbsRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const passThrough = !actions && !left && !title;
|
||||
|
||||
const [isScrolled, setScrolled] = React.useState(false);
|
||||
@@ -53,38 +62,50 @@ function Header({ left, title, actions, hasSidebar, className }: Props) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
$passThrough={passThrough}
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
{left || hasMobileSidebar ? (
|
||||
<Breadcrumbs>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
{left}
|
||||
</Breadcrumbs>
|
||||
) : null}
|
||||
const setBreadcrumbRef = React.useCallback((node: HTMLDivElement | null) => {
|
||||
breadcrumbsRef.current = node?.firstElementChild as HTMLDivElement;
|
||||
}, []);
|
||||
|
||||
{isScrolled ? (
|
||||
<Title onClick={handleClickTitle}>
|
||||
<Fade>{title}</Fade>
|
||||
</Title>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Actions align="center" justify="flex-end">
|
||||
{actions}
|
||||
</Actions>
|
||||
</Wrapper>
|
||||
const size = useComponentSize(internalRef);
|
||||
const breadcrumbsSize = useComponentSize(breadcrumbsRef);
|
||||
const breadcrumbMakesCompact = breadcrumbsSize.width > size.width / 3;
|
||||
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Wrapper
|
||||
ref={mergeRefs([ref, internalRef])}
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
$passThrough={passThrough}
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
{left || hasMobileSidebar ? (
|
||||
<Breadcrumbs ref={setBreadcrumbRef}>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
{left}
|
||||
</Breadcrumbs>
|
||||
) : null}
|
||||
|
||||
{isScrolled && !isCompact ? (
|
||||
<Title onClick={handleClickTitle}>
|
||||
<Fade>{title}</Fade>
|
||||
</Title>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Actions align="center" justify="flex-end">
|
||||
{typeof actions === "function" ? actions({ isCompact }) : actions}
|
||||
</Actions>
|
||||
</Wrapper>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,7 +172,6 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 16px;
|
||||
justify-content: center;
|
||||
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
|
||||
`};
|
||||
`;
|
||||
@@ -190,4 +210,4 @@ const MobileMenuButton = styled(Button)`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Header);
|
||||
export default observer(React.forwardRef(Header));
|
||||
|
||||
@@ -4,9 +4,9 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import Input, { Outline } from "./Input";
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Helmet } from "react-helmet-async";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||
import SkipNavContent from "~/components/SkipNavContent";
|
||||
@@ -13,7 +14,6 @@ import useAutoRefresh from "~/hooks/useAutoRefresh";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { MenuProvider } from "~/hooks/useMenuContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
|
||||
@@ -23,7 +23,6 @@ function eachMinute(fn: () => void) {
|
||||
export type Props = {
|
||||
children?: React.ReactNode;
|
||||
dateTime: string;
|
||||
tooltipDelay?: number;
|
||||
addSuffix?: boolean;
|
||||
shorten?: boolean;
|
||||
relative?: boolean;
|
||||
@@ -37,7 +36,6 @@ const LocaleTime: React.FC<Props> = ({
|
||||
shorten,
|
||||
format,
|
||||
relative,
|
||||
tooltipDelay,
|
||||
}: Props) => {
|
||||
const userLocale = useUserLocale();
|
||||
const dateFormatLong: Record<string, string> = {
|
||||
@@ -82,7 +80,7 @@ const LocaleTime: React.FC<Props> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent} delay={tooltipDelay} placement="bottom">
|
||||
<Tooltip content={tooltipContent} placement="bottom">
|
||||
<time dateTime={dateTime}>{children || content}</time>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -52,11 +52,7 @@ function NotificationListItem({ notification, onNavigate }: Props) {
|
||||
<Text weight="bold">{notification.subject}</Text>
|
||||
</Text>
|
||||
<Text type="tertiary" size="xsmall">
|
||||
<Time
|
||||
dateTime={notification.createdAt}
|
||||
tooltipDelay={1000}
|
||||
addSuffix
|
||||
/>{" "}
|
||||
<Time dateTime={notification.createdAt} addSuffix />{" "}
|
||||
{collection && <>· {collection.name}</>}
|
||||
</Text>
|
||||
{notification.comment && (
|
||||
|
||||
@@ -60,7 +60,7 @@ function Notifications(
|
||||
</Text>
|
||||
<Flex gap={8}>
|
||||
{notifications.approximateUnreadCount > 0 && (
|
||||
<Tooltip delay={500} content={t("Mark all as read")}>
|
||||
<Tooltip content={t("Mark all as read")}>
|
||||
<Button action={markNotificationsAsRead} context={context}>
|
||||
<MarkAsReadIcon />
|
||||
</Button>
|
||||
|
||||
@@ -10,13 +10,23 @@ import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type Props = PopoverProps & {
|
||||
children: React.ReactNode;
|
||||
/** The width of the popover, defaults to 380px. */
|
||||
width?: number;
|
||||
/** The minimum width of the popover, use instead of width if contents adjusts size. */
|
||||
minWidth?: number;
|
||||
/** Shrink the padding of the popover */
|
||||
shrink?: boolean;
|
||||
/** Make the popover flex */
|
||||
flex?: boolean;
|
||||
/** The tab index of the popover */
|
||||
tabIndex?: number;
|
||||
/** Whether the popover should be scrollable, defaults to true. */
|
||||
scrollable?: boolean;
|
||||
/** The position of the popover on mobile, defaults to "top". */
|
||||
mobilePosition?: "top" | "bottom";
|
||||
/** Function to show the popover */
|
||||
show: () => void;
|
||||
/** Function to hide the popover */
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
@@ -25,6 +35,7 @@ const Popover = (
|
||||
children,
|
||||
shrink,
|
||||
width = 380,
|
||||
minWidth,
|
||||
scrollable = true,
|
||||
flex,
|
||||
mobilePosition,
|
||||
@@ -71,6 +82,7 @@ const Popover = (
|
||||
ref={ref}
|
||||
$shrink={shrink}
|
||||
$width={width}
|
||||
$minWidth={minWidth}
|
||||
$scrollable={scrollable}
|
||||
$flex={flex}
|
||||
>
|
||||
@@ -83,6 +95,7 @@ const Popover = (
|
||||
type ContentsProps = {
|
||||
$shrink?: boolean;
|
||||
$width?: number;
|
||||
$minWidth?: number;
|
||||
$flex?: boolean;
|
||||
$scrollable: boolean;
|
||||
$mobilePosition?: "top" | "bottom";
|
||||
@@ -101,7 +114,8 @@ const Contents = styled.div<ContentsProps>`
|
||||
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
|
||||
max-height: 75vh;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
width: ${(props) => props.$width}px;
|
||||
${(props) => props.$width && `width: ${props.$width}px`};
|
||||
${(props) => props.$minWidth && `min-width: ${props.$minWidth}px`};
|
||||
|
||||
${(props) =>
|
||||
props.$scrollable
|
||||
|
||||
@@ -128,7 +128,7 @@ const Reaction: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
return tooltipContent ? (
|
||||
<Tooltip content={tooltipContent} delay={250} placement="bottom">
|
||||
<Tooltip content={tooltipContent} placement="bottom">
|
||||
{DisplayedEmoji}
|
||||
</Tooltip>
|
||||
) : (
|
||||
|
||||
@@ -98,12 +98,7 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<Tooltip
|
||||
content={t("Add reaction")}
|
||||
placement="top"
|
||||
delay={500}
|
||||
hideOnClick
|
||||
>
|
||||
<Tooltip content={t("Add reaction")} placement="top" hideOnClick>
|
||||
<NudeButton
|
||||
{...props}
|
||||
aria-label={t("Reaction picker")}
|
||||
|
||||
@@ -119,7 +119,7 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
: share?.url ?? "";
|
||||
|
||||
const copyButton = (
|
||||
<Tooltip content={t("Copy public link")} delay={500} placement="top">
|
||||
<Tooltip content={t("Copy public link")} placement="top">
|
||||
<CopyToClipboard text={shareUrl} onCopy={handleCopied}>
|
||||
<NudeButton type="button" disabled={!share} style={{ marginRight: 3 }}>
|
||||
<CopyIcon color={theme.placeholder} size={18} />
|
||||
|
||||
@@ -31,7 +31,7 @@ export function CopyLinkButton({
|
||||
}, [onCopy, t]);
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Copy link")} delay={500} placement="top">
|
||||
<Tooltip content={t("Copy link")} placement="top">
|
||||
<CopyToClipboard text={url} onCopy={handleCopied}>
|
||||
<NudeButton type="button">
|
||||
<LinkIcon size={20} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
@@ -14,7 +15,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Tooltip from "../Tooltip";
|
||||
@@ -80,7 +80,6 @@ function AppSidebar() {
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
|
||||
@@ -128,7 +128,7 @@ const Sidebar = styled(m.div)<{
|
||||
max-width: 80%;
|
||||
border-left: 1px solid ${s("divider")};
|
||||
transition: border-left 100ms ease-in-out;
|
||||
z-index: 1;
|
||||
z-index: ${depths.sidebar};
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
display: flex;
|
||||
|
||||
@@ -5,12 +5,12 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
@@ -42,11 +42,7 @@ function SettingsSidebar() {
|
||||
image={<StyledBackIcon />}
|
||||
onClick={returnToApp}
|
||||
>
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import SearchPopover from "~/components/SearchPopover";
|
||||
@@ -12,7 +13,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import history from "~/utils/history";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
@@ -81,11 +81,7 @@ const ToggleSidebar = () => {
|
||||
const { ui } = useStores();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import NotificationIcon from "../Notifications/NotificationIcon";
|
||||
import NotificationsPopover from "../Notifications/NotificationsPopover";
|
||||
import { TooltipProvider } from "../TooltipContext";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
@@ -194,8 +195,9 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Container
|
||||
id="sidebar"
|
||||
ref={ref}
|
||||
style={style}
|
||||
$hidden={hidden}
|
||||
@@ -242,7 +244,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
/>
|
||||
</Container>
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
|
||||
</>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarAction from "./SidebarAction";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
function Collections() {
|
||||
@@ -49,38 +50,40 @@ function Collections() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.allActive}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
<SidebarContext.Provider value="collections">
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.allActive}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
<DraggableCollectionLink
|
||||
key={item.id}
|
||||
collection={item}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
<DraggableCollectionLink
|
||||
key={item.id}
|
||||
collection={item}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
)}
|
||||
/>
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -278,7 +278,7 @@ function InnerDocumentLink(
|
||||
!isDraggingAnyDocument ? (
|
||||
<Fade>
|
||||
{can.createChildDocument && (
|
||||
<Tooltip content={t("New doc")} delay={500}>
|
||||
<Tooltip content={t("New doc")}>
|
||||
<NudeButton
|
||||
type={undefined}
|
||||
aria-label={t("New nested document")}
|
||||
|
||||
@@ -7,8 +7,8 @@ import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import DropCursor from "./DropCursor";
|
||||
@@ -29,7 +29,7 @@ function DraggableCollectionLink({
|
||||
prefetchDocument,
|
||||
belowCollection,
|
||||
}: Props) {
|
||||
const locationSidebarContext = useLocationState();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const { ui, policies, collections } = useStores();
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
|
||||
@@ -2,10 +2,11 @@ import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Group from "~/models/Group";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -14,13 +15,23 @@ type Props = {
|
||||
};
|
||||
|
||||
const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = groupSidebarContext(group.id);
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
locationSidebarContext === sidebarContext
|
||||
);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (locationSidebarContext === sidebarContext) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [sidebarContext, locationSidebarContext, setExpanded]);
|
||||
|
||||
return (
|
||||
<Relative>
|
||||
<SidebarLink
|
||||
@@ -30,7 +41,7 @@ const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
onClick={handleDisclosureClick}
|
||||
depth={0}
|
||||
/>
|
||||
<SidebarContext.Provider value={group.id}>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Folder expanded={expanded}>
|
||||
{group.documentMemberships.map((membership) => (
|
||||
<SharedWithMeLink
|
||||
|
||||
@@ -43,12 +43,12 @@ function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
|
||||
|
||||
return (
|
||||
<Navigation gap={4} {...props}>
|
||||
<Tooltip content={t("Go back")} delay={500}>
|
||||
<Tooltip content={t("Go back")}>
|
||||
<NudeButton onClick={() => Desktop.bridge?.goBack()}>
|
||||
<Back $active={back} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Go forward")} delay={500}>
|
||||
<Tooltip content={t("Go forward")}>
|
||||
<NudeButton onClick={() => Desktop.bridge?.goForward()}>
|
||||
<Forward $active={forward} />
|
||||
</NudeButton>
|
||||
|
||||
@@ -93,15 +93,11 @@ const NavLink = ({
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isActive && linkRef.current && scrollIntoViewIfNeeded !== false) {
|
||||
// If the page has an anchor hash then this means we're linking to an
|
||||
// anchor in the document – smooth scrolling the sidebar may the scrolling
|
||||
// to the anchor of the document so we must avoid it.
|
||||
if (!window.location.hash) {
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
});
|
||||
}
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
boundary: (parent) => parent.id !== "sidebar",
|
||||
});
|
||||
}
|
||||
}, [linkRef, scrollIntoViewIfNeeded, isActive]);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import GroupMembership from "~/models/GroupMembership";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import {
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
useDropToReorderUserMembership,
|
||||
useDropToReparentDocument,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
@@ -36,7 +36,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId } = membership;
|
||||
const isActiveDocument = documentId === ui.activeDocumentId;
|
||||
const locationSidebarContext = useLocationState();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const document = documentId ? documents.get(documentId) : undefined;
|
||||
|
||||
|
||||
@@ -1,9 +1,57 @@
|
||||
import * as React from "react";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
|
||||
export type SidebarContextType = "collections" | "starred" | string | undefined;
|
||||
export type SidebarContextType =
|
||||
| "collections"
|
||||
| "shared"
|
||||
| `group-${string}`
|
||||
| `starred-${string}`
|
||||
| undefined;
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextType>(undefined);
|
||||
|
||||
export const useSidebarContext = () => React.useContext(SidebarContext);
|
||||
|
||||
export const groupSidebarContext = (groupId: string): SidebarContextType =>
|
||||
`group-${groupId}`;
|
||||
|
||||
export const starredSidebarContext = (modelId: string): SidebarContextType =>
|
||||
`starred-${modelId}`;
|
||||
|
||||
export const determineSidebarContext = ({
|
||||
document,
|
||||
user,
|
||||
currentContext,
|
||||
}: {
|
||||
document: Document;
|
||||
user: User;
|
||||
currentContext?: SidebarContextType;
|
||||
}): SidebarContextType => {
|
||||
const isStarred = document.isStarred || !!document.collection?.isStarred;
|
||||
const preferStarred = !currentContext || currentContext.startsWith("starred");
|
||||
|
||||
if (isStarred && preferStarred) {
|
||||
const currentlyInStarredCollection =
|
||||
currentContext === starredSidebarContext(document.collectionId ?? "");
|
||||
|
||||
return document.isStarred && !currentlyInStarredCollection
|
||||
? starredSidebarContext(document.id)
|
||||
: starredSidebarContext(document.collectionId!);
|
||||
}
|
||||
|
||||
if (document.collection) {
|
||||
return "collections";
|
||||
} else if (
|
||||
user.documentMemberships.find((m) => m.documentId === document.id)
|
||||
) {
|
||||
return "shared";
|
||||
} else {
|
||||
const group = user.groupsWithDocumentMemberships.find(
|
||||
(g) => !!g.documentMemberships.find((m) => m.documentId === document.id)
|
||||
);
|
||||
return groupSidebarContext(group?.id ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
export default SidebarContext;
|
||||
|
||||
@@ -15,7 +15,6 @@ import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import StarredLink from "./StarredLink";
|
||||
|
||||
@@ -42,48 +41,46 @@ function Starred() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value="starred">
|
||||
<Flex column>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
</SidebarContext.Provider>
|
||||
<Flex column>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import styled, { useTheme } from "styled-components";
|
||||
import Star from "~/models/Star";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import {
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
useDropToCreateStar,
|
||||
useDropToReorderStar,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
@@ -25,7 +25,7 @@ import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext, {
|
||||
SidebarContextType,
|
||||
useSidebarContext,
|
||||
starredSidebarContext,
|
||||
} from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
@@ -39,10 +39,14 @@ function StarredLink({ star }: Props) {
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId, collectionId } = star;
|
||||
const collection = collections.get(collectionId);
|
||||
const locationSidebarContext = useLocationState();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = starredSidebarContext(
|
||||
star.documentId ?? star.collectionId
|
||||
);
|
||||
const [expanded, setExpanded] = useState(
|
||||
star.collectionId === ui.activeCollectionId &&
|
||||
(star.documentId
|
||||
? star.documentId === ui.activeDocumentId
|
||||
: star.collectionId === ui.activeCollectionId) &&
|
||||
sidebarContext === locationSidebarContext
|
||||
);
|
||||
|
||||
@@ -159,7 +163,7 @@ function StarredLink({ star }: Props) {
|
||||
}
|
||||
/>
|
||||
</Draggable>
|
||||
<SidebarContext.Provider value={document.id}>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
@@ -183,7 +187,7 @@ function StarredLink({ star }: Props) {
|
||||
|
||||
if (collection) {
|
||||
return (
|
||||
<>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
<CollectionLink
|
||||
collection={collection}
|
||||
@@ -193,16 +197,14 @@ function StarredLink({ star }: Props) {
|
||||
isDraggingAnyCollection={reorderStarProps.isDragging}
|
||||
/>
|
||||
</Draggable>
|
||||
<SidebarContext.Provider value={collection.id}>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
</>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ function Time({ onClick, ...props }: Props) {
|
||||
<time dateTime={props.dateTime}>{props.children || content}</time>
|
||||
}
|
||||
>
|
||||
<LocaleTime tooltipDelay={250} {...props} />
|
||||
<LocaleTime {...props} />
|
||||
</React.Suspense>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import Tippy, { TippyProps } from "@tippyjs/react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { createGlobalStyle } from "styled-components";
|
||||
import { roundArrow } from "tippy.js";
|
||||
import { s } from "@shared/styles";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useTooltipContext } from "./TooltipContext";
|
||||
|
||||
export type Props = Omit<TippyProps, "content" | "theme"> & {
|
||||
/** The content to display in the tooltip. */
|
||||
content?: React.ReactChild | React.ReactChild[];
|
||||
/** A keyboard shortcut to display next to the content */
|
||||
shortcut?: React.ReactNode;
|
||||
/** Whether to show the shortcut on a new line */
|
||||
shortcutOnNewline?: boolean;
|
||||
};
|
||||
|
||||
function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
|
||||
/**
|
||||
* A tooltip component that wraps Tippy and provides a consistent look and feel. Optionally
|
||||
* displays a keyboard shortcut next to the content.
|
||||
*
|
||||
* Wrap this component in a TooltipProvider to allow multiple tooltips to share the same
|
||||
* singleton instance (delay, animation, etc).
|
||||
*/
|
||||
function Tooltip({
|
||||
shortcut,
|
||||
shortcutOnNewline,
|
||||
content: tooltip,
|
||||
delay = 500,
|
||||
...rest
|
||||
}: Props) {
|
||||
const isMobile = useMobile();
|
||||
const singleton = useTooltipContext();
|
||||
|
||||
let content = <>{tooltip}</>;
|
||||
|
||||
@@ -24,7 +42,19 @@ function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
|
||||
if (shortcut) {
|
||||
content = (
|
||||
<>
|
||||
{tooltip} · <Shortcut>{shortcut}</Shortcut>
|
||||
{tooltip}
|
||||
{shortcutOnNewline ? <br /> : " "}
|
||||
{typeof shortcut === "string" ? (
|
||||
shortcut
|
||||
.split("+")
|
||||
.map((key, i) => (
|
||||
<Shortcut key={`${key}${i}`}>
|
||||
{key.length === 1 ? key.toUpperCase() : key}
|
||||
</Shortcut>
|
||||
))
|
||||
) : (
|
||||
<Shortcut>{shortcut}</Shortcut>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -32,9 +62,10 @@ function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
|
||||
return (
|
||||
<Tippy
|
||||
arrow={roundArrow}
|
||||
animation="shift-away"
|
||||
content={content}
|
||||
delay={delay}
|
||||
animation="shift-away"
|
||||
singleton={singleton}
|
||||
duration={[200, 150]}
|
||||
inertia
|
||||
{...rest}
|
||||
@@ -44,16 +75,17 @@ function Tooltip({ shortcut, content: tooltip, delay = 50, ...rest }: Props) {
|
||||
|
||||
const Shortcut = styled.kbd`
|
||||
position: relative;
|
||||
top: -2px;
|
||||
top: -1px;
|
||||
|
||||
margin-left: 2px;
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
font: 10px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
font-family: ${s("fontFamilyMono")};
|
||||
line-height: 10px;
|
||||
color: ${s("tooltipBackground")};
|
||||
color: ${s("tooltipText")};
|
||||
border: 1px solid ${(props) => transparentize(0.75, props.theme.tooltipText)};
|
||||
vertical-align: middle;
|
||||
background-color: ${s("tooltipText")};
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
@@ -132,7 +164,7 @@ export const TooltipStyles = createGlobalStyle`
|
||||
padding:5px 9px;
|
||||
z-index:1
|
||||
}
|
||||
|
||||
|
||||
/* Arrow Styles */
|
||||
.tippy-box[data-placement^=top]>.tippy-svg-arrow{
|
||||
bottom:0
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Tippy, { useSingleton, TippyProps } from "@tippyjs/react";
|
||||
import * as React from "react";
|
||||
import { roundArrow } from "tippy.js";
|
||||
|
||||
export const TooltipContext =
|
||||
React.createContext<TippyProps["singleton"]>(undefined);
|
||||
|
||||
export function useTooltipContext() {
|
||||
return React.useContext(TooltipContext);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
/** Props to pass to the Tippy component */
|
||||
tippyProps?: TippyProps;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap a collection of tooltips in a provider to allow them to share the same singleton instance.
|
||||
*/
|
||||
export function TooltipProvider({ children, tippyProps }: Props) {
|
||||
const [source, target] = useSingleton();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tippy
|
||||
delay={500}
|
||||
arrow={roundArrow}
|
||||
animation="shift-away"
|
||||
singleton={source}
|
||||
duration={[200, 150]}
|
||||
inertia
|
||||
{...tippyProps}
|
||||
/>
|
||||
<TooltipContext.Provider value={target}>
|
||||
{children}
|
||||
</TooltipContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input from "~/components/Input";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
@@ -85,7 +89,11 @@ export function UserSuspendDialog({ user, onSubmit }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog onSubmit={handleSubmit} savingText={`${t("Saving")}…`}>
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
savingText={`${t("Saving")}…`}
|
||||
danger
|
||||
>
|
||||
{t(
|
||||
"Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.",
|
||||
{
|
||||
@@ -123,6 +131,68 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) {
|
||||
onChange={handleChange}
|
||||
error={!name ? t("Name can't be empty") : undefined}
|
||||
value={name}
|
||||
autoSelect
|
||||
required
|
||||
flex
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserChangeEmailDialog({ user, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const actor = useCurrentUser();
|
||||
const [email, setEmail] = React.useState<string>(user.email);
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await client.post(`/users.updateEmail`, { id: user.id, email });
|
||||
onSubmit();
|
||||
toast.info(
|
||||
actor.id === user.id
|
||||
? t("Check your email to verify the new address.")
|
||||
: t("The email will be changed once verified.")
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(ev.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Save")}
|
||||
savingText={`${t("Saving")}…`}
|
||||
disabled={!email || email === user.email}
|
||||
>
|
||||
<Text as="p">
|
||||
{actor.id === user.id ? (
|
||||
<Trans>
|
||||
You will receive an email to verify your new address. It must be
|
||||
unique in the workspace.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
A confirmation email will be sent to the new address before it is
|
||||
changed.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
label={t("New email")}
|
||||
onChange={handleChange}
|
||||
error={!email ? t("Email can't be empty") : error}
|
||||
value={email}
|
||||
autoSelect
|
||||
required
|
||||
flex
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState } from "reakit/Popover";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { altDisplay, isModKey, metaDisplay } from "@shared/utils/keyboard";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
@@ -21,14 +22,21 @@ import Tooltip from "~/components/Tooltip";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { altDisplay, isModKey, metaDisplay } from "~/utils/keyboard";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type Props = {
|
||||
/** Whether the find and replace popover is open */
|
||||
open: boolean;
|
||||
/** Callback when the find and replace popover is opened */
|
||||
onOpen: () => void;
|
||||
/** Callback when the find and replace popover is closed */
|
||||
onClose: () => void;
|
||||
/** Whether the editor is in read-only mode */
|
||||
readOnly?: boolean;
|
||||
/** The current highlighted index in the search results */
|
||||
currentIndex: number;
|
||||
/** The total number of search results */
|
||||
totalResults: number;
|
||||
};
|
||||
|
||||
export default function FindAndReplace({
|
||||
@@ -36,6 +44,8 @@ export default function FindAndReplace({
|
||||
open,
|
||||
onOpen,
|
||||
onClose,
|
||||
currentIndex,
|
||||
totalResults,
|
||||
}: Props) {
|
||||
const editor = useEditor();
|
||||
const finalFocusRef = React.useRef<HTMLElement>(
|
||||
@@ -270,25 +280,26 @@ export default function FindAndReplace({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [popover.visible]);
|
||||
|
||||
const disabled = totalResults === 0;
|
||||
const navigation = (
|
||||
<>
|
||||
<Tooltip
|
||||
content={t("Previous match")}
|
||||
shortcut="shift+enter"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge onClick={() => editor.commands.prevSearchMatch()}>
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.prevSearchMatch()}
|
||||
>
|
||||
<CaretUpIcon />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={t("Next match")}
|
||||
shortcut="enter"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge onClick={() => editor.commands.nextSearchMatch()}>
|
||||
<Tooltip content={t("Next match")} shortcut="enter" placement="bottom">
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.nextSearchMatch()}
|
||||
>
|
||||
<CaretDownIcon />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
@@ -303,10 +314,11 @@ export default function FindAndReplace({
|
||||
style={style}
|
||||
aria-label={t("Find and replace")}
|
||||
scrollable={false}
|
||||
width={420}
|
||||
minWidth={420}
|
||||
width={0}
|
||||
>
|
||||
<Content column>
|
||||
<Flex gap={8}>
|
||||
<Flex gap={4}>
|
||||
<StyledInput
|
||||
ref={inputRef}
|
||||
maxLength={255}
|
||||
@@ -319,7 +331,6 @@ export default function FindAndReplace({
|
||||
<Tooltip
|
||||
content={t("Match case")}
|
||||
shortcut={`${altDisplay}+${metaDisplay}+c`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall onClick={handleCaseSensitive}>
|
||||
@@ -331,7 +342,6 @@ export default function FindAndReplace({
|
||||
<Tooltip
|
||||
content={t("Enable regex")}
|
||||
shortcut={`${altDisplay}+${metaDisplay}+r`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonSmall onClick={handleRegex}>
|
||||
@@ -344,16 +354,15 @@ export default function FindAndReplace({
|
||||
</StyledInput>
|
||||
{navigation}
|
||||
{!readOnly && (
|
||||
<Tooltip
|
||||
content={t("Replace options")}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Tooltip content={t("Replace options")} placement="bottom">
|
||||
<ButtonLarge onClick={handleMore}>
|
||||
<ReplaceIcon color={theme.textSecondary} />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Results>
|
||||
{totalResults > 0 ? currentIndex + 1 : 0} / {totalResults}
|
||||
</Results>
|
||||
</Flex>
|
||||
<ResizingHeightContainer>
|
||||
{showReplace && !readOnly && (
|
||||
@@ -367,10 +376,10 @@ export default function FindAndReplace({
|
||||
onRequestSubmit={handleReplaceAll}
|
||||
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
|
||||
/>
|
||||
<Button onClick={handleReplace} neutral>
|
||||
<Button onClick={handleReplace} disabled={disabled} neutral>
|
||||
{t("Replace")}
|
||||
</Button>
|
||||
<Button onClick={handleReplaceAll} neutral>
|
||||
<Button onClick={handleReplaceAll} disabled={disabled} neutral>
|
||||
{t("Replace all")}
|
||||
</Button>
|
||||
</Flex>
|
||||
@@ -396,6 +405,12 @@ const ButtonSmall = styled(NudeButton)`
|
||||
&[aria-expanded="true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${s("textTertiary")};
|
||||
background: none;
|
||||
cursor: default;
|
||||
}
|
||||
`;
|
||||
|
||||
const ButtonLarge = styled(ButtonSmall)`
|
||||
@@ -408,3 +423,15 @@ const Content = styled(Flex)`
|
||||
margin-bottom: -16px;
|
||||
position: static;
|
||||
`;
|
||||
|
||||
const Results = styled.span`
|
||||
color: ${s("textSecondary")};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 32px;
|
||||
min-width: 32px;
|
||||
letter-spacing: -0.5px;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
@@ -13,10 +13,10 @@ import Flex from "~/components/Flex";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import MentionMenuItem from "./MentionMenuItem";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
interface MentionItem extends MenuItem {
|
||||
name: string;
|
||||
@@ -122,11 +122,10 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<MentionMenuItem
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
label={item.attrs.label}
|
||||
icon={
|
||||
<Flex
|
||||
align="center"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as React from "react";
|
||||
import SuggestionsMenuItem, {
|
||||
Props as SuggestionsMenuItemProps,
|
||||
} from "./SuggestionsMenuItem";
|
||||
|
||||
type MentionMenuItemProps = Omit<
|
||||
SuggestionsMenuItemProps,
|
||||
"shortcut" | "theme"
|
||||
> & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export default function MentionMenuItem({
|
||||
label,
|
||||
...rest
|
||||
}: MentionMenuItemProps) {
|
||||
return <SuggestionsMenuItem {...rest} title={label} />;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export type Props = {
|
||||
icon?: React.ReactElement;
|
||||
/** The title of the item */
|
||||
title: React.ReactNode;
|
||||
/** An optional subtitle for the item */
|
||||
subtitle?: React.ReactNode;
|
||||
/** A string representing the keyboard shortcut for the item */
|
||||
shortcut?: string;
|
||||
};
|
||||
@@ -24,6 +26,7 @@ function SuggestionsMenuItem({
|
||||
disabled,
|
||||
onClick,
|
||||
title,
|
||||
subtitle,
|
||||
shortcut,
|
||||
icon,
|
||||
}: Props) {
|
||||
@@ -53,11 +56,17 @@ function SuggestionsMenuItem({
|
||||
icon={icon}
|
||||
>
|
||||
{title}
|
||||
{subtitle && <Subtitle $active={selected}>{subtitle}</Subtitle>}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const Subtitle = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
const Shortcut = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TippyProps } from "@tippyjs/react";
|
||||
import * as React from "react";
|
||||
import { useMenuState } from "reakit";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
@@ -7,6 +8,7 @@ import { MenuItem } from "@shared/editor/types";
|
||||
import { s } from "@shared/styles";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { TooltipProvider } from "~/components/TooltipContext";
|
||||
import { MenuItem as TMenuItem } from "~/types";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
@@ -75,6 +77,8 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
const tippyProps = { placement: "top" } as TippyProps;
|
||||
|
||||
function ToolbarMenu(props: Props) {
|
||||
const { commands, view } = useEditor();
|
||||
const { items } = props;
|
||||
@@ -91,36 +95,39 @@ function ToolbarMenu(props: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<FlexibleWrapper>
|
||||
{items.map((item, index) => {
|
||||
if (item.name === "separator" && item.visible !== false) {
|
||||
return <ToolbarSeparator key={index} />;
|
||||
}
|
||||
if (item.visible === false || !item.icon) {
|
||||
return null;
|
||||
}
|
||||
const isActive = item.active ? item.active(state) : false;
|
||||
<TooltipProvider tippyProps={tippyProps}>
|
||||
<FlexibleWrapper>
|
||||
{items.map((item, index) => {
|
||||
if (item.name === "separator" && item.visible !== false) {
|
||||
return <ToolbarSeparator key={index} />;
|
||||
}
|
||||
if (item.visible === false || !item.icon) {
|
||||
return null;
|
||||
}
|
||||
const isActive = item.active ? item.active(state) : false;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={item.label === item.tooltip ? undefined : item.tooltip}
|
||||
key={index}
|
||||
>
|
||||
{item.children ? (
|
||||
<ToolbarDropdown active={isActive && !item.label} item={item} />
|
||||
) : (
|
||||
<ToolbarButton
|
||||
onClick={handleClick(item)}
|
||||
active={isActive && !item.label}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</FlexibleWrapper>
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
shortcut={item.shortcut}
|
||||
content={item.label === item.tooltip ? undefined : item.tooltip}
|
||||
>
|
||||
{item.children ? (
|
||||
<ToolbarDropdown active={isActive && !item.label} item={item} />
|
||||
) : (
|
||||
<ToolbarButton
|
||||
onClick={handleClick(item)}
|
||||
active={isActive && !item.label}
|
||||
>
|
||||
{item.label && <Label>{item.label}</Label>}
|
||||
{item.icon}
|
||||
</ToolbarButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</FlexibleWrapper>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Tooltip, { Props } from "~/components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
/** The content to display in the tooltip. */
|
||||
content?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const WrappedTooltip: React.FC<Props> = ({ children, content }: Props) => (
|
||||
<Tooltip offset={[0, 16]} delay={150} content={content} placement="top">
|
||||
const WrappedTooltip: React.FC<Props> = ({
|
||||
children,
|
||||
content,
|
||||
...rest
|
||||
}: Props) => (
|
||||
<Tooltip
|
||||
offset={[0, 16]}
|
||||
delay={150}
|
||||
content={content}
|
||||
placement="top"
|
||||
shortcutOnNewline
|
||||
{...rest}
|
||||
>
|
||||
<TooltipContent>{children}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { action } from "mobx";
|
||||
import * as React from "react";
|
||||
import { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import { isBrowser } from "@shared/utils/browser";
|
||||
import Suggestion from "~/editor/extensions/Suggestion";
|
||||
import EmojiMenu from "../components/EmojiMenu";
|
||||
|
||||
@@ -13,10 +14,9 @@ const languagesUsingColon = ["fr"];
|
||||
|
||||
export default class EmojiMenuExtension extends Suggestion {
|
||||
get defaultOptions() {
|
||||
const languageIsUsingColon =
|
||||
typeof window === "undefined"
|
||||
? false
|
||||
: languagesUsingColon.includes(window.navigator.language.slice(0, 2));
|
||||
const languageIsUsingColon = isBrowser
|
||||
? languagesUsingColon.includes(window.navigator.language.slice(0, 2))
|
||||
: false;
|
||||
|
||||
return {
|
||||
openRegex: new RegExp(
|
||||
|
||||
@@ -332,6 +332,8 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
|
||||
public widget = ({ readOnly }: WidgetProps) => (
|
||||
<FindAndReplace
|
||||
currentIndex={this.currentResultIndex}
|
||||
totalResults={this.results.length}
|
||||
readOnly={readOnly}
|
||||
open={this.open}
|
||||
onOpen={() => {
|
||||
@@ -346,7 +348,11 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
@observable
|
||||
private open = false;
|
||||
|
||||
@observable
|
||||
private results: { from: number; to: number }[] = [];
|
||||
|
||||
@observable
|
||||
private currentResultIndex = 0;
|
||||
|
||||
private searchTerm = "";
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Image from "@shared/editor/components/Img";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
|
||||
const Img = styled(Image)`
|
||||
border-radius: 2px;
|
||||
|
||||
@@ -28,6 +28,7 @@ import { isInList } from "@shared/editor/queries/isInList";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
@@ -63,6 +64,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "strong",
|
||||
tooltip: dictionary.strong,
|
||||
shortcut: `${metaDisplay}+B`,
|
||||
icon: <BoldIcon />,
|
||||
active: isMarkActive(schema.marks.strong),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
@@ -70,6 +72,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "em",
|
||||
tooltip: dictionary.em,
|
||||
shortcut: `${metaDisplay}+I`,
|
||||
icon: <ItalicIcon />,
|
||||
active: isMarkActive(schema.marks.em),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
@@ -77,12 +80,14 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "strikethrough",
|
||||
tooltip: dictionary.strikethrough,
|
||||
shortcut: `${metaDisplay}+D`,
|
||||
icon: <StrikethroughIcon />,
|
||||
active: isMarkActive(schema.marks.strikethrough),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.mark,
|
||||
shortcut: `${metaDisplay}+Ctrl+H`,
|
||||
icon: highlight ? (
|
||||
<CircleIcon color={highlight.mark.attrs.color || Highlight.colors[0]} />
|
||||
) : (
|
||||
@@ -114,6 +119,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "code_inline",
|
||||
tooltip: dictionary.codeInline,
|
||||
shortcut: `${metaDisplay}+E`,
|
||||
icon: <CodeIcon />,
|
||||
active: isMarkActive(schema.marks.code_inline),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
@@ -125,6 +131,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "heading",
|
||||
tooltip: dictionary.heading,
|
||||
shortcut: `⇧+Ctrl+1`,
|
||||
icon: <Heading1Icon />,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 1 }),
|
||||
attrs: { level: 1 },
|
||||
@@ -133,6 +140,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "heading",
|
||||
tooltip: dictionary.subheading,
|
||||
shortcut: `⇧+Ctrl+2`,
|
||||
icon: <Heading2Icon />,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 2 }),
|
||||
attrs: { level: 2 },
|
||||
@@ -141,6 +149,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "heading",
|
||||
tooltip: dictionary.subheading,
|
||||
shortcut: `⇧+Ctrl+3`,
|
||||
icon: <Heading3Icon />,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 3 }),
|
||||
attrs: { level: 3 },
|
||||
@@ -149,6 +158,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "blockquote",
|
||||
tooltip: dictionary.quote,
|
||||
shortcut: `${metaDisplay}+]`,
|
||||
icon: <BlockQuoteIcon />,
|
||||
active: isNodeActive(schema.nodes.blockquote),
|
||||
attrs: { level: 2 },
|
||||
@@ -161,6 +171,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "checkbox_list",
|
||||
tooltip: dictionary.checkboxList,
|
||||
shortcut: `⇧+Ctrl+7`,
|
||||
icon: <TodoListIcon />,
|
||||
keywords: "checklist checkbox task",
|
||||
active: isNodeActive(schema.nodes.checkbox_list),
|
||||
@@ -169,6 +180,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "bullet_list",
|
||||
tooltip: dictionary.bulletList,
|
||||
shortcut: `⇧+Ctrl+8`,
|
||||
icon: <BulletedListIcon />,
|
||||
active: isNodeActive(schema.nodes.bullet_list),
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
@@ -176,6 +188,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "ordered_list",
|
||||
tooltip: dictionary.orderedList,
|
||||
shortcut: `⇧+Ctrl+9`,
|
||||
icon: <OrderedListIcon />,
|
||||
active: isNodeActive(schema.nodes.ordered_list),
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
@@ -183,6 +196,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "outdentList",
|
||||
tooltip: dictionary.outdent,
|
||||
shortcut: `⇧+Tab`,
|
||||
icon: <OutdentIcon />,
|
||||
visible:
|
||||
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
@@ -190,6 +204,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "indentList",
|
||||
tooltip: dictionary.indent,
|
||||
shortcut: `Tab`,
|
||||
icon: <IndentIcon />,
|
||||
visible:
|
||||
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
@@ -197,12 +212,14 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "outdentCheckboxList",
|
||||
tooltip: dictionary.outdent,
|
||||
shortcut: `⇧+Tab`,
|
||||
icon: <OutdentIcon />,
|
||||
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
{
|
||||
name: "indentCheckboxList",
|
||||
tooltip: dictionary.indent,
|
||||
shortcut: `Tab`,
|
||||
icon: <IndentIcon />,
|
||||
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
@@ -213,6 +230,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "link",
|
||||
tooltip: dictionary.createLink,
|
||||
shortcut: `${metaDisplay}+K`,
|
||||
icon: <LinkIcon />,
|
||||
attrs: { href: "" },
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
@@ -220,9 +238,14 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "comment",
|
||||
tooltip: dictionary.comment,
|
||||
shortcut: `${metaDisplay}+⌥+M`,
|
||||
icon: <CommentIcon />,
|
||||
label: isCodeBlock ? dictionary.comment : undefined,
|
||||
active: isMarkActive(schema.marks.comment, { resolved: false }),
|
||||
active: isMarkActive(
|
||||
schema.marks.comment,
|
||||
{ resolved: false },
|
||||
{ exact: true }
|
||||
),
|
||||
visible: !isMobile || !isEmpty,
|
||||
},
|
||||
{
|
||||
@@ -233,6 +256,7 @@ export default function formattingMenuItems(
|
||||
name: "copyToClipboard",
|
||||
icon: <CopyIcon />,
|
||||
tooltip: dictionary.copy,
|
||||
shortcut: `${metaDisplay}+C`,
|
||||
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
}
|
||||
|
||||
if (!isModKey(event) && !event.shiftKey) {
|
||||
history.push(navigateTo);
|
||||
history.push(navigateTo, { sidebarContext: "collections" }); // optimistic preference of "collections"
|
||||
} else {
|
||||
window.open(navigateTo, "_blank");
|
||||
}
|
||||
|
||||
@@ -45,12 +45,16 @@ export default function useImportDocument(
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const doc = await documents.import(file, documentId, cId, {
|
||||
publish: true,
|
||||
});
|
||||
try {
|
||||
const doc = await documents.import(file, documentId, cId, {
|
||||
publish: true,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
history.push(documentPath(doc));
|
||||
if (redirect) {
|
||||
history.push(documentPath(doc));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import isTextInput from "~/utils/isTextInput";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
type Callback = (event: KeyboardEvent) => void;
|
||||
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { SidebarContextType } from "../components/SidebarContext";
|
||||
import { SidebarContextType } from "../components/Sidebar/components/SidebarContext";
|
||||
|
||||
/**
|
||||
* Hook to retrieve the sidebar context from the current location state.
|
||||
*/
|
||||
export function useLocationState() {
|
||||
export function useLocationSidebarContext() {
|
||||
const location = useLocation<{
|
||||
sidebarContext?: SidebarContextType;
|
||||
}>();
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { isBrowser } from "@shared/utils/browser";
|
||||
import Logger from "~/utils/Logger";
|
||||
import useEventListener from "./useEventListener";
|
||||
|
||||
@@ -41,7 +42,7 @@ export default function usePersistedState<T extends Primitive | object>(
|
||||
options?: Options
|
||||
): [T, (value: T) => void] {
|
||||
const [storedValue, setStoredValue] = React.useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
if (!isBrowser) {
|
||||
return defaultValue;
|
||||
}
|
||||
return Storage.get(key) ?? defaultValue;
|
||||
|
||||
@@ -123,7 +123,6 @@ function CollectionMenu({
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
ev.target.value = "";
|
||||
}
|
||||
|
||||
@@ -136,14 +136,14 @@ type MenuContentProps = {
|
||||
showToggleEmbeds?: boolean;
|
||||
};
|
||||
|
||||
const MenuContent: React.FC<MenuContentProps> = ({
|
||||
const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||||
onOpen,
|
||||
onClose,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
showDisplayOptions,
|
||||
showToggleEmbeds,
|
||||
}) => {
|
||||
}) {
|
||||
const user = useCurrentUser();
|
||||
const { model: document, menuState } = useMenuContext<Document>();
|
||||
const can = usePolicy(document);
|
||||
@@ -348,7 +348,7 @@ const MenuContent: React.FC<MenuContentProps> = ({
|
||||
)}
|
||||
</ContextMenu>
|
||||
) : null;
|
||||
};
|
||||
});
|
||||
|
||||
function DocumentMenu({
|
||||
document,
|
||||
|
||||
@@ -19,12 +19,7 @@ function NewDocumentMenu() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Tooltip content={t("New document")} shortcut="n" placement="bottom">
|
||||
<Button as={Link} to={newDocumentPath()} icon={<PlusIcon />}>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import { DocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
@@ -14,11 +14,15 @@ import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
type Props = {
|
||||
/** The document to which the templates will be applied */
|
||||
document: Document;
|
||||
/** Whether to render the button as a compact icon */
|
||||
isCompact?: boolean;
|
||||
/** Callback to handle when a template is selected */
|
||||
onSelectTemplate: (template: Document) => void;
|
||||
};
|
||||
|
||||
function TemplatesMenu({ onSelectTemplate, document }: Props) {
|
||||
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
@@ -79,8 +83,13 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button {...props} disclosure neutral>
|
||||
{t("Templates")}
|
||||
<Button
|
||||
{...props}
|
||||
icon={isCompact ? <ShapesIcon /> : undefined}
|
||||
disclosure={!isCompact}
|
||||
neutral
|
||||
>
|
||||
{isCompact ? undefined : t("Templates")}
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
|
||||
+25
-1
@@ -11,6 +11,7 @@ import Template from "~/components/ContextMenu/Template";
|
||||
import {
|
||||
UserSuspendDialog,
|
||||
UserChangeNameDialog,
|
||||
UserChangeEmailDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import {
|
||||
@@ -49,6 +50,22 @@ function UserMenu({ user }: Props) {
|
||||
[dialogs, t, user]
|
||||
);
|
||||
|
||||
const handleChangeEmail = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog
|
||||
user={user}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
[dialogs, t, user]
|
||||
);
|
||||
|
||||
const handleSuspend = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
@@ -117,7 +134,13 @@ function UserMenu({ user }: Props) {
|
||||
type: "button",
|
||||
title: `${t("Change name")}…`,
|
||||
onClick: handleChangeName,
|
||||
visible: can.update && user.role !== "admin",
|
||||
visible: can.update,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Change email")}…`,
|
||||
onClick: handleChangeEmail,
|
||||
visible: can.update,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
@@ -144,6 +167,7 @@ function UserMenu({ user }: Props) {
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Suspend user")}…`,
|
||||
dangerous: true,
|
||||
onClick: handleSuspend,
|
||||
visible: !user.isInvited && !user.isSuspended,
|
||||
},
|
||||
|
||||
@@ -10,6 +10,9 @@ class Group extends Model {
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
@observable
|
||||
externalId: string | undefined;
|
||||
|
||||
@observable
|
||||
memberCount: number;
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ class Notification extends Model {
|
||||
return t("mentioned you in");
|
||||
case NotificationEventType.CreateComment:
|
||||
return t("left a comment on");
|
||||
case NotificationEventType.ResolveComment:
|
||||
return t("resolved a comment on");
|
||||
case NotificationEventType.AddUserToDocument:
|
||||
return t("shared");
|
||||
case NotificationEventType.AddUserToCollection:
|
||||
@@ -170,6 +172,7 @@ class Notification extends Model {
|
||||
return this.document?.path;
|
||||
}
|
||||
case NotificationEventType.MentionedInComment:
|
||||
case NotificationEventType.ResolveComment:
|
||||
case NotificationEventType.CreateComment: {
|
||||
return this.document && this.comment
|
||||
? commentPath(this.document, this.comment)
|
||||
|
||||
@@ -27,7 +27,6 @@ function Actions({ collection }: Props) {
|
||||
<Tooltip
|
||||
content={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -316,7 +316,7 @@ function CommentForm({
|
||||
{t("Cancel")}
|
||||
</ButtonSmall>
|
||||
</Flex>
|
||||
<Tooltip delay={500} content={t("Upload image")} placement="top">
|
||||
<Tooltip content={t("Upload image")} placement="top">
|
||||
<NudeButton onClick={handleImageUpload}>
|
||||
<ImageIcon color={theme.textTertiary} />
|
||||
</NudeButton>
|
||||
|
||||
@@ -7,12 +7,14 @@ import { s } from "@shared/styles";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import { CommentSortType } from "~/types";
|
||||
|
||||
const CommentSortMenu = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const history = useHistory();
|
||||
const user = useCurrentUser();
|
||||
const params = useQuery();
|
||||
@@ -39,6 +41,7 @@ const CommentSortMenu = () => {
|
||||
resolved: "",
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -49,6 +52,7 @@ const CommentSortMenu = () => {
|
||||
resolved: undefined,
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -61,6 +62,7 @@ function CommentThread({
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
||||
|
||||
const can = usePolicy(document);
|
||||
@@ -101,7 +103,10 @@ function CommentThread({
|
||||
history.replace({
|
||||
search: location.search,
|
||||
pathname: location.pathname,
|
||||
state: { commentId: undefined },
|
||||
state: {
|
||||
commentId: undefined,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -115,7 +120,10 @@ function CommentThread({
|
||||
// Clear any commentId from the URL when explicitly focusing a thread
|
||||
search: thread.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId: thread.id },
|
||||
state: {
|
||||
commentId: thread.id,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -197,21 +197,12 @@ function CommentThreadItem({
|
||||
{showAuthor && <em>{comment.createdBy.name}</em>}
|
||||
{showAuthor && showTime && <> · </>}
|
||||
{showTime && (
|
||||
<Time
|
||||
dateTime={comment.createdAt}
|
||||
tooltipDelay={500}
|
||||
addSuffix
|
||||
shorten
|
||||
/>
|
||||
<Time dateTime={comment.createdAt} addSuffix shorten />
|
||||
)}
|
||||
{showEdited && (
|
||||
<>
|
||||
{" "}
|
||||
(
|
||||
<Time dateTime={comment.updatedAt} tooltipDelay={500}>
|
||||
{t("edited")}
|
||||
</Time>
|
||||
)
|
||||
(<Time dateTime={comment.updatedAt}>{t("edited")}</Time>)
|
||||
</>
|
||||
)}
|
||||
</Meta>
|
||||
@@ -304,12 +295,7 @@ const ResolveButton = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("Mark as resolved")}
|
||||
placement="top"
|
||||
delay={500}
|
||||
hideOnClick
|
||||
>
|
||||
<Tooltip content={t("Mark as resolved")} placement="top" hideOnClick>
|
||||
<Action
|
||||
as={NudeButton}
|
||||
context={context}
|
||||
|
||||
@@ -94,14 +94,18 @@ function Comments() {
|
||||
React.useEffect(() => {
|
||||
// Handles: 1. on refresh 2. when switching sort setting
|
||||
const readyToDisplay = Boolean(document && isEditorInitialized);
|
||||
if (readyToDisplay && sortOption.type === CommentSortType.MostRecent) {
|
||||
if (
|
||||
readyToDisplay &&
|
||||
sortOption.type === CommentSortType.MostRecent &&
|
||||
!viewingResolved
|
||||
) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [sortOption.type, document, isEditorInitialized]);
|
||||
}, [sortOption.type, document, isEditorInitialized, viewingResolved]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setShowJumpToRecentBtn(false);
|
||||
if (sortOption.type === CommentSortType.MostRecent) {
|
||||
if (sortOption.type === CommentSortType.MostRecent && !viewingResolved) {
|
||||
const commentsAdded = threads.length > prevThreadCount.current;
|
||||
if (commentsAdded) {
|
||||
if (isAtBottom.current) {
|
||||
@@ -112,7 +116,7 @@ function Comments() {
|
||||
}
|
||||
}
|
||||
prevThreadCount.current = threads.length;
|
||||
}, [sortOption.type, threads.length]);
|
||||
}, [sortOption.type, threads.length, viewingResolved]);
|
||||
|
||||
if (!document || !isEditorInitialized) {
|
||||
return null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import history from "~/utils/history";
|
||||
import { matchDocumentEdit, settingsPath } from "~/utils/routeHelpers";
|
||||
import Loading from "./Loading";
|
||||
import MarkAsViewed from "./MarkAsViewed";
|
||||
|
||||
type Params = {
|
||||
/** The document urlId + slugified title */
|
||||
@@ -222,16 +223,19 @@ function DataLoader({ match, children }: Props) {
|
||||
const readOnly = !isEditing || !canEdit;
|
||||
|
||||
return (
|
||||
<React.Fragment key={canEdit ? "edit" : "read"}>
|
||||
{children({
|
||||
document,
|
||||
revision,
|
||||
abilities: can,
|
||||
readOnly,
|
||||
onCreateLink,
|
||||
sharedTree,
|
||||
})}
|
||||
</React.Fragment>
|
||||
<>
|
||||
{!shareId && !revision && <MarkAsViewed document={document} />}
|
||||
<React.Fragment key={canEdit ? "edit" : "read"}>
|
||||
{children({
|
||||
document,
|
||||
revision,
|
||||
abilities: can,
|
||||
readOnly,
|
||||
onCreateLink,
|
||||
sharedTree,
|
||||
})}
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
@@ -41,12 +42,12 @@ import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
|
||||
import withStores from "~/components/withStores";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import { SearchResult } from "~/editor/components/LinkEditor";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { emojiToUrl } from "~/utils/emoji";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
import {
|
||||
documentHistoryPath,
|
||||
@@ -58,7 +59,6 @@ import Contents from "./Contents";
|
||||
import Editor from "./Editor";
|
||||
import Header from "./Header";
|
||||
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
|
||||
import MarkAsViewed from "./MarkAsViewed";
|
||||
import { MeasuredContainer } from "./MeasuredContainer";
|
||||
import Notices from "./Notices";
|
||||
import PublicReferences from "./PublicReferences";
|
||||
@@ -77,6 +77,7 @@ type LocationState = {
|
||||
title?: string;
|
||||
restore?: boolean;
|
||||
revisionId?: string;
|
||||
sidebarContext?: SidebarContextType;
|
||||
};
|
||||
|
||||
type Props = WithTranslation &
|
||||
@@ -252,7 +253,10 @@ class DocumentScene extends React.Component<Props> {
|
||||
const { document, abilities } = this.props;
|
||||
|
||||
if (abilities.update) {
|
||||
this.props.history.push(documentEditPath(document));
|
||||
this.props.history.push({
|
||||
pathname: documentEditPath(document),
|
||||
state: { sidebarContext: this.props.location.state?.sidebarContext },
|
||||
});
|
||||
}
|
||||
} else if (this.editor.current?.isBlurred) {
|
||||
ev.preventDefault();
|
||||
@@ -271,9 +275,15 @@ class DocumentScene extends React.Component<Props> {
|
||||
const { document, location } = this.props;
|
||||
|
||||
if (location.pathname.endsWith("history")) {
|
||||
this.props.history.push(document.url);
|
||||
this.props.history.push({
|
||||
pathname: document.url,
|
||||
state: { sidebarContext: this.props.location.state?.sidebarContext },
|
||||
});
|
||||
} else {
|
||||
this.props.history.push(documentHistoryPath(document));
|
||||
this.props.history.push({
|
||||
pathname: documentHistoryPath(document),
|
||||
state: { sidebarContext: this.props.location.state?.sidebarContext },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -339,10 +349,16 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.isEditorDirty = false;
|
||||
|
||||
if (options.done) {
|
||||
this.props.history.push(savedDocument.url);
|
||||
this.props.history.push({
|
||||
pathname: savedDocument.url,
|
||||
state: { sidebarContext: this.props.location.state?.sidebarContext },
|
||||
});
|
||||
this.props.ui.setActiveDocument(savedDocument);
|
||||
} else if (document.isNew) {
|
||||
this.props.history.push(documentEditPath(savedDocument));
|
||||
this.props.history.push({
|
||||
pathname: documentEditPath(savedDocument),
|
||||
state: { sidebarContext: this.props.location.state?.sidebarContext },
|
||||
});
|
||||
this.props.ui.setActiveDocument(savedDocument);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -396,7 +412,10 @@ class DocumentScene extends React.Component<Props> {
|
||||
|
||||
goBack = () => {
|
||||
if (!this.props.readOnly) {
|
||||
this.props.history.push(this.props.document.url);
|
||||
this.props.history.push({
|
||||
pathname: this.props.document.url,
|
||||
state: { sidebarContext: this.props.location.state?.sidebarContext },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -563,7 +582,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
canUpdate={abilities.update}
|
||||
canComment={abilities.comment}
|
||||
>
|
||||
{shareId && (
|
||||
{shareId ? (
|
||||
<ReferencesWrapper>
|
||||
<PublicReferences
|
||||
shareId={shareId}
|
||||
@@ -571,15 +590,11 @@ class DocumentScene extends React.Component<Props> {
|
||||
sharedTree={this.props.sharedTree}
|
||||
/>
|
||||
</ReferencesWrapper>
|
||||
)}
|
||||
{!isShare && !revision && (
|
||||
<>
|
||||
<MarkAsViewed document={document} />
|
||||
<ReferencesWrapper>
|
||||
<References document={document} />
|
||||
</ReferencesWrapper>
|
||||
</>
|
||||
)}
|
||||
) : !revision ? (
|
||||
<ReferencesWrapper>
|
||||
<References document={document} />
|
||||
</ReferencesWrapper>
|
||||
) : null}
|
||||
</Editor>
|
||||
</MeasuredContainer>
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,7 @@ import Revision from "~/models/Revision";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Fade from "~/components/Fade";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath, documentInsightsPath } from "~/utils/routeHelpers";
|
||||
@@ -27,6 +28,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
const { views, comments, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const team = useCurrentTeam();
|
||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||
const totalViewers = documentViews.length;
|
||||
@@ -45,7 +47,10 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
<>
|
||||
•
|
||||
<CommentLink
|
||||
to={documentPath(document)}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
onClick={() => ui.toggleComments()}
|
||||
>
|
||||
<CommentIcon size={18} />
|
||||
@@ -62,9 +67,13 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
<Wrapper>
|
||||
•
|
||||
<Link
|
||||
to={
|
||||
match.url === insightsPath ? documentPath(document) : insightsPath
|
||||
}
|
||||
to={{
|
||||
pathname:
|
||||
match.url === insightsPath
|
||||
? documentPath(document)
|
||||
: insightsPath,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getCurrentDateTimeAsString,
|
||||
getCurrentTimeAsString,
|
||||
} from "@shared/utils/date";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
@@ -22,7 +23,6 @@ import Icon, { IconTitleWrapper } from "~/components/Icon";
|
||||
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import SmartText from "~/editor/extensions/SmartText";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -82,6 +83,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const params = useQuery();
|
||||
const {
|
||||
document,
|
||||
@@ -113,12 +115,15 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
history.replace({
|
||||
search: focusedComment.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname,
|
||||
state: { commentId: focusedComment.id },
|
||||
state: {
|
||||
commentId: focusedComment.id,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
}
|
||||
ui.set({ commentsExpanded: true });
|
||||
}
|
||||
}, [focusedComment, ui, document.id, history, params]);
|
||||
}, [focusedComment, ui, document.id, history, params, sidebarContext]);
|
||||
|
||||
// Save document when blurring title, but delay so that if clicking on a
|
||||
// button this is allowed to execute first.
|
||||
@@ -143,10 +148,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
(commentId: string) => {
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId },
|
||||
state: { commentId, sidebarContext },
|
||||
});
|
||||
},
|
||||
[history]
|
||||
[history, sidebarContext]
|
||||
);
|
||||
|
||||
// Create a Comment model in local store when a comment mark is created, this
|
||||
@@ -171,10 +176,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId },
|
||||
state: { commentId, sidebarContext },
|
||||
});
|
||||
},
|
||||
[comments, user?.id, props.id, history]
|
||||
[comments, user?.id, props.id, history, sidebarContext]
|
||||
);
|
||||
|
||||
// Soft delete the Comment model when associated mark is totally removed.
|
||||
@@ -238,11 +243,13 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
{!shareId && (
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
to={
|
||||
match.path === matchDocumentHistory
|
||||
? documentPath(document)
|
||||
: documentHistoryPath(document)
|
||||
}
|
||||
to={{
|
||||
pathname:
|
||||
match.path === matchDocumentHistory
|
||||
? documentPath(document)
|
||||
: documentHistoryPath(document),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
rtl={
|
||||
titleRef.current?.getComputedDirection() === "rtl" ? true : false
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
@@ -30,10 +31,12 @@ import { publishDocument } from "~/actions/definitions/documents";
|
||||
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
|
||||
import { restoreRevision } from "~/actions/definitions/revisions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useEditingFocus from "~/hooks/useEditingFocus";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -41,7 +44,6 @@ import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import NewChildDocumentMenu from "~/menus/NewChildDocumentMenu";
|
||||
import TableOfContentsMenu from "~/menus/TableOfContentsMenu";
|
||||
import TemplatesMenu from "~/menus/TemplatesMenu";
|
||||
import { altDisplay, metaDisplay } from "~/utils/keyboard";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import ObservingBanner from "./ObservingBanner";
|
||||
import PublicBreadcrumb from "./PublicBreadcrumb";
|
||||
@@ -86,11 +88,14 @@ function DocumentHeader({
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { resolvedTheme } = ui;
|
||||
const isMobile = useMobile();
|
||||
const isMobileMedia = useMobile();
|
||||
const isRevision = !!revision;
|
||||
const isEditingFocus = useEditingFocus();
|
||||
const { editor } = useDocumentContext();
|
||||
const { hasHeadings } = useDocumentContext();
|
||||
const { hasHeadings, editor } = useDocumentContext();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const ref = React.useRef<HTMLDivElement | null>(null);
|
||||
const size = useComponentSize(ref);
|
||||
const isMobile = isMobileMedia || size.width < 700;
|
||||
|
||||
// We cache this value for as long as the component is mounted so that if you
|
||||
// apply a template there is still the option to replace it until the user
|
||||
@@ -129,8 +134,7 @@ function DocumentHeader({
|
||||
? t("Show contents")
|
||||
: `${t("Show contents")} (${t("available when headings are added")})`
|
||||
}
|
||||
shortcut={`ctrl+${altDisplay}+h`}
|
||||
delay={250}
|
||||
shortcut={`Ctrl+${altDisplay}+h`}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
@@ -148,13 +152,15 @@ function DocumentHeader({
|
||||
noun: document.noun,
|
||||
})}
|
||||
shortcut="e"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
icon={<EditIcon />}
|
||||
to={documentEditPath(document)}
|
||||
to={{
|
||||
pathname: documentEditPath(document),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{isMobile ? null : t("Edit")}
|
||||
@@ -168,7 +174,6 @@ function DocumentHeader({
|
||||
content={
|
||||
resolvedTheme === "light" ? t("Switch to dark") : t("Switch to light")
|
||||
}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
@@ -194,6 +199,7 @@ function DocumentHeader({
|
||||
if (shareId) {
|
||||
return (
|
||||
<StyledHeader
|
||||
ref={ref}
|
||||
$hidden={isEditingFocus}
|
||||
title={
|
||||
<Flex gap={4}>
|
||||
@@ -230,6 +236,7 @@ function DocumentHeader({
|
||||
return (
|
||||
<>
|
||||
<StyledHeader
|
||||
ref={ref}
|
||||
$hidden={isEditingFocus}
|
||||
hasSidebar
|
||||
left={
|
||||
@@ -254,7 +261,7 @@ function DocumentHeader({
|
||||
{document.isArchived && <Badge>{t("Archived")}</Badge>}
|
||||
</Flex>
|
||||
}
|
||||
actions={
|
||||
actions={({ isCompact }) => (
|
||||
<>
|
||||
<ObservingBanner />
|
||||
|
||||
@@ -262,11 +269,15 @@ function DocumentHeader({
|
||||
<Status>{t("Saving")}…</Status>
|
||||
)}
|
||||
{!isDeleted && !isRevision && can.listViews && (
|
||||
<Collaborators document={document} />
|
||||
<Collaborators
|
||||
document={document}
|
||||
limit={isCompact ? 3 : undefined}
|
||||
/>
|
||||
)}
|
||||
{(isEditing || !user?.separateEditMode) && !isTemplate && isNew && (
|
||||
<Action>
|
||||
<TemplatesMenu
|
||||
isCompact={isCompact}
|
||||
document={document}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
@@ -282,7 +293,6 @@ function DocumentHeader({
|
||||
<Tooltip
|
||||
content={t("Save")}
|
||||
shortcut={`${metaDisplay}+enter`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
@@ -306,6 +316,7 @@ function DocumentHeader({
|
||||
{can.update &&
|
||||
can.createChildDocument &&
|
||||
!isRevision &&
|
||||
!isCompact &&
|
||||
!isMobile && (
|
||||
<Action>
|
||||
<NewChildDocumentMenu
|
||||
@@ -314,7 +325,6 @@ function DocumentHeader({
|
||||
<Tooltip
|
||||
content={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button icon={<PlusIcon />} {...props} neutral>
|
||||
@@ -327,11 +337,7 @@ function DocumentHeader({
|
||||
)}
|
||||
{revision && revision.createdAt !== document.updatedAt && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
content={t("Restore version")}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Tooltip content={t("Restore version")} placement="bottom">
|
||||
<Button
|
||||
action={restoreRevision}
|
||||
context={context}
|
||||
@@ -377,7 +383,7 @@ function DocumentHeader({
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import Event from "~/models/Event";
|
||||
import Empty from "~/components/Empty";
|
||||
import PaginatedEventList from "~/components/PaginatedEventList";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
@@ -20,6 +21,7 @@ function History() {
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
|
||||
const eventsInDocument = document
|
||||
@@ -28,7 +30,10 @@ function History() {
|
||||
|
||||
const onCloseHistory = () => {
|
||||
if (document) {
|
||||
history.push(documentPath(document));
|
||||
history.push({
|
||||
pathname: documentPath(document),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
} else {
|
||||
history.goBack();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import PaginatedList from "~/components/PaginatedList";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useTextSelection from "~/hooks/useTextSelection";
|
||||
@@ -28,6 +29,7 @@ function Insights() {
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const selectedText = useTextSelection();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const { editor } = useDocumentContext();
|
||||
@@ -38,7 +40,10 @@ function Insights() {
|
||||
|
||||
const onCloseInsights = () => {
|
||||
if (document) {
|
||||
history.push(documentPath(document));
|
||||
history.push({
|
||||
pathname: documentPath(document),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function KeyboardShortcutsButton() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Keyboard shortcuts")} shortcut="?" delay={500}>
|
||||
<Tooltip content={t("Keyboard shortcuts")} shortcut="?">
|
||||
<Button onClick={handleOpenKeyboardShortcuts} $hidden={isEditingFocus}>
|
||||
<KeyboardIcon />
|
||||
</Button>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { determineIconType } from "@shared/utils/icon";
|
||||
import Document from "~/models/Document";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
|
||||
import { hover } from "~/styles";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -17,6 +18,7 @@ type Props = {
|
||||
document: Document | NavigationNode;
|
||||
anchor?: string;
|
||||
showCollection?: boolean;
|
||||
sidebarContext?: SidebarContextType;
|
||||
};
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
@@ -57,6 +59,7 @@ function ReferenceListItem({
|
||||
showCollection,
|
||||
anchor,
|
||||
shareId,
|
||||
sidebarContext,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { icon, color } = document;
|
||||
@@ -73,6 +76,7 @@ function ReferenceListItem({
|
||||
hash: anchor ? `d-${anchor}` : undefined,
|
||||
state: {
|
||||
title: document.title,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
|
||||
@@ -5,8 +5,11 @@ import { useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import Fade from "~/components/Fade";
|
||||
import { determineSidebarContext } from "~/components/Sidebar/components/SidebarContext";
|
||||
import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ReferenceListItem from "./ReferenceListItem";
|
||||
|
||||
@@ -16,7 +19,9 @@ type Props = {
|
||||
|
||||
function References({ document }: Props) {
|
||||
const { collections, documents } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const location = useLocation();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
|
||||
React.useEffect(() => {
|
||||
void documents.fetchBacklinks(document.id);
|
||||
@@ -40,12 +45,24 @@ function References({ document }: Props) {
|
||||
<Component>
|
||||
<Tabs>
|
||||
{showChildDocuments && (
|
||||
<Tab to="#children" isActive={() => !isBacklinksTab}>
|
||||
<Tab
|
||||
to={{
|
||||
hash: "#children",
|
||||
state: { sidebarContext: locationSidebarContext },
|
||||
}}
|
||||
isActive={() => !isBacklinksTab}
|
||||
>
|
||||
<Trans>Documents</Trans>
|
||||
</Tab>
|
||||
)}
|
||||
{showBacklinks && (
|
||||
<Tab to="#backlinks" isActive={() => isBacklinksTab}>
|
||||
<Tab
|
||||
to={{
|
||||
hash: "#backlinks",
|
||||
state: { sidebarContext: locationSidebarContext },
|
||||
}}
|
||||
isActive={() => isBacklinksTab}
|
||||
>
|
||||
<Trans>Backlinks</Trans>
|
||||
</Tab>
|
||||
)}
|
||||
@@ -61,6 +78,11 @@ function References({ document }: Props) {
|
||||
showCollection={
|
||||
backlinkedDocument.collectionId !== document.collectionId
|
||||
}
|
||||
sidebarContext={determineSidebarContext({
|
||||
document: backlinkedDocument,
|
||||
user,
|
||||
currentContext: locationSidebarContext,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
@@ -76,6 +98,7 @@ function References({ document }: Props) {
|
||||
key={node.id}
|
||||
document={document || node}
|
||||
showCollection={false}
|
||||
sidebarContext={locationSidebarContext}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -32,7 +32,7 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
|
||||
<>
|
||||
<Header>
|
||||
<Title>{title}</Title>
|
||||
<Tooltip content={t("Close")} shortcut="Esc" delay={500}>
|
||||
<Tooltip content={t("Close")} shortcut="Esc">
|
||||
<Button
|
||||
icon={<ForwardIcon />}
|
||||
onClick={onClose}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { StaticContext } from "react-router";
|
||||
import { StaticContext, useHistory } from "react-router";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
|
||||
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DataLoader from "./components/DataLoader";
|
||||
@@ -16,12 +17,14 @@ type LocationState = {
|
||||
title?: string;
|
||||
restore?: boolean;
|
||||
revisionId?: string;
|
||||
sidebarContext?: SidebarContextType;
|
||||
};
|
||||
|
||||
type Props = RouteComponentProps<Params, StaticContext, LocationState>;
|
||||
|
||||
export default function DocumentScene(props: Props) {
|
||||
const { ui } = useStores();
|
||||
const history = useHistory();
|
||||
const { documentSlug, revisionId } = props.match.params;
|
||||
const currentPath = props.location.pathname;
|
||||
const [, setLastVisitedPath] = useLastVisitedPath();
|
||||
@@ -32,6 +35,16 @@ export default function DocumentScene(props: Props) {
|
||||
|
||||
React.useEffect(() => () => ui.clearActiveDocument(), [ui]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// When opening a document directly on app load, sidebarContext will not be set.
|
||||
if (!props.location.state?.sidebarContext) {
|
||||
history.replace({
|
||||
...props.location,
|
||||
state: { ...props.location.state, sidebarContext: "collections" }, // optimistic preference of "collections"
|
||||
});
|
||||
}
|
||||
}, [props.location, history]);
|
||||
|
||||
// the urlId portion of the url does not include the slugified title
|
||||
// we only want to force a re-mount of the document component when the
|
||||
// document changes, not when the title does so only this portion is used
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import { metaDisplay, altDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Key from "~/components/Key";
|
||||
import { metaDisplay, altDisplay } from "~/utils/keyboard";
|
||||
|
||||
function KeyboardShortcuts() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DocumentFilter(props: Props) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tooltip content={t("Remove document filter")} delay={350}>
|
||||
<Tooltip content={t("Remove document filter")}>
|
||||
<StyledButton onClick={props.onClick} icon={<CloseIcon />} neutral>
|
||||
{props.document.title}
|
||||
</StyledButton>
|
||||
|
||||
@@ -33,7 +33,7 @@ function RecentSearchListItem({ searchQuery }: Props) {
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
{searchQuery.query}
|
||||
<Tooltip content={t("Remove search")} delay={150}>
|
||||
<Tooltip content={t("Remove search")}>
|
||||
<RemoveButton
|
||||
aria-label={t("Remove search")}
|
||||
onClick={async (ev) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CollectionIcon,
|
||||
CommentIcon,
|
||||
DocumentIcon,
|
||||
DoneIcon,
|
||||
EditIcon,
|
||||
EmailIcon,
|
||||
PublishIcon,
|
||||
@@ -18,19 +19,22 @@ import { toast } from "sonner";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Notice from "~/components/Notice";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Notifications() {
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(team.id);
|
||||
|
||||
const options = [
|
||||
{
|
||||
@@ -65,6 +69,14 @@ function Notifications() {
|
||||
"Receive a notification when someone mentions you in a document or comment"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: NotificationEventType.ResolveComment,
|
||||
icon: <DoneIcon />,
|
||||
title: t("Resolved"),
|
||||
description: t(
|
||||
"Receive a notification when a comment thread you were involved in is resolved"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: NotificationEventType.CreateCollection,
|
||||
icon: <CollectionIcon />,
|
||||
@@ -152,17 +164,7 @@ function Notifications() {
|
||||
<Trans>Manage when and where you receive email notifications.</Trans>
|
||||
</Text>
|
||||
|
||||
{env.EMAIL_ENABLED ? (
|
||||
<SettingRow
|
||||
label={t("Email address")}
|
||||
name="email"
|
||||
description={t(
|
||||
"Your email address should be updated in your SSO provider."
|
||||
)}
|
||||
>
|
||||
<Input type="email" value={user.email} readOnly />
|
||||
</SettingRow>
|
||||
) : (
|
||||
{env.EMAIL_ENABLED && can.manage && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
The email integration is currently disabled. Please set the
|
||||
|
||||
@@ -8,14 +8,18 @@ import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { UserChangeEmailDialog } from "~/components/UserDialogs";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ImageInput from "./components/ImageInput";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
const Profile = () => {
|
||||
const user = useCurrentUser();
|
||||
const { dialogs } = useStores();
|
||||
const form = React.useRef<HTMLFormElement>(null);
|
||||
const [name, setName] = React.useState<string>(user.name || "");
|
||||
const [name, setName] = React.useState<string>(user.name);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (ev: React.SyntheticEvent) => {
|
||||
@@ -29,6 +33,15 @@ const Profile = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeEmail = () => {
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const handleNameChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(ev.target.value);
|
||||
};
|
||||
@@ -81,6 +94,17 @@ const Profile = () => {
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{env.EMAIL_ENABLED && (
|
||||
<SettingRow label={t("Email address")} name="email">
|
||||
<Input
|
||||
type="email"
|
||||
value={user.email}
|
||||
readOnly
|
||||
onClick={handleChangeEmail}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isSaving || !isValid}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* @param callback The callback to call inside the view transition.
|
||||
*/
|
||||
export const startViewTransition = (callback: UpdateCallback) => {
|
||||
export const startViewTransition = (callback: ViewTransitionUpdateCallback) => {
|
||||
if (self.document.startViewTransition) {
|
||||
self.document.startViewTransition(callback);
|
||||
} else {
|
||||
|
||||
+13
-13
@@ -59,7 +59,7 @@
|
||||
"@babel/plugin-transform-destructuring": "^7.24.8",
|
||||
"@babel/plugin-transform-regenerator": "^7.25.9",
|
||||
"@babel/preset-env": "^7.25.8",
|
||||
"@babel/preset-react": "^7.25.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^4.2.2",
|
||||
"@bull-board/koa": "^4.12.2",
|
||||
@@ -124,8 +124,8 @@
|
||||
"glob": "^8.1.0",
|
||||
"http-errors": "2.0.0",
|
||||
"i18next": "^22.5.1",
|
||||
"i18next-fs-backend": "^2.3.2",
|
||||
"i18next-http-backend": "^2.5.0",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"i18next-http-backend": "^2.7.1",
|
||||
"invariant": "^2.2.4",
|
||||
"ioredis": "^5.4.1",
|
||||
"is-printable-key-event": "^1.0.0",
|
||||
@@ -150,7 +150,7 @@
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-emoji": "^2.0.0",
|
||||
"mermaid": "11.4.0",
|
||||
"mermaid": "11.4.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
@@ -179,12 +179,12 @@
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.1",
|
||||
"prosemirror-model": "^1.23.0",
|
||||
"prosemirror-model": "^1.24.0",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.4.0",
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"prosemirror-view": "^1.36.0",
|
||||
"prosemirror-view": "^1.37.1",
|
||||
"query-string": "^7.1.3",
|
||||
"randomstring": "1.3.0",
|
||||
"rate-limiter-flexible": "^2.4.2",
|
||||
@@ -223,7 +223,7 @@
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"slug": "^5.3.0",
|
||||
"slugify": "^1.6.6",
|
||||
"socket.io": "^4.7.5",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"socket.io-redis": "^6.1.1",
|
||||
"sonner": "^1.0.3",
|
||||
@@ -237,7 +237,7 @@
|
||||
"tmp": "^0.2.3",
|
||||
"turndown": "^7.2.0",
|
||||
"umzug": "^3.8.1",
|
||||
"utility-types": "^3.10.0",
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vite": "^5.4.11",
|
||||
@@ -333,14 +333,14 @@
|
||||
"discord-api-types": "^0.37.102",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.3",
|
||||
"eslint-import-resolver-typescript": "^3.7.0",
|
||||
"eslint-plugin-es": "^4.1.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.35.0",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^8.0.3",
|
||||
"i18next-parser": "^7.9.0",
|
||||
@@ -348,14 +348,14 @@
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"lint-staged": "^13.3.0",
|
||||
"nodemon": "^3.1.7",
|
||||
"nodemon": "^3.1.9",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^2.8.8",
|
||||
"react-refresh": "^0.14.2",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^0.4.1",
|
||||
"terser": "^5.36.0",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
buildAdmin,
|
||||
buildUser,
|
||||
buildWebhookSubscription,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("#webhookSubscriptions.list", () => {
|
||||
it("should fail with status 401 unauthorized when user token is missing", async () => {
|
||||
const res = await server.post("/api/webhookSubscriptions.list", {
|
||||
body: {},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body.message).toEqual("Authentication required");
|
||||
});
|
||||
|
||||
it("should fail with status 403 forbidden for non-admin user", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body.message).toEqual("Admin role required");
|
||||
});
|
||||
|
||||
it("should return the webhook subscriptions for the user's team", async () => {
|
||||
const user = await buildAdmin();
|
||||
const webhookSubscriptions = await Promise.all(
|
||||
Array(20)
|
||||
.fill(1)
|
||||
.map(() =>
|
||||
buildWebhookSubscription({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(webhookSubscriptions.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#webhookSubscriptions.create", () => {
|
||||
it("should fail with status 401 unauthorized when user token is missing", async () => {
|
||||
const res = await server.post("/api/webhookSubscriptions.create", {
|
||||
body: {},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body.message).toEqual("Authentication required");
|
||||
});
|
||||
|
||||
it("should fail with status 403 forbidden for non-admin user", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.create", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body.message).toEqual("Admin role required");
|
||||
});
|
||||
|
||||
it("should successfully create a webhook subscription", async () => {
|
||||
const user = await buildAdmin();
|
||||
const name = "Test webhook";
|
||||
const url = "https://www.example.com";
|
||||
const events = ["comments"];
|
||||
const secret = "Test secret";
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
name,
|
||||
url,
|
||||
events,
|
||||
secret,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
const webhook = body.data;
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(webhook.name).toEqual(name);
|
||||
expect(webhook.url).toEqual(url);
|
||||
expect(webhook.events).toEqual(events);
|
||||
expect(webhook.secret).toEqual(secret);
|
||||
expect(webhook.enabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#webhookSubscriptions.update", () => {
|
||||
it("should fail with status 401 unauthorized when user token is missing", async () => {
|
||||
const res = await server.post("/api/webhookSubscriptions.update", {
|
||||
body: {},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body.message).toEqual("Authentication required");
|
||||
});
|
||||
|
||||
it("should fail with status 403 forbidden for non-admin user", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.update", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body.message).toEqual("Admin role required");
|
||||
});
|
||||
|
||||
it("should successfully update a webhook subscription", async () => {
|
||||
const user = await buildAdmin();
|
||||
const name = "Updated webhook name";
|
||||
const url = "https://www.example.com/update";
|
||||
const events = ["comments"];
|
||||
|
||||
const existingWebhook = await buildWebhookSubscription({
|
||||
name: "Created webhook name",
|
||||
url: "https://www.example.com/create",
|
||||
events: ["*"],
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: existingWebhook.id,
|
||||
name,
|
||||
url,
|
||||
events,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
const webhook = body.data;
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(webhook.name).toEqual(name);
|
||||
expect(webhook.url).toEqual(url);
|
||||
expect(webhook.events).toEqual(events);
|
||||
expect(webhook.enabled).toEqual(true);
|
||||
});
|
||||
|
||||
it("should activate a disabled webhook subscription when it's updated", async () => {
|
||||
const user = await buildAdmin();
|
||||
const name = "Updated webhook name";
|
||||
const url = "https://www.example.com/update";
|
||||
const events = ["comments"];
|
||||
|
||||
const disabledWebhook = await buildWebhookSubscription({
|
||||
name: "Created webhook name",
|
||||
url: "https://www.example.com/create",
|
||||
events: ["*"],
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: disabledWebhook.id,
|
||||
name,
|
||||
url,
|
||||
events,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
const webhook = body.data;
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(webhook.name).toEqual(name);
|
||||
expect(webhook.url).toEqual(url);
|
||||
expect(webhook.events).toEqual(events);
|
||||
expect(webhook.enabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#webhookSubscriptions.delete", () => {
|
||||
it("should fail with status 401 unauthorized when user token is missing", async () => {
|
||||
const res = await server.post("/api/webhookSubscriptions.delete", {
|
||||
body: {},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body.message).toEqual("Authentication required");
|
||||
});
|
||||
|
||||
it("should fail with status 403 forbidden for non-admin user", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.delete", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body.message).toEqual("Admin role required");
|
||||
});
|
||||
|
||||
it("should successfully delete a webhook subscription", async () => {
|
||||
const user = await buildAdmin();
|
||||
const createdWebhook = await buildWebhookSubscription({
|
||||
name: "Test webhook",
|
||||
url: "https://www.example.com",
|
||||
events: ["*"],
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/webhookSubscriptions.delete", {
|
||||
body: { token: user.getJwtToken(), id: createdWebhook.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { UserRole } from "@shared/types";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { WebhookSubscription, Event } from "@server/models";
|
||||
import { WebhookSubscription } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import pagination from "@server/routes/api/middlewares/pagination";
|
||||
import { APIContext } from "@server/types";
|
||||
@@ -20,7 +20,9 @@ router.post(
|
||||
pagination(),
|
||||
async (ctx: APIContext) => {
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
authorize(user, "listWebhookSubscription", user.team);
|
||||
|
||||
const webhooks = await WebhookSubscription.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
@@ -43,34 +45,19 @@ router.post(
|
||||
validate(T.WebhookSubscriptionsCreateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.WebhookSubscriptionsCreateReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { name, url, secret, events } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
authorize(user, "createWebhookSubscription", user.team);
|
||||
|
||||
const { name, url, secret } = ctx.input.body;
|
||||
const events: string[] = compact(ctx.input.body.events);
|
||||
|
||||
const webhookSubscription = await WebhookSubscription.create(
|
||||
{
|
||||
name,
|
||||
events,
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
url,
|
||||
enabled: true,
|
||||
secret: isEmpty(secret) ? undefined : secret,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "webhookSubscriptions.create",
|
||||
modelId: webhookSubscription.id,
|
||||
data: {
|
||||
name,
|
||||
url,
|
||||
events,
|
||||
},
|
||||
const webhookSubscription = await WebhookSubscription.createWithCtx(ctx, {
|
||||
name,
|
||||
url,
|
||||
events: compact(events),
|
||||
enabled: true,
|
||||
secret: isEmpty(secret) ? undefined : secret,
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
@@ -88,6 +75,7 @@ router.post(
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const webhookSubscription = await WebhookSubscription.findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
@@ -96,17 +84,7 @@ router.post(
|
||||
|
||||
authorize(user, "delete", webhookSubscription);
|
||||
|
||||
await webhookSubscription.destroy({ transaction });
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "webhookSubscriptions.delete",
|
||||
modelId: webhookSubscription.id,
|
||||
data: {
|
||||
name: webhookSubscription.name,
|
||||
url: webhookSubscription.url,
|
||||
events: webhookSubscription.events,
|
||||
},
|
||||
});
|
||||
await webhookSubscription.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
@@ -120,10 +98,10 @@ router.post(
|
||||
validate(T.WebhookSubscriptionsUpdateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.WebhookSubscriptionsUpdateReq>) => {
|
||||
const { id, name, url, secret } = ctx.input.body;
|
||||
const { id, name, url, secret, events } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
const events: string[] = compact(ctx.input.body.events);
|
||||
|
||||
const webhookSubscription = await WebhookSubscription.findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
@@ -132,25 +110,12 @@ router.post(
|
||||
|
||||
authorize(user, "update", webhookSubscription);
|
||||
|
||||
await webhookSubscription.update(
|
||||
{
|
||||
name,
|
||||
url,
|
||||
events,
|
||||
enabled: true,
|
||||
secret: isEmpty(secret) ? undefined : secret,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "webhookSubscriptions.update",
|
||||
modelId: webhookSubscription.id,
|
||||
data: {
|
||||
name: webhookSubscription.name,
|
||||
url: webhookSubscription.url,
|
||||
events: webhookSubscription.events,
|
||||
},
|
||||
await webhookSubscription.updateWithCtx(ctx, {
|
||||
name,
|
||||
url,
|
||||
events: compact(events),
|
||||
enabled: true,
|
||||
secret: isEmpty(secret) ? undefined : secret,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("documentDuplicator", () => {
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
user,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("documentDuplicator", () => {
|
||||
collection: original.collection,
|
||||
title: "New title",
|
||||
user,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("documentDuplicator", () => {
|
||||
collection: original.collection,
|
||||
user,
|
||||
recursive: true,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("documentDuplicator", () => {
|
||||
collection: original.collection,
|
||||
publish: false,
|
||||
user,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("documentImporter", () => {
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
const attachments = await Attachment.count({
|
||||
@@ -49,7 +49,7 @@ describe("documentImporter", () => {
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
const attachments = await Attachment.count({
|
||||
@@ -78,7 +78,7 @@ describe("documentImporter", () => {
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -100,7 +100,7 @@ describe("documentImporter", () => {
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
const attachments = await Attachment.count({
|
||||
@@ -127,7 +127,7 @@ describe("documentImporter", () => {
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
expect(response.text).toContain("Text paragraph");
|
||||
@@ -146,7 +146,7 @@ describe("documentImporter", () => {
|
||||
mimeType: "application/msword",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -167,7 +167,7 @@ describe("documentImporter", () => {
|
||||
mimeType: "text/plain",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
expect(response.text).toContain("This is a test paragraph");
|
||||
@@ -184,7 +184,7 @@ describe("documentImporter", () => {
|
||||
mimeType: "text/plain",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("documentImporter", () => {
|
||||
mimeType: "application/lol",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
expect(response.text).toContain("This is a test paragraph");
|
||||
@@ -227,7 +227,7 @@ describe("documentImporter", () => {
|
||||
mimeType: "executable/zip",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -257,7 +257,7 @@ describe("documentImporter", () => {
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
expect(response.text).toEqual("\\$100");
|
||||
@@ -283,7 +283,7 @@ describe("documentImporter", () => {
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
expect(response.text).toEqual("`echo $foo`");
|
||||
@@ -309,7 +309,7 @@ describe("documentImporter", () => {
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
expect(response.text).toEqual("```\necho $foo\n```");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import truncate from "lodash/truncate";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
@@ -51,10 +50,7 @@ async function documentImporter({
|
||||
if (text.trim().startsWith("# ")) {
|
||||
const result = parseTitle(text);
|
||||
title = result.title;
|
||||
text = text
|
||||
.trim()
|
||||
.replace(new RegExp(`#\\s+${escapeRegExp(title)}`), "")
|
||||
.trimStart();
|
||||
text = text.replace(/^.+(\n|$)/, "");
|
||||
}
|
||||
|
||||
// Replace any <br> generated by the turndown plugin with escaped newlines
|
||||
|
||||
@@ -171,15 +171,19 @@ export default async function loadDocument({
|
||||
throw AuthorizationError();
|
||||
}
|
||||
|
||||
const childDocumentIds =
|
||||
(await share.document?.findAllChildDocumentIds({
|
||||
archivedAt: {
|
||||
[Op.is]: null,
|
||||
},
|
||||
})) ?? [];
|
||||
// If the document is not a direct child of the shared document then we
|
||||
// need to check if it is nested within the shared document somewhere.
|
||||
if (document.parentDocumentId !== share.documentId) {
|
||||
const childDocumentIds =
|
||||
(await share.document?.findAllChildDocumentIds({
|
||||
archivedAt: {
|
||||
[Op.is]: null,
|
||||
},
|
||||
})) ?? [];
|
||||
|
||||
if (!childDocumentIds.includes(document.id)) {
|
||||
throw AuthorizationError();
|
||||
if (!childDocumentIds.includes(document.id)) {
|
||||
throw AuthorizationError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { Transaction } from "sequelize";
|
||||
import { Event, Group, type User } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
actor: User;
|
||||
ip: string;
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
export default async function groupCreator({
|
||||
name,
|
||||
actor,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<Group> {
|
||||
const group = await Group.create(
|
||||
{
|
||||
name,
|
||||
teamId: actor.teamId,
|
||||
createdById: actor.id,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Event.create(
|
||||
{
|
||||
name: "groups.create",
|
||||
modelId: group.id,
|
||||
teamId: actor.teamId,
|
||||
actorId: actor.id,
|
||||
data: {
|
||||
name: group.name,
|
||||
},
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
return group;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Transaction } from "sequelize";
|
||||
import { Event, type Group, type User } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
actor: User;
|
||||
ip: string;
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
export default async function groupDestroyer({
|
||||
group,
|
||||
actor,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<void> {
|
||||
await group.destroy({ transaction });
|
||||
await Event.create(
|
||||
{
|
||||
name: "groups.delete",
|
||||
modelId: group.id,
|
||||
teamId: actor.teamId,
|
||||
actorId: actor.id,
|
||||
data: {
|
||||
name: group.name,
|
||||
},
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { Transaction } from "sequelize";
|
||||
import { Event, type Group, type User } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
name: string;
|
||||
actor: User;
|
||||
ip: string;
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
export default async function groupUpdater({
|
||||
group,
|
||||
name,
|
||||
actor,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<Group> {
|
||||
group.name = name;
|
||||
|
||||
if (group.changed()) {
|
||||
await group.save({ transaction });
|
||||
await Event.create(
|
||||
{
|
||||
name: "groups.update",
|
||||
modelId: group.id,
|
||||
teamId: actor.teamId,
|
||||
actorId: actor.id,
|
||||
data: {
|
||||
name: group.name,
|
||||
},
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createContext } from "@server/context";
|
||||
import { Subscription, Event } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import subscriptionCreator from "./subscriptionCreator";
|
||||
import subscriptionDestroyer from "./subscriptionDestroyer";
|
||||
|
||||
describe("subscriptionCreator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
@@ -18,11 +18,9 @@ describe("subscriptionCreator", () => {
|
||||
|
||||
const subscription = await sequelize.transaction(async (transaction) =>
|
||||
subscriptionCreator({
|
||||
user,
|
||||
ctx: createContext({ user, transaction, ip }),
|
||||
documentId: document.id,
|
||||
event: subscribedEvent,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -57,11 +55,9 @@ describe("subscriptionCreator", () => {
|
||||
|
||||
const subscription1 = await sequelize.transaction(async (transaction) =>
|
||||
subscriptionCreator({
|
||||
user,
|
||||
ctx: createContext({ user, transaction, ip }),
|
||||
documentId: document.id,
|
||||
event: subscribedEvent,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -92,21 +88,14 @@ describe("subscriptionCreator", () => {
|
||||
|
||||
const subscription0 = await sequelize.transaction(async (transaction) =>
|
||||
subscriptionCreator({
|
||||
user,
|
||||
ctx: createContext({ user, transaction, ip }),
|
||||
documentId: document.id,
|
||||
event: subscribedEvent,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
await sequelize.transaction(async (transaction) =>
|
||||
subscriptionDestroyer({
|
||||
user,
|
||||
subscription: subscription0,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
subscription0.destroyWithCtx(createContext({ user, transaction, ip }))
|
||||
);
|
||||
|
||||
expect(subscription0.id).toBeDefined();
|
||||
@@ -116,11 +105,9 @@ describe("subscriptionCreator", () => {
|
||||
|
||||
const subscription1 = await sequelize.transaction(async (transaction) =>
|
||||
subscriptionCreator({
|
||||
user,
|
||||
ctx: createContext({ user, transaction, ip }),
|
||||
documentId: document.id,
|
||||
event: subscribedEvent,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -153,21 +140,17 @@ describe("subscriptionCreator", () => {
|
||||
|
||||
const subscription0 = await sequelize.transaction(async (transaction) =>
|
||||
subscriptionCreator({
|
||||
user,
|
||||
ctx: createContext({ user, transaction, ip }),
|
||||
documentId: document.id,
|
||||
event: subscribedEvent,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
const subscription1 = await sequelize.transaction(async (transaction) =>
|
||||
subscriptionCreator({
|
||||
user,
|
||||
ctx: createContext({ user, transaction, ip }),
|
||||
documentId: document.id,
|
||||
event: subscribedEvent,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -198,21 +181,14 @@ describe("subscriptionCreator", () => {
|
||||
|
||||
const subscription0 = await sequelize.transaction(async (transaction) =>
|
||||
subscriptionCreator({
|
||||
user,
|
||||
ctx: createContext({ user, transaction, ip }),
|
||||
documentId: document.id,
|
||||
event: subscribedEvent,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
await sequelize.transaction(async (transaction) =>
|
||||
subscriptionDestroyer({
|
||||
user,
|
||||
subscription: subscription0,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
subscription0.destroyWithCtx(createContext({ user, transaction, ip }))
|
||||
);
|
||||
|
||||
expect(subscription0.id).toBeDefined();
|
||||
@@ -222,11 +198,9 @@ describe("subscriptionCreator", () => {
|
||||
|
||||
const subscription1 = await sequelize.transaction(async (transaction) =>
|
||||
subscriptionCreator({
|
||||
user,
|
||||
ctx: createContext({ user, transaction, ip }),
|
||||
documentId: document.id,
|
||||
event: subscribedEvent,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -265,11 +239,9 @@ describe("subscriptionCreator", () => {
|
||||
|
||||
const subscription0 = await sequelize.transaction(async (transaction) =>
|
||||
subscriptionCreator({
|
||||
user,
|
||||
ctx: createContext({ user, transaction, ip }),
|
||||
documentId: document.id,
|
||||
event: subscribedEvent,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user