mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ddccc195a | |||
| 66b0341cfa | |||
| 057d57e21a | |||
| 13c00c4663 | |||
| eb584ed6b6 | |||
| 40c81a5e30 | |||
| 5e976fe732 | |||
| fe9daa0a75 | |||
| 08227ce4da | |||
| 4f6ee1a00b | |||
| 797c28a12e | |||
| 129e872578 | |||
| b4053f344f | |||
| ffe7cda26b | |||
| 38880f8335 | |||
| 1caca05876 | |||
| 0722b42613 | |||
| 5d749efd84 | |||
| 0363481a6a | |||
| c8fbdc35fb | |||
| c382e1233b | |||
| 3a875d4466 | |||
| 66f9113975 | |||
| a52391842f | |||
| 20e84c8e1d | |||
| 1488341f66 | |||
| a06174b627 | |||
| 22556b2121 | |||
| 7252701e9b | |||
| 5fd6ef646a | |||
| 0e9f34bd6a | |||
| 23177578b2 | |||
| 40bbfc78cd | |||
| dc9aad99e9 | |||
| ea9e9675fb | |||
| db42af7fe1 | |||
| eb59aed5b7 | |||
| 8209f56e56 | |||
| a097676e9c | |||
| 2da35f2504 |
+8
-1
@@ -1,4 +1,3 @@
|
||||
__mocks__
|
||||
.git
|
||||
.vscode
|
||||
.github
|
||||
@@ -8,11 +7,19 @@ __mocks__
|
||||
.eslint*
|
||||
.oxlintrc*
|
||||
.log
|
||||
*.md
|
||||
Makefile
|
||||
Procfile
|
||||
app.json
|
||||
crowdin.yml
|
||||
lint-staged.config.mjs
|
||||
build
|
||||
docker-compose.yml
|
||||
node_modules
|
||||
.yarn
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.test.js
|
||||
**/*.test.jsx
|
||||
**/__tests__
|
||||
**/__mocks__
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./public/logos/outline-logo-dark.png" height="29">
|
||||
<source media="(prefers-color-scheme: light)" srcset="./public/logos/outline-logo-light.png" height="29">
|
||||
<img src="./public/logos/outline-logo-light.png" height="29" alt="Outline" />
|
||||
</picture>
|
||||
</p>
|
||||
<p align="center">
|
||||
<i>A fast, collaborative, knowledge base for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
@@ -132,6 +133,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</DocumentContextProvider>
|
||||
|
||||
@@ -170,7 +170,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
<Container align="center" $rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
{to ? (
|
||||
<Link to={to} replace={replace}>
|
||||
{content}
|
||||
@@ -219,8 +219,8 @@ const Strong = styled.strong`
|
||||
font-weight: 550;
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
const Container = styled(Flex)<{ $rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.$rtl ? "flex-end" : "flex-start")};
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -266,7 +266,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
<>
|
||||
{paragraphs ? (
|
||||
<EditorContainer
|
||||
rtl={props.dir === "rtl"}
|
||||
$rtl={props.dir === "rtl"}
|
||||
grow={props.grow}
|
||||
style={props.style}
|
||||
editorStyle={props.editorStyle}
|
||||
|
||||
@@ -95,7 +95,7 @@ const IconWrapper = styled.span`
|
||||
export const Outline = styled(Flex)<{
|
||||
margin?: string | number;
|
||||
hasError?: boolean;
|
||||
focused?: boolean;
|
||||
$focused?: boolean;
|
||||
}>`
|
||||
flex: 1;
|
||||
margin: ${(props) =>
|
||||
@@ -106,7 +106,7 @@ export const Outline = styled(Flex)<{
|
||||
border-color: ${(props) =>
|
||||
props.hasError
|
||||
? props.theme.danger
|
||||
: props.focused
|
||||
: props.$focused
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
border-radius: 4px;
|
||||
@@ -224,7 +224,7 @@ function Input(
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={focused} margin={margin}>
|
||||
<Outline $focused={focused} margin={margin}>
|
||||
{prefix}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{type === "textarea" ? (
|
||||
|
||||
@@ -16,6 +16,7 @@ import { fadeAndScaleIn, fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -93,9 +94,11 @@ const Modal: React.FC<Props> = ({
|
||||
</DesktopContent>
|
||||
<Header>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
<Tooltip content={t("Close")} shortcut="Esc">
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Header>
|
||||
</Centered>
|
||||
</Wrapper>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { NotificationBadgeType, UserPreference } from "@shared/types";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
/**
|
||||
* Component that keeps the app icon notification badge in sync with unread
|
||||
* notification count. Renders nothing visible — mount near the app root so it
|
||||
* stays alive as long as the user is authenticated.
|
||||
*/
|
||||
function NotificationBadge() {
|
||||
const { notifications } = useStores();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const badgeType = user.getPreference(UserPreference.NotificationBadge);
|
||||
const unreadCount = notifications.approximateUnreadCount;
|
||||
|
||||
React.useEffect(() => {
|
||||
// Desktop app badge
|
||||
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
|
||||
if (badgeType === NotificationBadgeType.Disabled || unreadCount === 0) {
|
||||
void Desktop.bridge.setNotificationCount(0);
|
||||
} else if (badgeType === NotificationBadgeType.Count) {
|
||||
void Desktop.bridge.setNotificationCount(unreadCount);
|
||||
} else {
|
||||
void Desktop.bridge.setNotificationCount("・");
|
||||
}
|
||||
}
|
||||
|
||||
// PWA badge
|
||||
if ("setAppBadge" in navigator) {
|
||||
if (unreadCount > 0 && badgeType !== NotificationBadgeType.Disabled) {
|
||||
void navigator.setAppBadge(
|
||||
badgeType === NotificationBadgeType.Count ? unreadCount : undefined
|
||||
);
|
||||
} else {
|
||||
void navigator.clearAppBadge();
|
||||
}
|
||||
}
|
||||
}, [unreadCount, badgeType]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default observer(NotificationBadge);
|
||||
@@ -8,7 +8,6 @@ import Notification, { type NotificationFilter } from "~/models/Notification";
|
||||
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NotificationMenu from "~/menus/NotificationMenu";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Empty from "../Empty";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
import Flex from "../Flex";
|
||||
@@ -61,25 +60,7 @@ function Notifications(
|
||||
);
|
||||
}, [notifications.active, filter]);
|
||||
|
||||
// Update the notification count in the dock icon, if possible.
|
||||
React.useEffect(() => {
|
||||
// Account for old versions of the desktop app that don't have the
|
||||
// setNotificationCount method on the bridge.
|
||||
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
|
||||
void Desktop.bridge.setNotificationCount(
|
||||
notifications.approximateUnreadCount
|
||||
);
|
||||
}
|
||||
|
||||
// PWA badging
|
||||
if ("setAppBadge" in navigator) {
|
||||
if (notifications.approximateUnreadCount) {
|
||||
void navigator.setAppBadge(notifications.approximateUnreadCount);
|
||||
} else {
|
||||
void navigator.clearAppBadge();
|
||||
}
|
||||
}
|
||||
}, [notifications.approximateUnreadCount]);
|
||||
const unreadCount = notifications.approximateUnreadCount;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
@@ -105,7 +86,7 @@ function Notifications(
|
||||
short
|
||||
nude
|
||||
/>
|
||||
{notifications.approximateUnreadCount > 0 && (
|
||||
{unreadCount > 0 && (
|
||||
<Tooltip content={t("Mark all as read")}>
|
||||
<Button
|
||||
action={markNotificationsAsRead}
|
||||
|
||||
@@ -15,6 +15,7 @@ import EditableTitle from "~/components/EditableTitle";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -122,6 +123,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
|
||||
const contextMenuAction = useCollectionMenuAction({
|
||||
collectionId: collection.id,
|
||||
onRename: handleRename,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -165,17 +167,18 @@ const CollectionLink: React.FC<Props> = ({
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
{can.createDocument && (
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
handleExpand();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<Tooltip content={t("New doc")} delay={500}>
|
||||
<NudeButton
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
handleExpand();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
|
||||
@@ -40,6 +40,10 @@ import type UserMembership from "~/models/UserMembership";
|
||||
import type GroupMembership from "~/models/GroupMembership";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosure,
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
@@ -119,6 +123,13 @@ function InnerDocumentLink(
|
||||
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
|
||||
|
||||
// Context-based recursive expand/collapse for descendant DocumentLinks
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
// Subscribe to recursive expand/collapse events from an ancestor
|
||||
useSidebarDisclosure(setExpanded, setCollapsed);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
setExpanded();
|
||||
@@ -132,13 +143,18 @@ function InnerDocumentLink(
|
||||
}
|
||||
}, [setCollapsed, expanded, hasChildDocuments]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(() => {
|
||||
if (expanded) {
|
||||
setCollapsed();
|
||||
} else {
|
||||
setExpanded();
|
||||
}
|
||||
}, [setCollapsed, setExpanded, expanded]);
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: React.MouseEvent<HTMLElement>) => {
|
||||
const willExpand = !expanded;
|
||||
if (willExpand) {
|
||||
setExpanded();
|
||||
} else {
|
||||
setCollapsed();
|
||||
}
|
||||
onDisclosureClick(willExpand, ev.altKey);
|
||||
},
|
||||
[setCollapsed, setExpanded, expanded, onDisclosureClick]
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void prefetchDocument?.(node.id);
|
||||
@@ -336,7 +352,10 @@ function InnerDocumentLink(
|
||||
]
|
||||
);
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
|
||||
const contextMenuAction = useDocumentMenuAction({
|
||||
documentId: node.id,
|
||||
onRename: handleRename,
|
||||
});
|
||||
|
||||
const labelElement = React.useMemo(
|
||||
() => (
|
||||
@@ -464,22 +483,24 @@ function InnerDocumentLink(
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={childIndex}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={childIndex}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import useStores from "~/hooks/useStores";
|
||||
import type { DragObject } from "../hooks/useDragAndDrop";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import Relative from "./Relative";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
|
||||
@@ -36,6 +39,10 @@ function DraggableCollectionLink({
|
||||
);
|
||||
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
|
||||
|
||||
// Context-based recursive expand/collapse for descendant DocumentLinks
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
// Drop to reorder collection
|
||||
const [
|
||||
{ isCollectionDropping, isDraggingAnyCollection },
|
||||
@@ -91,15 +98,22 @@ function DraggableCollectionLink({
|
||||
locationSidebarContext,
|
||||
]);
|
||||
|
||||
const handleDisclosureClick = useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
const handleDisclosureClick = useCallback(
|
||||
(ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => {
|
||||
const willExpand = !e;
|
||||
onDisclosureClick(willExpand, !!ev?.altKey);
|
||||
return willExpand;
|
||||
});
|
||||
},
|
||||
[onDisclosureClick]
|
||||
);
|
||||
|
||||
const displayChildDocuments = expanded && !isDragging;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Draggable
|
||||
key={collection.id}
|
||||
ref={dragToReorderCollection}
|
||||
@@ -121,7 +135,7 @@ function DraggableCollectionLink({
|
||||
/>
|
||||
)}
|
||||
</Relative>
|
||||
</>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -21,10 +24,20 @@ const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
locationSidebarContext === sidebarContext
|
||||
);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => {
|
||||
const willExpand = !e;
|
||||
onDisclosureClick(willExpand, !!ev?.altKey);
|
||||
return willExpand;
|
||||
});
|
||||
},
|
||||
[onDisclosureClick]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (locationSidebarContext === sidebarContext) {
|
||||
@@ -42,15 +55,17 @@ const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
depth={0}
|
||||
/>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Folder expanded={expanded}>
|
||||
{group.documentMemberships.map((membership) => (
|
||||
<SharedWithMeLink
|
||||
key={membership.id}
|
||||
membership={membership}
|
||||
depth={1}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Folder expanded={expanded}>
|
||||
{group.documentMemberships.map((membership) => (
|
||||
<SharedWithMeLink
|
||||
key={membership.id}
|
||||
membership={membership}
|
||||
depth={1}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
</SidebarContext.Provider>
|
||||
</Relative>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,10 @@ import type Document from "~/models/Document";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sharedModelPath } from "~/utils/routeHelpers";
|
||||
import { descendants } from "@shared/utils/tree";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosure,
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -62,6 +66,14 @@ function DocumentLink(
|
||||
|
||||
const [expanded, setExpanded] = React.useState(showChildren);
|
||||
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
const handleExpand = React.useCallback(() => setExpanded(true), []);
|
||||
const handleCollapse = React.useCallback(() => setExpanded(false), []);
|
||||
|
||||
useSidebarDisclosure(handleExpand, handleCollapse);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
setExpanded(showChildren);
|
||||
@@ -72,9 +84,12 @@ function DocumentLink(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
const willExpand = !expanded;
|
||||
setExpanded(willExpand);
|
||||
const altKey = "altKey" in ev && (ev as React.MouseEvent).altKey;
|
||||
onDisclosureClick(willExpand, !!altKey);
|
||||
},
|
||||
[expanded]
|
||||
[expanded, onDisclosureClick]
|
||||
);
|
||||
|
||||
// since we don't have access to the collection sort here, we just put any
|
||||
@@ -133,22 +148,24 @@ function DocumentLink(
|
||||
ref={ref}
|
||||
isActive={() => !!isActiveDocument}
|
||||
/>
|
||||
{expanded &&
|
||||
nodeChildren.map((childNode, index) => (
|
||||
<SharedDocumentLink
|
||||
shareId={shareId}
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
activeDocumentId={activeDocumentId}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
{expanded &&
|
||||
nodeChildren.map((childNode, index) => (
|
||||
<SharedDocumentLink
|
||||
shareId={shareId}
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
activeDocumentId={activeDocumentId}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</SidebarDisclosureContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosure,
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import { useSidebarContext, type SidebarContextType } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
@@ -48,6 +52,12 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
isActiveDocumentInPath && locationSidebarContext === sidebarContext
|
||||
);
|
||||
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
// Subscribe to recursive expand/collapse events from an ancestor (e.g. GroupLink)
|
||||
useSidebarDisclosure(setExpanded, setCollapsed);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActiveDocumentInPath && locationSidebarContext === sidebarContext) {
|
||||
setExpanded();
|
||||
@@ -76,13 +86,15 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (expanded) {
|
||||
setCollapsed();
|
||||
} else {
|
||||
const willExpand = !expanded;
|
||||
if (willExpand) {
|
||||
setExpanded();
|
||||
} else {
|
||||
setCollapsed();
|
||||
}
|
||||
onDisclosureClick(willExpand, ev.altKey);
|
||||
},
|
||||
[expanded, setExpanded, setCollapsed]
|
||||
[expanded, setExpanded, setCollapsed, onDisclosureClick]
|
||||
);
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -174,20 +186,22 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
</div>
|
||||
</Draggable>
|
||||
</Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((childNode, index) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
activeDocument={documents.active}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((childNode, index) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
activeDocument={documents.active}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
{reorderProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderProps.isOverCursor}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Represents a recursive expand/collapse event broadcast through context.
|
||||
*/
|
||||
export interface SidebarDisclosureEvent {
|
||||
/** Whether descendants should expand or collapse. */
|
||||
action: "expand" | "collapse";
|
||||
/**
|
||||
* Monotonically increasing counter used to detect new events.
|
||||
* Each increment represents a distinct user interaction.
|
||||
*/
|
||||
generation: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for broadcasting recursive expand/collapse events from a parent
|
||||
* (e.g. a collection or document disclosure toggle with alt-click) to all
|
||||
* descendant DocumentLinks in the sidebar tree.
|
||||
*
|
||||
* The nearest provider determines the scope — only descendants within that
|
||||
* provider react to the event. Each DocumentLink should both consume and
|
||||
* provide this context so that alt-click at any level only affects its subtree.
|
||||
*/
|
||||
const SidebarDisclosureContext = createContext<SidebarDisclosureEvent | null>(
|
||||
null
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook that subscribes to recursive expand/collapse events from an ancestor
|
||||
* provider. When a new event is detected, the appropriate callback is invoked.
|
||||
*
|
||||
* Newly mounted components will also react to the current event, which enables
|
||||
* cascading: expanding a parent reveals children, which mount and see the
|
||||
* expand event, then expand themselves to reveal grandchildren, and so on.
|
||||
*
|
||||
* @param onExpand - called when a recursive expand event is received.
|
||||
* @param onCollapse - called when a recursive collapse event is received.
|
||||
*/
|
||||
export function useSidebarDisclosure(
|
||||
onExpand: () => void,
|
||||
onCollapse: () => void
|
||||
): void {
|
||||
const event = useContext(SidebarDisclosureContext);
|
||||
const lastHandledGeneration = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!event || event.generation === lastHandledGeneration.current) {
|
||||
return;
|
||||
}
|
||||
lastHandledGeneration.current = event.generation;
|
||||
|
||||
if (event.action === "expand") {
|
||||
onExpand();
|
||||
} else {
|
||||
onCollapse();
|
||||
}
|
||||
}, [event, onExpand, onCollapse]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the producing side of the disclosure context. Returns the current
|
||||
* event value (to pass to a Provider) and a single callback to handle
|
||||
* alt-click expand/collapse broadcasts.
|
||||
*
|
||||
* This hook also reads the parent context and automatically forwards any
|
||||
* incoming disclosure events so that the cascade propagates through the
|
||||
* entire tree — even when intermediate nodes each create their own provider.
|
||||
*
|
||||
* @returns object with `event` to spread onto the Provider's value and
|
||||
* `onDisclosureClick` to call from disclosure click handlers.
|
||||
*/
|
||||
export function useSidebarDisclosureState() {
|
||||
const parentEvent = useContext(SidebarDisclosureContext);
|
||||
const [event, setEvent] = useState<SidebarDisclosureEvent | null>(null);
|
||||
const lastForwardedParentGeneration = useRef(-1);
|
||||
|
||||
// Forward parent disclosure events into our own provider value so that
|
||||
// grandchildren (and beyond) see the event even though each level creates
|
||||
// its own independent provider.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!parentEvent ||
|
||||
parentEvent.generation === lastForwardedParentGeneration.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastForwardedParentGeneration.current = parentEvent.generation;
|
||||
setEvent((prev) => ({
|
||||
action: parentEvent.action,
|
||||
generation: (prev?.generation ?? 0) + 1,
|
||||
}));
|
||||
}, [parentEvent]);
|
||||
|
||||
/**
|
||||
* Call from a disclosure click handler after toggling expand/collapse state.
|
||||
* When alt is held, broadcasts a recursive expand or collapse event to all
|
||||
* descendants. Otherwise, clears any stale event.
|
||||
*
|
||||
* @param willExpand - whether the node is expanding or collapsing.
|
||||
* @param altKey - whether the alt/option key was held during the click.
|
||||
*/
|
||||
const onDisclosureClick = useCallback(
|
||||
(willExpand: boolean, altKey: boolean) => {
|
||||
if (altKey) {
|
||||
setEvent((prev) => ({
|
||||
action: willExpand ? "expand" : "collapse",
|
||||
generation: (prev?.generation ?? 0) + 1,
|
||||
}));
|
||||
} else {
|
||||
setEvent(null);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { event, onDisclosureClick };
|
||||
}
|
||||
|
||||
export default SidebarDisclosureContext;
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
@@ -204,6 +207,9 @@ function StarredLink({ star }: Props) {
|
||||
sidebarContext === locationSidebarContext
|
||||
);
|
||||
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
star.documentId === ui.activeDocumentId &&
|
||||
@@ -235,9 +241,13 @@ function StarredLink({ star }: Props) {
|
||||
(ev?: React.MouseEvent<HTMLElement>) => {
|
||||
ev?.preventDefault();
|
||||
ev?.stopPropagation();
|
||||
setExpanded((prevExpanded) => !prevExpanded);
|
||||
setExpanded((prevExpanded) => {
|
||||
const willExpand = !prevExpanded;
|
||||
onDisclosureClick(willExpand, !!ev?.altKey);
|
||||
return willExpand;
|
||||
});
|
||||
},
|
||||
[]
|
||||
[onDisclosureClick]
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
@@ -284,39 +294,43 @@ function StarredLink({ star }: Props) {
|
||||
|
||||
if (documentId) {
|
||||
return (
|
||||
<StarredDocumentLink
|
||||
star={star}
|
||||
documentId={documentId}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
handlePrefetch={handlePrefetch}
|
||||
icon={icon}
|
||||
label={label}
|
||||
menuOpen={menuOpen}
|
||||
handleMenuOpen={handleMenuOpen}
|
||||
handleMenuClose={handleMenuClose}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
/>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<StarredDocumentLink
|
||||
star={star}
|
||||
documentId={documentId}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
handlePrefetch={handlePrefetch}
|
||||
icon={icon}
|
||||
label={label}
|
||||
menuOpen={menuOpen}
|
||||
handleMenuOpen={handleMenuOpen}
|
||||
handleMenuClose={handleMenuClose}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
/>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
return (
|
||||
<StarredCollectionLink
|
||||
star={star}
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
displayChildDocuments={displayChildDocuments}
|
||||
reorderStarProps={reorderStarProps}
|
||||
/>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<StarredCollectionLink
|
||||
star={star}
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
displayChildDocuments={displayChildDocuments}
|
||||
reorderStarProps={reorderStarProps}
|
||||
/>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ export type Props<TData> = {
|
||||
};
|
||||
rowHeight: number;
|
||||
stickyOffset?: number;
|
||||
decorateRow?: (item: TData, rowElement: React.ReactNode) => React.ReactNode;
|
||||
};
|
||||
|
||||
function Table<TData>({
|
||||
@@ -70,6 +71,7 @@ function Table<TData>({
|
||||
page,
|
||||
rowHeight,
|
||||
stickyOffset = 0,
|
||||
decorateRow,
|
||||
}: Props<TData>) {
|
||||
const { t } = useTranslation();
|
||||
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -206,7 +208,7 @@ function Table<TData>({
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = rows[virtualRow.index] as TRow<TData>;
|
||||
return (
|
||||
const baseRow = (
|
||||
<TR
|
||||
role="row"
|
||||
key={row.id}
|
||||
@@ -231,6 +233,8 @@ function Table<TData>({
|
||||
))}
|
||||
</TR>
|
||||
);
|
||||
|
||||
return decorateRow ? decorateRow(row.original, baseRow) : baseRow;
|
||||
})}
|
||||
</TBody>
|
||||
{showPlaceholder && (
|
||||
|
||||
@@ -33,6 +33,7 @@ type Props = {
|
||||
mark?: Mark;
|
||||
dictionary: Dictionary;
|
||||
view: EditorView;
|
||||
autoFocus?: boolean;
|
||||
onLinkAdd: () => void;
|
||||
onLinkUpdate: () => void;
|
||||
onLinkRemove: () => void;
|
||||
@@ -45,6 +46,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
mark,
|
||||
dictionary,
|
||||
view,
|
||||
autoFocus,
|
||||
onLinkAdd,
|
||||
onLinkUpdate,
|
||||
onLinkRemove,
|
||||
@@ -209,7 +211,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleSearch}
|
||||
onFocus={handleSearch}
|
||||
autoFocus={getHref() === ""}
|
||||
autoFocus={autoFocus}
|
||||
readOnly={!view.editable}
|
||||
/>
|
||||
{actions.map((action, index) => {
|
||||
|
||||
@@ -87,25 +87,29 @@ export function SelectionToolbar(props: Props) {
|
||||
const isMobile = useMobile();
|
||||
const isActive = props.isActive || isMobile;
|
||||
const { state } = view;
|
||||
const [autoFocusLinkInput, setAutoFocusLinkInput] = React.useState(false);
|
||||
const isDragging = useIsDragging(state);
|
||||
const { selection } = state;
|
||||
const [activeToolbar, setActiveToolbar] = React.useState<Toolbar | null>(
|
||||
null
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const { selection } = state;
|
||||
const linkMark =
|
||||
selection instanceof NodeSelection
|
||||
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
|
||||
: getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const linkMark =
|
||||
selection instanceof NodeSelection
|
||||
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
|
||||
: getMarkRange(selection.$from, state.schema.marks.link);
|
||||
|
||||
const isEmbedSelection =
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === "embed";
|
||||
const isEmbedSelection =
|
||||
selection instanceof NodeSelection && selection.node.type.name === "embed";
|
||||
|
||||
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isActive) {
|
||||
setActiveToolbar(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmbedSelection && !readOnly) {
|
||||
setActiveToolbar(Toolbar.Media);
|
||||
@@ -124,22 +128,37 @@ export function SelectionToolbar(props: Props) {
|
||||
} else if (selection.empty) {
|
||||
setActiveToolbar(null);
|
||||
}
|
||||
}, [readOnly, selection]);
|
||||
}, [
|
||||
readOnly,
|
||||
isActive,
|
||||
selection,
|
||||
linkMark,
|
||||
isEmbedSelection,
|
||||
isCodeSelection,
|
||||
isNoticeSelection,
|
||||
]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (autoFocusLinkInput && activeToolbar !== Toolbar.Link) {
|
||||
setAutoFocusLinkInput(false);
|
||||
}
|
||||
}, [activeToolbar]);
|
||||
|
||||
// Refocus the editor when the link toolbar closes to prevent focus loss
|
||||
const prevActiveToolbar = React.useRef(activeToolbar);
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
if (
|
||||
prevActiveToolbar.current === Toolbar.Link &&
|
||||
activeToolbar !== Toolbar.Link &&
|
||||
!readOnly
|
||||
!readOnly &&
|
||||
isActive
|
||||
) {
|
||||
view.focus();
|
||||
}
|
||||
prevActiveToolbar.current = activeToolbar;
|
||||
}, [activeToolbar, readOnly, view]);
|
||||
}, [activeToolbar, readOnly, isActive, view]);
|
||||
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
const handleClickOutside = (ev: MouseEvent): void => {
|
||||
if (
|
||||
ev.target instanceof HTMLElement &&
|
||||
@@ -193,11 +212,10 @@ export function SelectionToolbar(props: Props) {
|
||||
) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (activeToolbar === Toolbar.Link) {
|
||||
setActiveToolbar(Toolbar.Menu);
|
||||
} else if (activeToolbar === Toolbar.Menu) {
|
||||
setActiveToolbar(Toolbar.Link);
|
||||
}
|
||||
setAutoFocusLinkInput(true);
|
||||
setActiveToolbar(
|
||||
activeToolbar === Toolbar.Link ? Toolbar.Menu : Toolbar.Link
|
||||
);
|
||||
}
|
||||
},
|
||||
view.dom,
|
||||
@@ -218,12 +236,6 @@ export function SelectionToolbar(props: Props) {
|
||||
const isAttachmentSelection =
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === "attachment";
|
||||
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
const link =
|
||||
selection instanceof NodeSelection
|
||||
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
|
||||
: getMarkRange(selection.$from, state.schema.marks.link);
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
let align: "center" | "start" | "end" = "center";
|
||||
@@ -289,6 +301,7 @@ export function SelectionToolbar(props: Props) {
|
||||
|
||||
if (item.name === "linkOnImage" || item.name === "addLink") {
|
||||
item.onClick = () => {
|
||||
setAutoFocusLinkInput(true);
|
||||
setActiveToolbar(Toolbar.Link);
|
||||
};
|
||||
}
|
||||
@@ -315,10 +328,11 @@ export function SelectionToolbar(props: Props) {
|
||||
>
|
||||
{activeToolbar === Toolbar.Link ? (
|
||||
<LinkEditor
|
||||
key={`${selection.from}-${selection.to}`}
|
||||
key={`link-${selection.anchor}`}
|
||||
dictionary={dictionary}
|
||||
autoFocus={autoFocusLinkInput}
|
||||
view={view}
|
||||
mark={link ? link.mark : undefined}
|
||||
mark={linkMark ? linkMark.mark : undefined}
|
||||
onLinkAdd={() => setActiveToolbar(null)}
|
||||
onLinkUpdate={() => setActiveToolbar(null)}
|
||||
onLinkRemove={() => setActiveToolbar(null)}
|
||||
@@ -328,7 +342,7 @@ export function SelectionToolbar(props: Props) {
|
||||
/>
|
||||
) : activeToolbar === Toolbar.Media ? (
|
||||
<MediaLinkEditor
|
||||
key={`embed-${selection.from}`}
|
||||
key={`embed-${selection.anchor}`}
|
||||
node={
|
||||
"node" in selection ? (selection as NodeSelection).node : undefined
|
||||
}
|
||||
|
||||
@@ -78,6 +78,11 @@ export type Props = {
|
||||
focusedCommentId?: string;
|
||||
/** If the editor should not allow editing */
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Whether we are rendering a cached version of the document while multiplayer loads.
|
||||
* This is used to disable some editor functionality
|
||||
*/
|
||||
cacheOnly?: boolean;
|
||||
/** If the editor should still allow editing checkboxes when it is readOnly */
|
||||
canUpdate?: boolean;
|
||||
/** If the editor should still allow commenting when it is readOnly */
|
||||
@@ -854,7 +859,7 @@ export class Editor extends React.PureComponent<
|
||||
column
|
||||
>
|
||||
<EditorContainer
|
||||
rtl={isRTL}
|
||||
$rtl={isRTL}
|
||||
grow={grow}
|
||||
readOnly={readOnly}
|
||||
readOnlyWriteCheckboxes={canUpdate}
|
||||
@@ -867,6 +872,7 @@ export class Editor extends React.PureComponent<
|
||||
/>
|
||||
|
||||
{this.widgets &&
|
||||
!this.props.cacheOnly &&
|
||||
Object.values(this.widgets).map((Widget, index) => (
|
||||
<Widget
|
||||
key={String(index)}
|
||||
@@ -887,7 +893,7 @@ export class Editor extends React.PureComponent<
|
||||
images={this.getLightboxImages()}
|
||||
activeImage={this.state.activeLightboxImage}
|
||||
onUpdate={this.updateActiveLightboxImage}
|
||||
onClose={this.view.focus}
|
||||
onClose={this.view.focus.bind(this.view)}
|
||||
/>
|
||||
)}
|
||||
</EditorContext.Provider>
|
||||
|
||||
+5
-1
@@ -10,7 +10,11 @@ if (!window.env) {
|
||||
);
|
||||
}
|
||||
|
||||
const env: Record<string, any> = {
|
||||
const env: Record<string, any> & {
|
||||
isDevelopment: boolean;
|
||||
isTest: boolean;
|
||||
isProduction: boolean;
|
||||
} = {
|
||||
...window.env,
|
||||
isDevelopment: window.env.ENVIRONMENT === "development",
|
||||
isTest: window.env.ENVIRONMENT === "test",
|
||||
|
||||
@@ -74,7 +74,7 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
|
||||
.filter((policy): policy is Policy => policy !== undefined),
|
||||
|
||||
isModelActive: (model: Model): boolean => stores.ui.isModelActive(model),
|
||||
activeModels: stores.ui.activeModels,
|
||||
activeModels: new Set(stores.ui.activeModels.values()),
|
||||
|
||||
currentUserId: stores.auth.user?.id,
|
||||
currentTeamId: stores.auth.team?.id,
|
||||
@@ -84,9 +84,50 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
|
||||
};
|
||||
|
||||
// Merge the parent context with the provided overrides
|
||||
const activeCollectionId =
|
||||
value.activeCollectionId ?? baseContext.activeCollectionId;
|
||||
const activeDocumentId =
|
||||
value.activeDocumentId ?? baseContext.activeDocumentId;
|
||||
|
||||
const getActiveModels = <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): T[] => {
|
||||
// @ts-expect-error modelName
|
||||
if (activeCollectionId && modelClass.modelName === "Collection") {
|
||||
const model = stores.collections.get(activeCollectionId);
|
||||
if (model) {
|
||||
return [model as unknown as T];
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error modelName
|
||||
if (activeDocumentId && modelClass.modelName === "Document") {
|
||||
const model = stores.documents.get(activeDocumentId);
|
||||
if (model) {
|
||||
return [model as unknown as T];
|
||||
}
|
||||
}
|
||||
|
||||
return baseContext.getActiveModels(modelClass);
|
||||
};
|
||||
|
||||
const getActiveModel = <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): T | undefined => getActiveModels(modelClass)[0];
|
||||
|
||||
const getActivePolicies = <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): Policy[] =>
|
||||
getActiveModels(modelClass)
|
||||
.map((node) => stores.policies.get(node.id))
|
||||
.filter((policy): policy is Policy => policy !== undefined);
|
||||
|
||||
const contextValue: ActionContextType = {
|
||||
...baseContext,
|
||||
...value,
|
||||
getActiveModels,
|
||||
getActiveModel,
|
||||
getActivePolicies,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,6 +26,9 @@ export default function useDictionary() {
|
||||
alignFullWidth: t("Full width"),
|
||||
bulletList: t("Bulleted list"),
|
||||
checkboxList: t("Todo list"),
|
||||
showCompleted: (count: number) =>
|
||||
t("Show {{ count }} completed", { count }),
|
||||
hideCompleted: t("Hide completed"),
|
||||
codeBlock: t("Code block"),
|
||||
codeCopied: t("Copied to clipboard"),
|
||||
codeInline: t("Code"),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react";
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type Emoji from "~/models/Emoji";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { createAction } from "~/actions";
|
||||
import { EmojiSecion } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for emoji management operations.
|
||||
*
|
||||
* @param targetEmoji - the emoji to build actions for, or null to skip.
|
||||
* @returns action with children for use in menus, or undefined if emoji is null.
|
||||
*/
|
||||
export function useEmojiMenuActions(targetEmoji: Emoji | null) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const can = usePolicy(targetEmoji ?? ({} as Emoji));
|
||||
|
||||
const openDeleteDialog = React.useCallback(() => {
|
||||
if (!targetEmoji) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Delete Emoji"),
|
||||
content: (
|
||||
<DeleteEmojiDialog emoji={targetEmoji} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, targetEmoji, dialogs]);
|
||||
|
||||
const actionList = React.useMemo(
|
||||
() =>
|
||||
!targetEmoji || !can.delete
|
||||
? []
|
||||
: [
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: EmojiSecion,
|
||||
visible: true,
|
||||
dangerous: true,
|
||||
perform: openDeleteDialog,
|
||||
}),
|
||||
],
|
||||
[t, targetEmoji, can.delete, openDeleteDialog]
|
||||
);
|
||||
|
||||
return useMenuAction(actionList);
|
||||
}
|
||||
|
||||
const DeleteEmojiDialog = ({
|
||||
emoji,
|
||||
onSubmit,
|
||||
}: {
|
||||
emoji: Emoji;
|
||||
onSubmit: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (emoji) {
|
||||
await emoji.delete();
|
||||
onSubmit();
|
||||
toast.success(t("Emoji deleted"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I'm sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
|
||||
values={{
|
||||
emojiName: emoji.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as React from "react";
|
||||
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Group from "~/models/Group";
|
||||
import {
|
||||
DeleteGroupDialog,
|
||||
EditGroupDialog,
|
||||
ViewGroupMembersDialog,
|
||||
} from "~/scenes/Settings/components/GroupDialogs";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
ActionSeparator,
|
||||
createAction,
|
||||
createExternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { GroupSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for group management operations.
|
||||
*
|
||||
* @param targetGroup - the group to build actions for, or null to skip.
|
||||
* @returns action with children for use in menus, or undefined if group is null.
|
||||
*/
|
||||
export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const can = usePolicy(targetGroup ?? ({} as Group));
|
||||
|
||||
const openMembersDialog = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={targetGroup} />,
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
|
||||
const openEditDialog = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Edit group"),
|
||||
content: (
|
||||
<EditGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
|
||||
const openDeleteDialog = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Delete group"),
|
||||
content: (
|
||||
<DeleteGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
|
||||
const actionList = React.useMemo(
|
||||
() =>
|
||||
!targetGroup
|
||||
? []
|
||||
: [
|
||||
createAction({
|
||||
name: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.read),
|
||||
perform: openMembersDialog,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.update),
|
||||
perform: openEditDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.delete),
|
||||
dangerous: true,
|
||||
perform: openDeleteDialog,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createExternalLinkAction({
|
||||
name: targetGroup.externalId ?? "",
|
||||
section: GroupSection,
|
||||
visible: !!targetGroup.externalId,
|
||||
disabled: true,
|
||||
url: "",
|
||||
}),
|
||||
],
|
||||
[
|
||||
t,
|
||||
targetGroup,
|
||||
can.read,
|
||||
can.update,
|
||||
can.delete,
|
||||
openMembersDialog,
|
||||
openEditDialog,
|
||||
openDeleteDialog,
|
||||
]
|
||||
);
|
||||
|
||||
return useMenuAction(actionList);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
import type Share from "~/models/Share";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { ActionSeparator } from "~/actions";
|
||||
import {
|
||||
copyShareUrlFactory,
|
||||
goToShareSourceFactory,
|
||||
revokeShareFactory,
|
||||
} from "~/actions/definitions/shares";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for share management operations.
|
||||
*
|
||||
* @param targetShare - the share to build actions for, or null to skip.
|
||||
* @returns action with children for use in menus, or undefined if share is null.
|
||||
*/
|
||||
export function useShareMenuActions(targetShare: Share | null) {
|
||||
const can = usePolicy(targetShare ?? ({} as Share));
|
||||
|
||||
const actionList = React.useMemo(
|
||||
() =>
|
||||
!targetShare
|
||||
? []
|
||||
: [
|
||||
copyShareUrlFactory({ share: targetShare }),
|
||||
goToShareSourceFactory({ share: targetShare }),
|
||||
ActionSeparator,
|
||||
revokeShareFactory({ share: targetShare, can }),
|
||||
],
|
||||
[targetShare, can]
|
||||
);
|
||||
|
||||
return useMenuAction(actionList);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import type User from "~/models/User";
|
||||
import {
|
||||
ActionSeparator,
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
} from "~/actions";
|
||||
import {
|
||||
deleteUserActionFactory,
|
||||
updateUserRoleActionFactory,
|
||||
} from "~/actions/definitions/users";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
UserSuspendDialog,
|
||||
UserChangeNameDialog,
|
||||
UserChangeEmailDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for user management operations.
|
||||
*
|
||||
* @param targetUser - the user to build actions for, or null to skip.
|
||||
* @returns action with children for use in menus, or undefined if user is null.
|
||||
*/
|
||||
export function useUserMenuActions(targetUser: User | null) {
|
||||
const { users, dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(targetUser ?? ({} as User));
|
||||
|
||||
const openNameDialog = React.useCallback(() => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Change name"),
|
||||
content: (
|
||||
<UserChangeNameDialog
|
||||
user={targetUser}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, targetUser]);
|
||||
|
||||
const openEmailDialog = React.useCallback(() => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog
|
||||
user={targetUser}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, targetUser]);
|
||||
|
||||
const openSuspendDialog = React.useCallback(() => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Suspend user"),
|
||||
content: (
|
||||
<UserSuspendDialog
|
||||
user={targetUser}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, targetUser]);
|
||||
|
||||
const revokeInvitation = React.useCallback(async () => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
await users.delete(targetUser);
|
||||
}, [users, targetUser]);
|
||||
|
||||
const resendInvitation = React.useCallback(async () => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await users.resendInvite(targetUser);
|
||||
toast.success(t(`Invite was resent to ${targetUser.name}`));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err.message ?? t(`An error occurred while sending the invite`)
|
||||
);
|
||||
}
|
||||
}, [users, targetUser, t]);
|
||||
|
||||
const activateUser = React.useCallback(async () => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
await users.activate(targetUser);
|
||||
}, [users, targetUser]);
|
||||
|
||||
const roleChangeActions = React.useMemo(
|
||||
() =>
|
||||
targetUser
|
||||
? [UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
|
||||
updateUserRoleActionFactory(targetUser, role)
|
||||
)
|
||||
: [],
|
||||
[targetUser]
|
||||
);
|
||||
|
||||
const actionList = React.useMemo(
|
||||
() =>
|
||||
!targetUser
|
||||
? []
|
||||
: [
|
||||
createActionWithChildren({
|
||||
name: t("Change role"),
|
||||
section: UserSection,
|
||||
visible: can.demote || can.promote,
|
||||
children: roleChangeActions,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change name")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: openNameDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change email")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: openEmailDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: t("Resend invite"),
|
||||
section: UserSection,
|
||||
visible: can.resendInvite,
|
||||
perform: resendInvitation,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Revoke invite")}…`,
|
||||
section: UserSection,
|
||||
visible: targetUser.isInvited,
|
||||
dangerous: true,
|
||||
perform: revokeInvitation,
|
||||
}),
|
||||
createAction({
|
||||
name: t("Activate user"),
|
||||
section: UserSection,
|
||||
visible: !targetUser.isInvited && targetUser.isSuspended,
|
||||
perform: activateUser,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Suspend user")}…`,
|
||||
section: UserSection,
|
||||
visible: !targetUser.isInvited && !targetUser.isSuspended,
|
||||
dangerous: true,
|
||||
perform: openSuspendDialog,
|
||||
}),
|
||||
ActionSeparator,
|
||||
deleteUserActionFactory(targetUser.id),
|
||||
],
|
||||
[
|
||||
t,
|
||||
targetUser,
|
||||
can.demote,
|
||||
can.promote,
|
||||
can.update,
|
||||
can.resendInvite,
|
||||
roleChangeActions,
|
||||
openNameDialog,
|
||||
openEmailDialog,
|
||||
resendInvitation,
|
||||
revokeInvitation,
|
||||
activateUser,
|
||||
openSuspendDialog,
|
||||
]
|
||||
);
|
||||
|
||||
return useMenuAction(actionList);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import "vite/modulepreload-polyfill";
|
||||
import { LazyMotion } from "framer-motion";
|
||||
import { KBarProvider } from "kbar";
|
||||
import { Provider } from "mobx-react";
|
||||
import { configure as configureMobx } from "mobx";
|
||||
import { StrictMode } from "react";
|
||||
import { render } from "react-dom";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
@@ -37,6 +38,13 @@ if (env.SENTRY_DSN) {
|
||||
initSentry(history);
|
||||
}
|
||||
|
||||
configureMobx({
|
||||
// TODO: Enable these options and fix any resulting warnings
|
||||
// enforceActions: env.isDevelopment ? "always" : "never",
|
||||
// computedRequiresReaction: true,
|
||||
isolateGlobalState: true,
|
||||
});
|
||||
|
||||
// Make sure to return the specific export containing the feature bundle.
|
||||
const loadFeatures = () => import("./utils/motion").then((res) => res.default);
|
||||
|
||||
|
||||
+23
-70
@@ -1,75 +1,28 @@
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import { IconButton } from "~/components/IconPicker/components/IconButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Emoji from "~/models/Emoji";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import { useEmojiMenuActions } from "~/hooks/useEmojiMenuActions";
|
||||
|
||||
const EmojisMenu = ({ emoji }: { emoji: Emoji }) => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const can = usePolicy(emoji);
|
||||
|
||||
const handleDelete = () => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete Emoji"),
|
||||
content: (
|
||||
<DeleteEmojiDialog emoji={emoji} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
if (!can.delete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Delete Emoji")}>
|
||||
<IconButton onClick={handleDelete}>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteEmojiDialog = ({
|
||||
emoji,
|
||||
onSubmit,
|
||||
}: {
|
||||
type Props = {
|
||||
emoji: Emoji;
|
||||
onSubmit: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (emoji) {
|
||||
await emoji.delete();
|
||||
onSubmit();
|
||||
toast.success(t("Emoji deleted"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
|
||||
values={{
|
||||
emojiName: emoji.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojisMenu;
|
||||
function EmojisMenu({ emoji }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const rootAction = useEmojiMenuActions(emoji);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align="end"
|
||||
ariaLabel={t("Emoji options")}
|
||||
>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(EmojisMenu);
|
||||
|
||||
+3
-91
@@ -1,24 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Group from "~/models/Group";
|
||||
import {
|
||||
DeleteGroupDialog,
|
||||
EditGroupDialog,
|
||||
ViewGroupMembersDialog,
|
||||
} from "~/scenes/Settings/components/GroupDialogs";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
ActionSeparator,
|
||||
createAction,
|
||||
createExternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { GroupSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
@@ -26,81 +12,7 @@ type Props = {
|
||||
|
||||
function GroupMenu({ group }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const can = usePolicy(group);
|
||||
|
||||
const handleViewMembers = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleEditGroup = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Edit group"),
|
||||
content: (
|
||||
<EditGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleDeleteGroup = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete group"),
|
||||
content: (
|
||||
<DeleteGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
createAction({
|
||||
name: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(group && can.read),
|
||||
perform: handleViewMembers,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(group && can.update),
|
||||
perform: handleEditGroup,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(group && can.delete),
|
||||
dangerous: true,
|
||||
perform: handleDeleteGroup,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createExternalLinkAction({
|
||||
name: group.externalId ?? "",
|
||||
section: GroupSection,
|
||||
visible: !!group.externalId,
|
||||
disabled: true,
|
||||
url: "",
|
||||
}),
|
||||
],
|
||||
[
|
||||
t,
|
||||
group,
|
||||
can.read,
|
||||
can.update,
|
||||
can.delete,
|
||||
handleViewMembers,
|
||||
handleEditGroup,
|
||||
handleDeleteGroup,
|
||||
]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
const rootAction = useGroupMenuActions(group);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
|
||||
+2
-21
@@ -4,14 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type Share from "~/models/Share";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { ActionSeparator } from "~/actions";
|
||||
import {
|
||||
copyShareUrlFactory,
|
||||
goToShareSourceFactory,
|
||||
revokeShareFactory,
|
||||
} from "~/actions/definitions/shares";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { useShareMenuActions } from "~/hooks/useShareMenuActions";
|
||||
|
||||
type Props = {
|
||||
share: Share;
|
||||
@@ -19,19 +12,7 @@ type Props = {
|
||||
|
||||
function ShareMenu({ share }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(share);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
copyShareUrlFactory({ share }),
|
||||
goToShareSourceFactory({ share }),
|
||||
ActionSeparator,
|
||||
revokeShareFactory({ share, can }),
|
||||
],
|
||||
[share, can]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
const rootAction = useShareMenuActions(share);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
|
||||
+2
-147
@@ -1,163 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import type User from "~/models/User";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import {
|
||||
UserSuspendDialog,
|
||||
UserChangeNameDialog,
|
||||
UserChangeEmailDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
import {
|
||||
ActionSeparator,
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
} from "~/actions";
|
||||
import {
|
||||
deleteUserActionFactory,
|
||||
updateUserRoleActionFactory,
|
||||
} from "~/actions/definitions/users";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useUserMenuActions } from "~/hooks/useUserMenuActions";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
function UserMenu({ user }: Props) {
|
||||
const { users, dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(user);
|
||||
|
||||
const handleChangeName = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Change name"),
|
||||
content: (
|
||||
<UserChangeNameDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, user]);
|
||||
|
||||
const handleChangeEmail = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, user]);
|
||||
|
||||
const handleSuspend = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Suspend user"),
|
||||
content: (
|
||||
<UserSuspendDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, user]);
|
||||
|
||||
const handleRevoke = React.useCallback(async () => {
|
||||
await users.delete(user);
|
||||
}, [users, user]);
|
||||
|
||||
const handleResendInvite = React.useCallback(async () => {
|
||||
try {
|
||||
await users.resendInvite(user);
|
||||
toast.success(t(`Invite was resent to ${user.name}`));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err.message ?? t(`An error occurred while sending the invite`)
|
||||
);
|
||||
}
|
||||
}, [users, user, t]);
|
||||
|
||||
const handleActivate = React.useCallback(async () => {
|
||||
await users.activate(user);
|
||||
}, [users, user]);
|
||||
|
||||
const changeRoleActions = React.useMemo(
|
||||
() =>
|
||||
[UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
|
||||
updateUserRoleActionFactory(user, role)
|
||||
),
|
||||
[user]
|
||||
);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createActionWithChildren({
|
||||
name: t("Change role"),
|
||||
section: UserSection,
|
||||
visible: can.demote || can.promote,
|
||||
children: changeRoleActions,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change name")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: handleChangeName,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change email")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: handleChangeEmail,
|
||||
}),
|
||||
createAction({
|
||||
name: t("Resend invite"),
|
||||
section: UserSection,
|
||||
visible: can.resendInvite,
|
||||
perform: handleResendInvite,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Revoke invite")}…`,
|
||||
section: UserSection,
|
||||
visible: user.isInvited,
|
||||
dangerous: true,
|
||||
perform: handleRevoke,
|
||||
}),
|
||||
createAction({
|
||||
name: t("Activate user"),
|
||||
section: UserSection,
|
||||
visible: !user.isInvited && user.isSuspended,
|
||||
perform: handleActivate,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Suspend user")}…`,
|
||||
section: UserSection,
|
||||
visible: !user.isInvited && !user.isSuspended,
|
||||
dangerous: true,
|
||||
perform: handleSuspend,
|
||||
}),
|
||||
ActionSeparator,
|
||||
deleteUserActionFactory(user.id),
|
||||
],
|
||||
[
|
||||
t,
|
||||
can.demote,
|
||||
can.promote,
|
||||
can.update,
|
||||
can.resendInvite,
|
||||
user.id,
|
||||
user.isInvited,
|
||||
user.isSuspended,
|
||||
changeRoleActions,
|
||||
handleChangeName,
|
||||
handleChangeEmail,
|
||||
handleResendInvite,
|
||||
handleRevoke,
|
||||
handleActivate,
|
||||
handleSuspend,
|
||||
]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
const rootAction = useUserMenuActions(user);
|
||||
|
||||
return (
|
||||
<DropdownMenu action={rootAction} align="end" ariaLabel={t("User options")}>
|
||||
|
||||
+13
-5
@@ -231,10 +231,14 @@ class User extends ParanoidModel implements Searchable {
|
||||
* @param key The UserPreference key to retrieve
|
||||
* @returns The value
|
||||
*/
|
||||
getPreference(key: UserPreference, defaultValue = false): boolean {
|
||||
return (
|
||||
this.preferences?.[key] ?? UserPreferenceDefaults[key] ?? defaultValue
|
||||
);
|
||||
getPreference<K extends UserPreference>(
|
||||
key: K,
|
||||
defaultValue?: UserPreferences[K]
|
||||
): NonNullable<UserPreferences[K]> {
|
||||
return (this.preferences?.[key] ??
|
||||
UserPreferenceDefaults[key] ??
|
||||
defaultValue ??
|
||||
false) as NonNullable<UserPreferences[K]>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,7 +247,11 @@ class User extends ParanoidModel implements Searchable {
|
||||
* @param key The UserPreference key to retrieve
|
||||
* @param value The value to set
|
||||
*/
|
||||
setPreference(key: UserPreference, value: boolean) {
|
||||
@action
|
||||
setPreference<K extends UserPreference>(
|
||||
key: K,
|
||||
value: NonNullable<UserPreferences[K]>
|
||||
) {
|
||||
this.preferences = {
|
||||
...this.preferences,
|
||||
[key]: value,
|
||||
|
||||
@@ -104,18 +104,23 @@ function DataLoader({ match, children }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchRevision() {
|
||||
if (revisionId) {
|
||||
try {
|
||||
await revisions[revisionId === "latest" ? "fetchLatest" : "fetch"](
|
||||
revisionId
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
if (!revisionId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (revisionId === "latest") {
|
||||
if (document?.id) {
|
||||
await revisions.fetchLatest(document.id);
|
||||
}
|
||||
} else {
|
||||
await revisions.fetch(revisionId);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
void fetchRevision();
|
||||
}, [revisions, revisionId]);
|
||||
}, [revisions, revisionId, document?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchViews() {
|
||||
@@ -162,7 +167,7 @@ function DataLoader({ match, children }: Props) {
|
||||
|
||||
// If we're attempting to update an archived, deleted, or otherwise
|
||||
// uneditable document then forward to the canonical read url.
|
||||
if (!can.update && isEditRoute && !document.template) {
|
||||
if (!missingPolicy && !can.update && isEditRoute && !document.template) {
|
||||
history.push(document.url);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,8 +67,6 @@ function DocumentHeader({
|
||||
revision,
|
||||
isEditing,
|
||||
isDraft,
|
||||
isPublishing,
|
||||
isSaving,
|
||||
savingIsDisabled,
|
||||
publishingIsDisabled,
|
||||
onSelectTemplate,
|
||||
@@ -256,10 +254,6 @@ function DocumentHeader({
|
||||
actions={({ isCompact }) => (
|
||||
<>
|
||||
<ObservingBanner />
|
||||
|
||||
{!isPublishing && isSaving && user?.separateEditMode && (
|
||||
<Status>{t("Saving")}…</Status>
|
||||
)}
|
||||
{!isDeleted && !isRevision && can.listViews && (
|
||||
<Collaborators
|
||||
document={document}
|
||||
@@ -286,7 +280,7 @@ function DocumentHeader({
|
||||
{(isEditing || isTemplateEditable) && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
content={t("Save")}
|
||||
content={isDraft ? t("Save draft") : t("Done editing")}
|
||||
shortcut={`${metaDisplay}+enter`}
|
||||
placement="bottom"
|
||||
>
|
||||
@@ -376,10 +370,4 @@ const StyledHeader = styled(Header)<{ $hidden: boolean }>`
|
||||
${(props) => props.$hidden && "opacity: 0;"}
|
||||
`;
|
||||
|
||||
const Status = styled(Action)`
|
||||
padding-left: 0;
|
||||
padding-right: 4px;
|
||||
color: ${(props) => props.theme.slate};
|
||||
`;
|
||||
|
||||
export default observer(DocumentHeader);
|
||||
|
||||
@@ -317,6 +317,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
defaultValue={props.defaultValue}
|
||||
extensions={props.extensions}
|
||||
scrollTo={props.scrollTo}
|
||||
cacheOnly
|
||||
readOnly
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -23,11 +23,11 @@ function DocumentNew({ template }: Props) {
|
||||
const location = useLocation();
|
||||
const query = useQuery();
|
||||
const user = useCurrentUser();
|
||||
const match = useRouteMatch<{ id?: string }>();
|
||||
const match = useRouteMatch<{ collectionSlug?: string }>();
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, userMemberships, groupMemberships } =
|
||||
useStores();
|
||||
const id = match.params.id || query.get("collectionId");
|
||||
const id = match.params.collectionSlug || query.get("collectionId");
|
||||
|
||||
useEffect(() => {
|
||||
async function createDocument() {
|
||||
|
||||
@@ -117,14 +117,6 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) {
|
||||
),
|
||||
label: t("Publish document and exit"),
|
||||
},
|
||||
{
|
||||
shortcut: (
|
||||
<>
|
||||
<Key symbol>{metaDisplay}</Key> + <Key>s</Key>
|
||||
</>
|
||||
),
|
||||
label: t("Save document"),
|
||||
},
|
||||
{
|
||||
shortcut: (
|
||||
<>
|
||||
|
||||
@@ -4,7 +4,11 @@ import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { languageOptions as availableLanguages } from "@shared/i18n";
|
||||
import { TeamPreference, UserPreference } from "@shared/types";
|
||||
import {
|
||||
NotificationBadgeType,
|
||||
TeamPreference,
|
||||
UserPreference,
|
||||
} from "@shared/types";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
@@ -95,6 +99,39 @@ function Preferences() {
|
||||
[user, t]
|
||||
);
|
||||
|
||||
const notificationBadgeOptions: Option[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
type: "item",
|
||||
label: t("Disabled"),
|
||||
value: NotificationBadgeType.Disabled,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Unread count"),
|
||||
value: NotificationBadgeType.Count,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Unread indicator"),
|
||||
value: NotificationBadgeType.Indicator,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleNotificationBadgeChange = React.useCallback(
|
||||
async (value: string) => {
|
||||
user.setPreference(
|
||||
UserPreference.NotificationBadge,
|
||||
value as NotificationBadgeType
|
||||
);
|
||||
await user.save();
|
||||
toast.success(t("Preferences saved"));
|
||||
},
|
||||
[user, t]
|
||||
);
|
||||
|
||||
const handleLanguageChange = React.useCallback(
|
||||
async (language: string) => {
|
||||
await user.save({ language });
|
||||
@@ -230,7 +267,6 @@ function Preferences() {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
border={false}
|
||||
name={UserPreference.EnableSmartText}
|
||||
label={t("Smart text replacements")}
|
||||
description={t(
|
||||
@@ -244,6 +280,22 @@ function Preferences() {
|
||||
onChange={handleEnableSmartTextChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
border={false}
|
||||
name={UserPreference.NotificationBadge}
|
||||
label={t("Notification badge")}
|
||||
description={t(
|
||||
"Choose how unread notifications are indicated on the app icon."
|
||||
)}
|
||||
>
|
||||
<InputSelect
|
||||
options={notificationBadgeOptions}
|
||||
value={user.getPreference(UserPreference.NotificationBadge)}
|
||||
onChange={handleNotificationBadgeChange}
|
||||
label={t("Notification badge")}
|
||||
hideLabel
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{can.delete && (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Emoji from "~/models/Emoji";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import { ContextMenu } from "~/components/Menu/ContextMenu";
|
||||
import { useEmojiMenuActions } from "~/hooks/useEmojiMenuActions";
|
||||
import Time from "~/components/Time";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
import { CustomEmoji } from "@shared/components/CustomEmoji";
|
||||
@@ -25,12 +28,38 @@ type Props = Omit<TableProps<Emoji>, "columns" | "rowHeight"> & {
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
function EmojiRowContextMenu({
|
||||
emoji,
|
||||
menuLabel,
|
||||
children,
|
||||
}: {
|
||||
emoji: Emoji;
|
||||
menuLabel: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const action = useEmojiMenuActions(emoji);
|
||||
return (
|
||||
<ContextMenu action={action} ariaLabel={menuLabel}>
|
||||
{children}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const EmojisTable = observer(function EmojisTable({
|
||||
canManage,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const applyContextMenu = useCallback(
|
||||
(emoji: Emoji, rowElement: React.ReactNode) => (
|
||||
<EmojiRowContextMenu emoji={emoji} menuLabel={t("Emoji options")}>
|
||||
{rowElement}
|
||||
</EmojiRowContextMenu>
|
||||
),
|
||||
[t]
|
||||
);
|
||||
|
||||
const columns = React.useMemo(
|
||||
(): TableColumn<Emoji>[] =>
|
||||
compact([
|
||||
@@ -73,12 +102,14 @@ const EmojisTable = observer(function EmojisTable({
|
||||
component: (emoji) => <Time dateTime={emoji.createdAt} addSuffix />,
|
||||
width: "1fr",
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
id: "action",
|
||||
component: (emoji) => <EmojisMenu emoji={emoji} />,
|
||||
width: "50px",
|
||||
},
|
||||
canManage
|
||||
? {
|
||||
type: "action",
|
||||
id: "action",
|
||||
component: (emoji) => <EmojisMenu emoji={emoji} />,
|
||||
width: "50px",
|
||||
}
|
||||
: undefined,
|
||||
]),
|
||||
[t, canManage]
|
||||
);
|
||||
@@ -88,6 +119,7 @@ const EmojisTable = observer(function EmojisTable({
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={STICKY_OFFSET}
|
||||
decorateRow={canManage ? applyContextMenu : undefined}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,8 @@ import DelayedMount from "~/components/DelayedMount";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import type { Item } from "~/components/InputSelect";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import { ListItem } from "~/components/Sharing/components/ListItem";
|
||||
@@ -229,6 +231,10 @@ export const ViewGroupMembersDialog = observer(function ({
|
||||
const { dialogs, users, groupUsers } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(group);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [permissionFilter, setPermissionFilter] = React.useState<
|
||||
GroupPermission | "all"
|
||||
>("all");
|
||||
|
||||
const handleAddPeople = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
@@ -262,6 +268,59 @@ export const ViewGroupMembersDialog = observer(function ({
|
||||
[t, groupUsers, group.id]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePermissionFilterChange = React.useCallback((value: string) => {
|
||||
setPermissionFilter(value as GroupPermission | "all");
|
||||
}, []);
|
||||
|
||||
const permissionOptions: Item[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
type: "item",
|
||||
label: t("All permissions"),
|
||||
value: "all",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Group admin"),
|
||||
value: GroupPermission.Admin,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Member"),
|
||||
value: GroupPermission.Member,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
let result = users.inGroup(group.id, query);
|
||||
|
||||
if (permissionFilter !== "all") {
|
||||
const groupUserMap = new Map(
|
||||
groupUsers.orderedData
|
||||
.filter((gu) => gu.groupId === group.id)
|
||||
.map((gu) => [gu.userId, gu])
|
||||
);
|
||||
|
||||
result = result.filter((user) => {
|
||||
const groupUser = groupUserMap.get(user.id);
|
||||
return groupUser?.permission === permissionFilter;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [users, group.id, query, permissionFilter, groupUsers.orderedData]);
|
||||
|
||||
const hasActiveFilters = query || permissionFilter !== "all";
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
{can.update ? (
|
||||
@@ -304,13 +363,40 @@ export const ViewGroupMembersDialog = observer(function ({
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{(filteredUsers.length || hasActiveFilters) && (
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
label={t("Search members")}
|
||||
labelHidden
|
||||
flex
|
||||
/>
|
||||
<InputSelect
|
||||
options={permissionOptions}
|
||||
value={permissionFilter}
|
||||
onChange={handlePermissionFilterChange}
|
||||
label={t("Filter by permissions")}
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
<PaginatedList<User>
|
||||
items={users.inGroup(group.id)}
|
||||
items={filteredUsers}
|
||||
fetch={groupUsers.fetchPage}
|
||||
options={{
|
||||
id: group.id,
|
||||
}}
|
||||
empty={<Empty>{t("This group has no members.")}</Empty>}
|
||||
empty={
|
||||
hasActiveFilters ? (
|
||||
<Empty>{t("No members matching your filters")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("This group has no members.")}</Empty>
|
||||
)
|
||||
}
|
||||
renderItem={(user) => (
|
||||
<GroupMemberListItem
|
||||
key={user.id}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import compact from "lodash/compact";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import { ContextMenu } from "~/components/Menu/ContextMenu";
|
||||
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -29,6 +32,23 @@ const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
|
||||
type Props = Omit<TableProps<Group>, "columns" | "rowHeight">;
|
||||
|
||||
function GroupRowContextMenu({
|
||||
group,
|
||||
menuLabel,
|
||||
children,
|
||||
}: {
|
||||
group: Group;
|
||||
menuLabel: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const action = useGroupMenuActions(group);
|
||||
return (
|
||||
<ContextMenu action={action} ariaLabel={menuLabel}>
|
||||
{children}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupsTable(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
@@ -43,6 +63,15 @@ export function GroupsTable(props: Props) {
|
||||
[t, dialogs]
|
||||
);
|
||||
|
||||
const applyContextMenu = useCallback(
|
||||
(group: Group, rowElement: React.ReactNode) => (
|
||||
<GroupRowContextMenu group={group} menuLabel={t("Group options")}>
|
||||
{rowElement}
|
||||
</GroupRowContextMenu>
|
||||
),
|
||||
[t]
|
||||
);
|
||||
|
||||
const columns = useMemo<TableColumn<Group>[]>(
|
||||
() =>
|
||||
compact<TableColumn<Group>>([
|
||||
@@ -136,6 +165,7 @@ export function GroupsTable(props: Props) {
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={STICKY_OFFSET}
|
||||
decorateRow={applyContextMenu}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import compact from "lodash/compact";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Text from "@shared/components/Text";
|
||||
import type User from "~/models/User";
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import { ContextMenu } from "~/components/Menu/ContextMenu";
|
||||
import { useUserMenuActions } from "~/hooks/useUserMenuActions";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -26,11 +28,43 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
function UserRowContextMenu({
|
||||
user,
|
||||
menuLabel,
|
||||
children,
|
||||
}: {
|
||||
user: User;
|
||||
menuLabel: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const action = useUserMenuActions(user);
|
||||
return (
|
||||
<ContextMenu action={action} ariaLabel={menuLabel}>
|
||||
{children}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function MembersTable({ canManage, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const applyContextMenu = useCallback(
|
||||
(user: User, rowElement: React.ReactNode) => {
|
||||
if (currentUser.id === user.id) {
|
||||
return rowElement;
|
||||
}
|
||||
|
||||
return (
|
||||
<UserRowContextMenu user={user} menuLabel={t("User options")}>
|
||||
{rowElement}
|
||||
</UserRowContextMenu>
|
||||
);
|
||||
},
|
||||
[currentUser.id, t]
|
||||
);
|
||||
|
||||
const columns = useMemo<TableColumn<User>[]>(
|
||||
() =>
|
||||
compact<TableColumn<User>>([
|
||||
@@ -119,6 +153,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={STICKY_OFFSET}
|
||||
decorateRow={canManage ? applyContextMenu : undefined}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import compact from "lodash/compact";
|
||||
import { useMemo } from "react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Share from "~/models/Share";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import { ContextMenu } from "~/components/Menu/ContextMenu";
|
||||
import { useShareMenuActions } from "~/hooks/useShareMenuActions";
|
||||
import Time from "~/components/Time";
|
||||
import ShareMenu from "~/menus/ShareMenu";
|
||||
import { useFormatNumber } from "~/hooks/useFormatNumber";
|
||||
@@ -22,11 +25,37 @@ type Props = Omit<TableProps<Share>, "columns" | "rowHeight"> & {
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
function ShareRowContextMenu({
|
||||
share,
|
||||
menuLabel,
|
||||
children,
|
||||
}: {
|
||||
share: Share;
|
||||
menuLabel: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const action = useShareMenuActions(share);
|
||||
return (
|
||||
<ContextMenu action={action} ariaLabel={menuLabel}>
|
||||
{children}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function SharesTable({ data, canManage, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const formatNumber = useFormatNumber();
|
||||
const hasDomain = data.some((share) => share.domain);
|
||||
|
||||
const applyContextMenu = useCallback(
|
||||
(share: Share, rowElement: React.ReactNode) => (
|
||||
<ShareRowContextMenu share={share} menuLabel={t("Share options")}>
|
||||
{rowElement}
|
||||
</ShareRowContextMenu>
|
||||
),
|
||||
[t]
|
||||
);
|
||||
|
||||
const columns = useMemo<TableColumn<Share>[]>(
|
||||
() =>
|
||||
compact<TableColumn<Share>>([
|
||||
@@ -38,7 +67,7 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
|
||||
sortable: false,
|
||||
component: (share) => (
|
||||
<>
|
||||
{share.sourceTitle || t("Untitled")}
|
||||
{share.sourceTitle || t("Untitled")}{" "}
|
||||
{share.collectionId ? <Badge>{t("Collection")}</Badge> : null}
|
||||
</>
|
||||
),
|
||||
@@ -125,6 +154,7 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={HEADER_HEIGHT}
|
||||
decorateRow={canManage ? applyContextMenu : undefined}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,8 @@ export default class RevisionsStore extends Store<Revision> {
|
||||
/**
|
||||
* Fetches the latest revision for the given document.
|
||||
*
|
||||
* @returns A promise that resolves to the latest revision for the given document
|
||||
* @param documentId - the id of the document to fetch the latest revision for.
|
||||
* @returns A promise that resolves to the latest revision for the given document.
|
||||
*/
|
||||
fetchLatest = async (documentId: string) => {
|
||||
const res = await client.post(`/revisions.info`, { documentId });
|
||||
|
||||
@@ -54,7 +54,7 @@ class UiStore {
|
||||
systemTheme: SystemTheme;
|
||||
|
||||
@observable
|
||||
activeModels = new Set<Model>();
|
||||
activeModels = observable.map<string, Model>();
|
||||
|
||||
@observable
|
||||
observingUserId: string | undefined;
|
||||
@@ -156,7 +156,7 @@ class UiStore {
|
||||
*/
|
||||
@action
|
||||
addActiveModel = (model: Model): void => {
|
||||
this.activeModels.add(model);
|
||||
this.activeModels.set(model.id, model);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -166,7 +166,7 @@ class UiStore {
|
||||
*/
|
||||
@action
|
||||
removeActiveModel = (model: Model): void => {
|
||||
this.activeModels.delete(model);
|
||||
this.activeModels.delete(model.id);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -176,7 +176,7 @@ class UiStore {
|
||||
* @returns array of active models of the specified type.
|
||||
*/
|
||||
getActiveModels<T extends Model>(modelClass: new (...args: any[]) => T): T[] {
|
||||
return Array.from(this.activeModels).filter(
|
||||
return Array.from(this.activeModels.values()).filter(
|
||||
(model) => model.constructor === modelClass
|
||||
) as T[];
|
||||
}
|
||||
@@ -188,7 +188,7 @@ class UiStore {
|
||||
* @returns true if the model is active.
|
||||
*/
|
||||
isModelActive(model: Model): boolean {
|
||||
return this.activeModels.has(model);
|
||||
return this.activeModels.has(model.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,7 +200,7 @@ class UiStore {
|
||||
clearActiveModels(modelClass?: new (...args: any[]) => Model): void {
|
||||
if (modelClass) {
|
||||
const modelsToRemove = this.getActiveModels(modelClass);
|
||||
modelsToRemove.forEach((model) => this.activeModels.delete(model));
|
||||
modelsToRemove.forEach((model) => this.activeModels.delete(model.id));
|
||||
} else {
|
||||
this.activeModels.clear();
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -63,7 +63,7 @@ declare global {
|
||||
/**
|
||||
* Set the badge on the app icon.
|
||||
*/
|
||||
setNotificationCount: (count: number) => Promise<void>;
|
||||
setNotificationCount: (count: number | string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Registers a callback to be called when the window is focused.
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ import backend from "i18next-http-backend";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { languages } from "@shared/i18n";
|
||||
import { unicodeCLDRtoBCP47, unicodeBCP47toCLDR } from "@shared/utils/date";
|
||||
import { cdnPath } from "@shared/utils/urls";
|
||||
import Logger from "./Logger";
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,7 @@ export function initI18n(defaultLanguage = "en_US") {
|
||||
// this must match the path defined in routes. It's the path that the
|
||||
// frontend UI code will hit to load missing translations.
|
||||
loadPath: (locale: string[]) =>
|
||||
`/locales/${unicodeBCP47toCLDR(locale[0])}.json`,
|
||||
cdnPath(`/locales/${unicodeBCP47toCLDR(locale[0])}.json`),
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
|
||||
@@ -296,6 +296,7 @@
|
||||
"@types/invariant": "^2.2.37",
|
||||
"@types/ioredis-mock": "^8.2.6",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/koa": "^2.15.0",
|
||||
|
||||
@@ -44,9 +44,10 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
|
||||
const client = new NotionClient(integration.authentication.token);
|
||||
|
||||
const parsedPages = await Promise.all(
|
||||
importTask.input.map(async (item) => this.processPage({ item, client }))
|
||||
);
|
||||
const parsedPages: (ParsePageOutput | null)[] = [];
|
||||
for (const item of importTask.input) {
|
||||
parsedPages.push(await this.processPage({ item, client }));
|
||||
}
|
||||
|
||||
// Filter out any null results (from pages/databases that couldn't be accessed)
|
||||
const validParsedPages = parsedPages.filter(Boolean) as ParsePageOutput[];
|
||||
|
||||
@@ -5,6 +5,10 @@ import allNodes from "@server/test/fixtures/notion-page.json";
|
||||
import type { NotionPage } from "./NotionConverter";
|
||||
import { NotionConverter } from "./NotionConverter";
|
||||
|
||||
jest.mock("node:crypto", () => ({
|
||||
randomUUID: jest.fn(() => "550e8400-e29b-41d4-a716-446655440000"),
|
||||
}));
|
||||
|
||||
describe("NotionConverter", () => {
|
||||
it("converts a page", () => {
|
||||
const response = NotionConverter.page({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
BookmarkBlockObjectResponse,
|
||||
BreadcrumbBlockObjectResponse,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
ImageBlockObjectResponse,
|
||||
EmbedBlockObjectResponse,
|
||||
TableBlockObjectResponse,
|
||||
TableOfContentsBlockObjectResponse,
|
||||
ToDoBlockObjectResponse,
|
||||
EquationBlockObjectResponse,
|
||||
CodeBlockObjectResponse,
|
||||
@@ -45,7 +47,7 @@ export class NotionConverter {
|
||||
* Nodes which cannot contain block children in Outline, their children
|
||||
* will be flattened into the parent.
|
||||
*/
|
||||
private static nodesWithoutBlockChildren = ["paragraph", "toggle"];
|
||||
private static nodesWithoutBlockChildren = ["paragraph"];
|
||||
|
||||
public static page(item: NotionPage): ProsemirrorDoc {
|
||||
return {
|
||||
@@ -66,6 +68,20 @@ export class NotionConverter {
|
||||
if (this[child.type]) {
|
||||
// @ts-expect-error Not all blocks have an interface
|
||||
const response = this[child.type](child);
|
||||
|
||||
// @ts-expect-error Not all blocks have an interface
|
||||
const canToggle = child[child.type].is_toggleable === true;
|
||||
|
||||
if (canToggle) {
|
||||
return {
|
||||
type: "container_toggle",
|
||||
attrs: {
|
||||
id: randomUUID(),
|
||||
},
|
||||
content: [response, ...this.mapChildren(child)],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
response &&
|
||||
this.nodesWithoutBlockChildren.includes(response.type) &&
|
||||
@@ -560,10 +576,23 @@ export class NotionConverter {
|
||||
};
|
||||
}
|
||||
|
||||
private static toggle(item: ToggleBlockObjectResponse) {
|
||||
private static table_of_contents(_: TableOfContentsBlockObjectResponse) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static toggle(item: Block<ToggleBlockObjectResponse>) {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: item.toggle.rich_text.map(this.rich_text).filter(Boolean),
|
||||
type: "container_toggle",
|
||||
attrs: {
|
||||
id: randomUUID(),
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: item.toggle.rich_text.map(this.rich_text).filter(Boolean),
|
||||
},
|
||||
...this.mapChildren(item),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -608,38 +608,105 @@ exports[`NotionConverter converts a page 1`] = `
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"level": 2,
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Toggleable heading",
|
||||
"type": "text",
|
||||
"attrs": {
|
||||
"level": 2,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Toggleable heading",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "heading",
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Some paragraph content within toggleable heading.",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"level": 3,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Toggleable heading inside toggleable heading",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "heading",
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Some paragraph content within toggleable heading, which is within another toggleable heading.",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "container_toggle",
|
||||
},
|
||||
],
|
||||
"type": "heading",
|
||||
"type": "container_toggle",
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"level": 2,
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Toggleable heading with a ",
|
||||
"type": "text",
|
||||
"attrs": {
|
||||
"level": 2,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Toggleable heading with a ",
|
||||
"type": "text",
|
||||
},
|
||||
{
|
||||
"text": "2025-03-11",
|
||||
"type": "text",
|
||||
},
|
||||
{
|
||||
"marks": [],
|
||||
"text": " mention.",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "heading",
|
||||
},
|
||||
{
|
||||
"text": "2025-03-11",
|
||||
"type": "text",
|
||||
},
|
||||
{
|
||||
"marks": [],
|
||||
"text": " mention.",
|
||||
"type": "text",
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Some paragraph content within toggleable heading with mention.",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "heading",
|
||||
"type": "container_toggle",
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
@@ -1875,53 +1942,69 @@ exports[`NotionConverter converts a page 1`] = `
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Toggle list item 1",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"style": "info",
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Callout inside toggle list item 1",
|
||||
"text": "Toggle list item 1",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"style": "info",
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Callout inside toggle list item 1",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "container_notice",
|
||||
},
|
||||
],
|
||||
"type": "container_toggle",
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Toggle list item 2",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Some content inside toggle list item 2",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "container_notice",
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Toggle list item 2",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [],
|
||||
"text": "Some content inside toggle list item 2",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
"type": "container_toggle",
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -145,7 +145,6 @@ describe("accountProvisioner", () => {
|
||||
expect(user.id).toEqual(userWithoutAuth.id);
|
||||
expect(isNewTeam).toEqual(false);
|
||||
expect(isNewUser).toEqual(false);
|
||||
expect(user.authentications.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should throw an error when authentication provider is disabled", async () => {
|
||||
|
||||
@@ -213,6 +213,32 @@ describe("documentImporter", () => {
|
||||
expect(response.title).toEqual("Title");
|
||||
});
|
||||
|
||||
it("should convert frontmatter to yaml codeblock", async () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "markdown-frontmatter.md";
|
||||
const content = await fs.readFile(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName),
|
||||
"utf8"
|
||||
);
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "text/plain",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.text).toContain("```yaml");
|
||||
expect(response.text).toContain("title: Test Document");
|
||||
expect(response.text).toContain("date: 2024-01-15");
|
||||
expect(response.text).toContain("tags: [test, markdown]");
|
||||
expect(response.text).toContain("```");
|
||||
expect(response.text).toContain("This is content after frontmatter");
|
||||
expect(response.title).toEqual("Heading 1");
|
||||
});
|
||||
|
||||
it("should fallback to extension if mimetype unknown", async () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "markdown.md";
|
||||
|
||||
@@ -121,10 +121,7 @@ export default async function userProvisioner(
|
||||
|
||||
// A `user` record may exist even if there is no existing authentication record.
|
||||
// This is either an invite or a user that's external to the team
|
||||
const existingUser = await User.scope([
|
||||
"withAuthentications",
|
||||
"withTeam",
|
||||
]).findOne({
|
||||
const existingUser = await User.scope(["withTeam"]).findOne({
|
||||
where: {
|
||||
// Email from auth providers may be capitalized
|
||||
email: {
|
||||
|
||||
+106
-1
@@ -1,4 +1,4 @@
|
||||
import { parser } from ".";
|
||||
import { parser, serializer } from ".";
|
||||
|
||||
test("renders an empty doc", () => {
|
||||
const ast = parser.parse("");
|
||||
@@ -8,3 +8,108 @@ test("renders an empty doc", () => {
|
||||
type: "doc",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses lowercase alpha lists", () => {
|
||||
const ast = parser.parse("a. First item\nb. Second item");
|
||||
|
||||
expect(ast?.toJSON()).toEqual({
|
||||
content: [
|
||||
{
|
||||
attrs: { listStyle: "lower-alpha", order: 1 },
|
||||
content: [
|
||||
{
|
||||
content: [{ content: [{ text: "First item", type: "text" }], type: "paragraph" }],
|
||||
type: "list_item",
|
||||
},
|
||||
{
|
||||
content: [{ content: [{ text: "Second item", type: "text" }], type: "paragraph" }],
|
||||
type: "list_item",
|
||||
},
|
||||
],
|
||||
type: "ordered_list",
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses uppercase alpha lists", () => {
|
||||
const ast = parser.parse("A. First item\nB. Second item");
|
||||
|
||||
expect(ast?.toJSON()).toEqual({
|
||||
content: [
|
||||
{
|
||||
attrs: { listStyle: "upper-alpha", order: 1 },
|
||||
content: [
|
||||
{
|
||||
content: [{ content: [{ text: "First item", type: "text" }], type: "paragraph" }],
|
||||
type: "list_item",
|
||||
},
|
||||
{
|
||||
content: [{ content: [{ text: "Second item", type: "text" }], type: "paragraph" }],
|
||||
type: "list_item",
|
||||
},
|
||||
],
|
||||
type: "ordered_list",
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses alpha lists with blank lines (issue example)", () => {
|
||||
const markdown = `## 3. Step Three
|
||||
|
||||
a. Do this.
|
||||
|
||||
b. Do that.`;
|
||||
|
||||
const ast = parser.parse(markdown);
|
||||
const json = ast?.toJSON();
|
||||
|
||||
// Find the ordered_list in the result
|
||||
const orderedList = json?.content?.find((node: any) => node.type === "ordered_list");
|
||||
|
||||
expect(orderedList).toBeDefined();
|
||||
expect(orderedList?.attrs.listStyle).toBe("lower-alpha");
|
||||
expect(orderedList?.attrs.order).toBe(1);
|
||||
expect(orderedList?.content).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("preserves numeric lists", () => {
|
||||
const ast = parser.parse("1. First item\n2. Second item");
|
||||
|
||||
expect(ast?.toJSON()).toEqual({
|
||||
content: [
|
||||
{
|
||||
attrs: { listStyle: "number", order: 1 },
|
||||
content: [
|
||||
{
|
||||
content: [{ content: [{ text: "First item", type: "text" }], type: "paragraph" }],
|
||||
type: "list_item",
|
||||
},
|
||||
{
|
||||
content: [{ content: [{ text: "Second item", type: "text" }], type: "paragraph" }],
|
||||
type: "list_item",
|
||||
},
|
||||
],
|
||||
type: "ordered_list",
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
});
|
||||
});
|
||||
|
||||
test("serializes lowercase alpha lists back to markdown", () => {
|
||||
const ast = parser.parse("a. First item\nb. Second item");
|
||||
const output = serializer.serialize(ast);
|
||||
|
||||
expect(output.trim()).toBe("a. First item\nb. Second item");
|
||||
});
|
||||
|
||||
test("serializes uppercase alpha lists back to markdown", () => {
|
||||
const ast = parser.parse("A. First item\nB. Second item");
|
||||
const output = serializer.serialize(ast);
|
||||
|
||||
expect(output.trim()).toBe("A. First item\nB. Second item");
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ import { CollectionValidation } from "@shared/validations";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import type { APIContext } from "@server/types";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
|
||||
import removeIndexCollision from "@server/utils/removeIndexCollision";
|
||||
import { generateUrlId } from "@server/utils/url";
|
||||
import { ValidateIndex } from "@server/validation";
|
||||
@@ -347,7 +348,7 @@ class Collection extends ParanoidModel<
|
||||
}
|
||||
if (model.changed("documentStructure")) {
|
||||
await CacheHelper.clearData(
|
||||
CacheHelper.getCollectionDocumentsKey(model.id)
|
||||
RedisPrefixHelper.getCollectionDocumentsKey(model.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -360,7 +361,7 @@ class Collection extends ParanoidModel<
|
||||
if (model.changed("documentStructure")) {
|
||||
const setData = () =>
|
||||
CacheHelper.setData(
|
||||
CacheHelper.getCollectionDocumentsKey(model.id),
|
||||
RedisPrefixHelper.getCollectionDocumentsKey(model.id),
|
||||
model.documentStructure,
|
||||
60
|
||||
);
|
||||
|
||||
+10
-5
@@ -411,7 +411,10 @@ class User extends ParanoidModel<
|
||||
* @param value Sets the preference value
|
||||
* @returns The current user preferences
|
||||
*/
|
||||
public setPreference = (preference: UserPreference, value: boolean) => {
|
||||
public setPreference = <K extends UserPreference>(
|
||||
preference: K,
|
||||
value: NonNullable<UserPreferences[K]>
|
||||
) => {
|
||||
if (!this.preferences) {
|
||||
this.preferences = {};
|
||||
}
|
||||
@@ -428,10 +431,12 @@ class User extends ParanoidModel<
|
||||
* @param preference The user preference to retrieve
|
||||
* @returns The preference value if set, else the default value.
|
||||
*/
|
||||
public getPreference = (preference: UserPreference) =>
|
||||
this.preferences?.[preference] ??
|
||||
UserPreferenceDefaults[preference] ??
|
||||
false;
|
||||
public getPreference = <K extends UserPreference>(
|
||||
preference: K
|
||||
): NonNullable<UserPreferences[K]> =>
|
||||
(this.preferences?.[preference] ??
|
||||
UserPreferenceDefaults[preference] ??
|
||||
false) as NonNullable<UserPreferences[K]>;
|
||||
|
||||
/**
|
||||
* Returns the user's active groups.
|
||||
|
||||
@@ -481,7 +481,7 @@ export class ProsemirrorHelper {
|
||||
<>
|
||||
{options?.title && <h1 dir={rtl ? "rtl" : "ltr"}>{options.title}</h1>}
|
||||
{options?.includeStyles !== false ? (
|
||||
<EditorContainer dir={rtl ? "rtl" : "ltr"} rtl={rtl} staticHTML>
|
||||
<EditorContainer dir={rtl ? "rtl" : "ltr"} $rtl={rtl} staticHTML>
|
||||
{content}
|
||||
</EditorContainer>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Integration } from "@server/models";
|
||||
import BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import type { IntegrationEvent, Event } from "@server/types";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
|
||||
import CacheIssueSourcesTask from "../tasks/CacheIssueSourcesTask";
|
||||
|
||||
export default class IntegrationCreatedProcessor extends BaseProcessor {
|
||||
@@ -25,6 +26,6 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
|
||||
});
|
||||
|
||||
// Clear the cache of unfurled data for the team as it may be stale now.
|
||||
await CacheHelper.clearData(CacheHelper.getUnfurlKey(integration.teamId));
|
||||
await CacheHelper.clearData(RedisPrefixHelper.getUnfurlKey(integration.teamId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Integration } from "@server/models";
|
||||
import BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import type { IntegrationEvent, Event } from "@server/types";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
|
||||
export default class IntegrationDeletedProcessor extends BaseProcessor {
|
||||
@@ -26,7 +27,7 @@ export default class IntegrationDeletedProcessor extends BaseProcessor {
|
||||
|
||||
// Clear the cache of unfurled data for the team as it may be stale now.
|
||||
if (integration.type === IntegrationType.Embed) {
|
||||
await CacheHelper.clearData(CacheHelper.getUnfurlKey(integration.teamId));
|
||||
await CacheHelper.clearData(RedisPrefixHelper.getUnfurlKey(integration.teamId));
|
||||
}
|
||||
|
||||
await integration.destroy({ force: true });
|
||||
|
||||
@@ -327,11 +327,7 @@ export default abstract class APIImportTask<
|
||||
const uploadItems = Object.entries(urlToAttachment).map(
|
||||
([url, attachment]) => ({ attachmentId: attachment.id, url })
|
||||
);
|
||||
// publish task after attachments are persisted in DB.
|
||||
const job = await new UploadAttachmentsForImportTask().schedule(
|
||||
uploadItems
|
||||
);
|
||||
await job.finished();
|
||||
await new UploadAttachmentsForImportTask().schedule(uploadItems);
|
||||
} catch (err) {
|
||||
// upload attachments failure is not critical enough to fail the whole import.
|
||||
Logger.error(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { MentionType, NotificationEventType } from "@shared/types";
|
||||
import { Notification } from "@server/models";
|
||||
import {
|
||||
buildDocument,
|
||||
buildCollection,
|
||||
buildGroup,
|
||||
buildGroupUser,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import DocumentPublishedNotificationsTask from "./DocumentPublishedNotificationsTask";
|
||||
@@ -119,4 +122,57 @@ describe("documents.publish", () => {
|
||||
});
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not send a notification for group mentions when disableMentions is true", async () => {
|
||||
const spy = jest.spyOn(Notification, "create");
|
||||
const actor = await buildUser();
|
||||
const group = await buildGroup({
|
||||
teamId: actor.teamId,
|
||||
disableMentions: true,
|
||||
});
|
||||
const member = await buildUser({ teamId: actor.teamId });
|
||||
await buildGroupUser({ groupId: group.id, userId: member.id });
|
||||
|
||||
member.setNotificationEventType(
|
||||
NotificationEventType.GroupMentionedInDocument
|
||||
);
|
||||
await member.save();
|
||||
|
||||
const document = await buildDocument({
|
||||
teamId: actor.teamId,
|
||||
userId: actor.id,
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Group,
|
||||
label: group.name,
|
||||
modelId: group.id,
|
||||
actorId: actor.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const processor = new DocumentPublishedNotificationsTask();
|
||||
await processor.perform({
|
||||
name: "documents.publish",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId,
|
||||
actorId: actor.id,
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,13 +230,21 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
|
||||
format: FileOperationFormat
|
||||
) {
|
||||
const map = new Map<string, string>();
|
||||
const usedRoots = new Set<string>();
|
||||
|
||||
for (const collection of collections) {
|
||||
if (collection.documentStructure) {
|
||||
let root = serializeFilename(collection.name);
|
||||
let i = 0;
|
||||
while (usedRoots.has(root)) {
|
||||
root = `${serializeFilename(collection.name)} (${++i})`;
|
||||
}
|
||||
usedRoots.add(root);
|
||||
|
||||
this.addDocumentTreeToPathMap(
|
||||
map,
|
||||
collection.documentStructure,
|
||||
serializeFilename(collection.name),
|
||||
root,
|
||||
format
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,13 +20,22 @@ export default class ExportJSONTask extends ExportTask {
|
||||
fileOperation: FileOperation
|
||||
) {
|
||||
const zip = new JSZip();
|
||||
const usedFilenames = new Set<string>();
|
||||
|
||||
// serial to avoid overloading, slow and steady wins the race
|
||||
for (const collection of collections) {
|
||||
let filename = serializeFilename(collection.name);
|
||||
let i = 0;
|
||||
while (usedFilenames.has(filename)) {
|
||||
filename = `${serializeFilename(collection.name)} (${++i})`;
|
||||
}
|
||||
usedFilenames.add(filename);
|
||||
|
||||
await this.addCollectionToArchive(
|
||||
zip,
|
||||
collection,
|
||||
fileOperation.options?.includeAttachments ?? true
|
||||
fileOperation.options?.includeAttachments ?? true,
|
||||
filename
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +66,8 @@ export default class ExportJSONTask extends ExportTask {
|
||||
private async addCollectionToArchive(
|
||||
zip: JSZip,
|
||||
collection: Collection,
|
||||
includeAttachments: boolean
|
||||
includeAttachments: boolean,
|
||||
filename: string
|
||||
) {
|
||||
const output: CollectionJSONExport = {
|
||||
collection: {
|
||||
@@ -167,7 +177,7 @@ export default class ExportJSONTask extends ExportTask {
|
||||
}
|
||||
|
||||
zip.file(
|
||||
`${serializeFilename(collection.name)}.json`,
|
||||
`${filename}.json`,
|
||||
env.isDevelopment
|
||||
? JSON.stringify(output, null, 2)
|
||||
: JSON.stringify(output)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { DeepPartial } from "utility-types";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { MentionType, NotificationEventType } from "@shared/types";
|
||||
import { createContext } from "@server/context";
|
||||
import { parser } from "@server/editor";
|
||||
import type { Document } from "@server/models";
|
||||
@@ -8,7 +12,12 @@ import {
|
||||
Notification,
|
||||
Revision,
|
||||
} from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import {
|
||||
buildDocument,
|
||||
buildGroup,
|
||||
buildGroupUser,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import RevisionCreatedNotificationsTask from "./RevisionCreatedNotificationsTask";
|
||||
|
||||
const ip = "127.0.0.1";
|
||||
@@ -514,4 +523,136 @@ describe("revisions.create", () => {
|
||||
});
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should send a mention notification even when change is below threshold", async () => {
|
||||
const spy = jest.spyOn(Notification, "create");
|
||||
const actor = await buildUser();
|
||||
const mentioned = await buildUser({ teamId: actor.teamId, name: "Kim" });
|
||||
|
||||
// Build a document with some initial content
|
||||
let document = await buildDocument({
|
||||
teamId: actor.teamId,
|
||||
userId: actor.id,
|
||||
});
|
||||
await Revision.createFromDocument(createContext({ user: actor }), document);
|
||||
|
||||
// Now add a mention – the only change is the mention node itself, which
|
||||
// renders as "@<label>" in plain text and may be below the 5-char
|
||||
// threshold that gates generic update notifications.
|
||||
const mentionContent: DeepPartial<ProsemirrorData> = {
|
||||
type: "doc",
|
||||
content: [
|
||||
...(document.content?.content ?? []),
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
type: MentionType.User,
|
||||
label: mentioned.name,
|
||||
modelId: mentioned.id,
|
||||
actorId: actor.id,
|
||||
id: "test-mention-id",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
document.content = mentionContent as ProsemirrorData;
|
||||
document.updatedAt = new Date();
|
||||
await document.save();
|
||||
|
||||
const revision = await Revision.createFromDocument(
|
||||
createContext({ user: actor }),
|
||||
document
|
||||
);
|
||||
|
||||
const task = new RevisionCreatedNotificationsTask();
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
actorId: actor.id,
|
||||
modelId: revision.id,
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: NotificationEventType.MentionedInDocument,
|
||||
userId: mentioned.id,
|
||||
actorId: actor.id,
|
||||
documentId: document.id,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should not send a notification for group mentions when disableMentions is true", async () => {
|
||||
const spy = jest.spyOn(Notification, "create");
|
||||
const actor = await buildUser();
|
||||
const group = await buildGroup({
|
||||
teamId: actor.teamId,
|
||||
disableMentions: true,
|
||||
});
|
||||
const member = await buildUser({ teamId: actor.teamId });
|
||||
await buildGroupUser({ groupId: group.id, userId: member.id });
|
||||
|
||||
member.setNotificationEventType(
|
||||
NotificationEventType.GroupMentionedInDocument
|
||||
);
|
||||
await member.save();
|
||||
|
||||
let document = await buildDocument({
|
||||
teamId: actor.teamId,
|
||||
userId: actor.id,
|
||||
});
|
||||
await Revision.createFromDocument(createContext({ user: actor }), document);
|
||||
|
||||
// Update document to include a group mention
|
||||
document.content = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Updated content with a group mention ",
|
||||
},
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Group,
|
||||
label: group.name,
|
||||
modelId: group.id,
|
||||
actorId: actor.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
document.updatedAt = new Date();
|
||||
await document.save();
|
||||
|
||||
const revision = await Revision.createFromDocument(
|
||||
createContext({ user: actor }),
|
||||
document
|
||||
);
|
||||
|
||||
const task = new RevisionCreatedNotificationsTask();
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
actorId: actor.id,
|
||||
modelId: revision.id,
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import {
|
||||
Document,
|
||||
Group,
|
||||
Revision,
|
||||
Notification,
|
||||
User,
|
||||
@@ -34,16 +35,8 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
|
||||
|
||||
const before = await revision.before();
|
||||
|
||||
// If the content looks the same, don't send notifications
|
||||
if (!DocumentHelper.isChangeOverThreshold(before, revision, 5)) {
|
||||
Logger.info(
|
||||
"processor",
|
||||
`suppressing notifications as update has insignificant changes`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send notifications to mentioned users first
|
||||
// Send notifications to mentioned users first – these must be processed
|
||||
// regardless of the change threshold as even a small edit can add a mention.
|
||||
const oldMentions = before
|
||||
? [...DocumentHelper.parseMentions(before, { type: MentionType.User })]
|
||||
: [];
|
||||
@@ -83,7 +76,7 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
|
||||
}
|
||||
}
|
||||
|
||||
// send notifications to users in mentioned groups
|
||||
// Send notifications to users in mentioned groups
|
||||
const oldGroupMentions = before
|
||||
? DocumentHelper.parseMentions(before, { type: MentionType.Group })
|
||||
: [];
|
||||
@@ -101,6 +94,13 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
|
||||
if (mentionedGroup.includes(group.modelId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the group has mentions disabled
|
||||
const groupModel = await Group.findByPk(group.modelId);
|
||||
if (groupModel?.disableMentions) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const usersFromMentionedGroup = await GroupUser.findAll({
|
||||
where: {
|
||||
groupId: group.modelId,
|
||||
@@ -140,6 +140,16 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
|
||||
mentionedGroup.push(group.modelId);
|
||||
}
|
||||
|
||||
// If the content change is insignificant, don't send generic update
|
||||
// notifications (mention notifications above are still sent).
|
||||
if (!DocumentHelper.isChangeOverThreshold(before, revision, 5)) {
|
||||
Logger.info(
|
||||
"processor",
|
||||
`suppressing update notifications as change has insignificant edits`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const recipients = (
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
|
||||
@@ -216,152 +216,6 @@ describe("#collections.list", () => {
|
||||
expect(afterArchiveRes.status).toEqual(200);
|
||||
expect(afterArchiveBody.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("includeListOnly parameter", () => {
|
||||
it("should restrict regular users to their collections with includeListOnly=true", async () => {
|
||||
const team = await buildTeam();
|
||||
const regularUser = await buildUser({ teamId: team.id });
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
|
||||
// Create a public collection that regularUser can access
|
||||
const publicCollection = await buildCollection({
|
||||
userId: regularUser.id,
|
||||
teamId: team.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
});
|
||||
|
||||
// Create a private collection that regularUser cannot access
|
||||
const privateCollection = await buildCollection({
|
||||
userId: admin.id,
|
||||
teamId: team.id,
|
||||
permission: null, // private collection
|
||||
});
|
||||
|
||||
// Regular user tries to list with includeListOnly=true
|
||||
const res = await server.post("/api/collections.list", {
|
||||
body: {
|
||||
token: regularUser.getJwtToken(),
|
||||
includeListOnly: true,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
// Should only see the public collection they have access to
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(publicCollection.id);
|
||||
// Should NOT see the private collection
|
||||
expect(body.data.find((c: any) => c.id === privateCollection.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should allow admins to see all collections with includeListOnly=false", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const regularUser = await buildUser({ teamId: team.id });
|
||||
|
||||
// Create a public collection
|
||||
const publicCollection = await buildCollection({
|
||||
userId: admin.id,
|
||||
teamId: team.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
});
|
||||
|
||||
// Create a private collection that admin doesn't have explicit membership to
|
||||
const privateCollection = await buildCollection({
|
||||
userId: regularUser.id,
|
||||
teamId: team.id,
|
||||
permission: null, // private collection
|
||||
});
|
||||
|
||||
// Admin lists with includeListOnly=false
|
||||
const res = await server.post("/api/collections.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
includeListOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
// Admin should see ALL collections in the team
|
||||
expect(body.data.length).toEqual(2);
|
||||
const collectionIds = body.data.map((c: any) => c.id);
|
||||
expect(collectionIds).toContain(publicCollection.id);
|
||||
expect(collectionIds).toContain(privateCollection.id);
|
||||
});
|
||||
|
||||
it("should restrict admins to their collections with includeListOnly=true", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const regularUser = await buildUser({ teamId: team.id });
|
||||
|
||||
// Create a public collection that admin can access
|
||||
const publicCollection = await buildCollection({
|
||||
userId: admin.id,
|
||||
teamId: team.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
});
|
||||
|
||||
// Create a private collection that admin doesn't have membership to
|
||||
const privateCollection = await buildCollection({
|
||||
userId: regularUser.id,
|
||||
teamId: team.id,
|
||||
permission: null, // private collection
|
||||
});
|
||||
|
||||
// Admin lists with includeListOnly=true
|
||||
const res = await server.post("/api/collections.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
includeListOnly: true,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
// Admin should only see collections they have access to when includeListOnly=true
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(publicCollection.id);
|
||||
// Should NOT see the private collection without membership
|
||||
expect(body.data.find((c: any) => c.id === privateCollection.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should restrict regular users to their collections with includeListOnly=false (default)", async () => {
|
||||
const team = await buildTeam();
|
||||
const regularUser = await buildUser({ teamId: team.id });
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
|
||||
// Create a public collection that regularUser can access
|
||||
const publicCollection = await buildCollection({
|
||||
userId: regularUser.id,
|
||||
teamId: team.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
});
|
||||
|
||||
// Create a private collection that regularUser cannot access
|
||||
const privateCollection = await buildCollection({
|
||||
userId: admin.id,
|
||||
teamId: team.id,
|
||||
permission: null, // private collection
|
||||
});
|
||||
|
||||
// Regular user tries to list with includeListOnly=false (default)
|
||||
const res = await server.post("/api/collections.list", {
|
||||
body: {
|
||||
token: regularUser.getJwtToken(),
|
||||
includeListOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
// Should only see the public collection they have access to (still restricted because not admin)
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(publicCollection.id);
|
||||
// Should NOT see the private collection
|
||||
expect(body.data.find((c: any) => c.id === privateCollection.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("#collections.import", () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@server/presenters";
|
||||
import type { APIContext } from "@server/types";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
|
||||
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import { collectionIndexing } from "@server/utils/indexing";
|
||||
import pagination from "../middlewares/pagination";
|
||||
@@ -143,7 +144,7 @@ router.post(
|
||||
authorize(user, "readDocument", collection);
|
||||
|
||||
const documentStructure = await CacheHelper.getDataOrSet(
|
||||
CacheHelper.getCollectionDocumentsKey(collection.id),
|
||||
RedisPrefixHelper.getCollectionDocumentsKey(collection.id),
|
||||
async () =>
|
||||
(
|
||||
await Collection.findByPk(collection.id, {
|
||||
@@ -733,7 +734,7 @@ router.post(
|
||||
where[Op.and].push({ archivedAt: { [Op.eq]: null } });
|
||||
}
|
||||
|
||||
if (includeListOnly || !user.isAdmin) {
|
||||
if (!includeListOnly || !user.isAdmin) {
|
||||
where[Op.and].push({ id: collectionIds });
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { authorize, can } from "@server/policies";
|
||||
import presentUnfurl from "@server/presenters/unfurl";
|
||||
import type { APIContext, Unfurl } from "@server/types";
|
||||
import { CacheHelper, type CacheResult } from "@server/utils/CacheHelper";
|
||||
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import {
|
||||
@@ -134,7 +135,7 @@ router.post(
|
||||
// External resources
|
||||
// Use getDataOrSet which handles distributed locking to prevent thundering herd
|
||||
// when multiple clients request the same URL simultaneously
|
||||
const cacheKey = CacheHelper.getUnfurlKey(actor.teamId, url);
|
||||
const cacheKey = RedisPrefixHelper.getUnfurlKey(actor.teamId, url);
|
||||
const defaultCacheExpiry = 3600;
|
||||
|
||||
const unfurlResult = await CacheHelper.getDataOrSet<
|
||||
@@ -186,7 +187,7 @@ router.post(
|
||||
const { url } = ctx.input.body;
|
||||
|
||||
const result = await CacheHelper.getDataOrSet<EmbedCheckResult>(
|
||||
CacheHelper.getEmbedCheckKey(url),
|
||||
RedisPrefixHelper.getEmbedCheckKey(url),
|
||||
() => checkEmbeddability(url),
|
||||
Day.seconds
|
||||
);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { NotificationEventType, UserPreference, UserRole } from "@shared/types";
|
||||
import {
|
||||
NotificationBadgeType,
|
||||
NotificationEventType,
|
||||
UserPreference,
|
||||
UserRole,
|
||||
} from "@shared/types";
|
||||
import { locales } from "@shared/utils/date";
|
||||
import User from "@server/models/User";
|
||||
import { zodEnumFromObjectKeys, zodTimezone } from "@server/utils/zod";
|
||||
@@ -90,7 +95,12 @@ export const UsersUpdateSchema = BaseSchema.extend({
|
||||
name: z.string().optional(),
|
||||
avatarUrl: z.string().nullish(),
|
||||
language: zodEnumFromObjectKeys(locales).optional(),
|
||||
preferences: z.record(z.nativeEnum(UserPreference), z.boolean()).optional(),
|
||||
preferences: z
|
||||
.record(
|
||||
z.nativeEnum(UserPreference),
|
||||
z.union([z.boolean(), z.nativeEnum(NotificationBadgeType)])
|
||||
)
|
||||
.optional(),
|
||||
timezone: zodTimezone().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Router from "koa-router";
|
||||
import type { WhereOptions } from "sequelize";
|
||||
import { Op, Sequelize } from "sequelize";
|
||||
import type { UserPreference } from "@shared/types";
|
||||
import type { UserPreferences } from "@shared/types";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
|
||||
import { settingsPath } from "@shared/utils/routeHelpers";
|
||||
@@ -332,9 +332,10 @@ router.post(
|
||||
user.language = language;
|
||||
}
|
||||
if (preferences) {
|
||||
for (const key of Object.keys(preferences) as Array<UserPreference>) {
|
||||
user.setPreference(key, preferences[key] as boolean);
|
||||
}
|
||||
user.preferences = {
|
||||
...user.preferences,
|
||||
...(preferences as UserPreferences),
|
||||
};
|
||||
}
|
||||
if (timezone) {
|
||||
user.timezone = timezone;
|
||||
|
||||
@@ -9,6 +9,7 @@ import env from "@server/env";
|
||||
import type Model from "@server/models/base/Model";
|
||||
import Logger from "../logging/Logger";
|
||||
import * as models from "../models";
|
||||
import { getConnectionName } from "./utils";
|
||||
|
||||
/**
|
||||
* Returns database configuration for Sequelize constructor.
|
||||
@@ -64,6 +65,7 @@ export function createDatabaseInstance(
|
||||
typeValidation: true,
|
||||
logQueryParameters: env.isDevelopment,
|
||||
dialectOptions: {
|
||||
application_name: getConnectionName(),
|
||||
ssl:
|
||||
env.isProduction && !isSSLDisabled
|
||||
? {
|
||||
|
||||
@@ -37,7 +37,7 @@ export default class S3Storage extends BaseStorage {
|
||||
public async getPresignedPost(
|
||||
_ctx: AppContext,
|
||||
key: string,
|
||||
acl: string,
|
||||
_acl: string,
|
||||
maxUploadSize: number,
|
||||
contentType = "image"
|
||||
) {
|
||||
@@ -52,7 +52,7 @@ export default class S3Storage extends BaseStorage {
|
||||
Fields: {
|
||||
"Content-Disposition": this.getContentDisposition(contentType),
|
||||
key,
|
||||
...(acl && { acl }),
|
||||
...(env.AWS_S3_ACL && { ACL: env.AWS_S3_ACL as ObjectCannedACL }),
|
||||
},
|
||||
Expires: 3600,
|
||||
};
|
||||
@@ -103,7 +103,6 @@ export default class S3Storage extends BaseStorage {
|
||||
body,
|
||||
contentType,
|
||||
key,
|
||||
acl,
|
||||
}: {
|
||||
body: Buffer | Uint8Array | string | Readable;
|
||||
contentLength?: number;
|
||||
@@ -114,7 +113,7 @@ export default class S3Storage extends BaseStorage {
|
||||
const upload = new Upload({
|
||||
client: this.client,
|
||||
params: {
|
||||
...(acl && { ACL: acl as ObjectCannedACL }),
|
||||
...(env.AWS_S3_ACL && { ACL: env.AWS_S3_ACL as ObjectCannedACL }),
|
||||
Bucket: this.getBucket(),
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
|
||||
@@ -3,6 +3,7 @@ import Redis from "ioredis";
|
||||
import defaults from "lodash/defaults";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { getConnectionName } from "./utils";
|
||||
|
||||
type RedisAdapterOptions = RedisOptions & {
|
||||
/** Suffix to append to the connection name that will be displayed in Redis */
|
||||
@@ -42,14 +43,7 @@ export default class RedisAdapter extends Redis {
|
||||
url: string | undefined,
|
||||
{ connectionNameSuffix, ...options }: RedisAdapterOptions = {}
|
||||
) {
|
||||
/**
|
||||
* For debugging. The connection name is based on the services running in
|
||||
* this process. Note that this does not need to be unique.
|
||||
*/
|
||||
const connectionNamePrefix = env.isDevelopment ? process.pid : "outline";
|
||||
const connectionName =
|
||||
`${connectionNamePrefix}:${env.SERVICES.join("-")}` +
|
||||
(connectionNameSuffix ? `:${connectionNameSuffix}` : "");
|
||||
const connectionName = getConnectionName(connectionNameSuffix);
|
||||
|
||||
if (!url || !url.startsWith("ioredis://")) {
|
||||
super(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import env from "@server/env";
|
||||
|
||||
/**
|
||||
* For debugging. The connection name is based on the services running in
|
||||
* this process. Note that this does not need to be unique.
|
||||
*/
|
||||
export const getConnectionName = (connectionNameSuffix?: string) => {
|
||||
const connectionNamePrefix = env.isDevelopment ? process.pid : "outline";
|
||||
return (
|
||||
`${connectionNamePrefix}:${env.SERVICES.join("-")}` +
|
||||
(connectionNameSuffix ? `:${connectionNameSuffix}` : "")
|
||||
);
|
||||
};
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
title: Test Document
|
||||
date: 2024-01-15
|
||||
tags: [test, markdown]
|
||||
author: John Doe
|
||||
---
|
||||
|
||||
# Heading 1
|
||||
|
||||
This is content after frontmatter.
|
||||
|
||||
## Heading 2
|
||||
|
||||
More content here.
|
||||
@@ -141,31 +141,4 @@ export class CacheHelper {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// keys
|
||||
|
||||
/**
|
||||
* Gets key against which unfurl response for the given url is stored
|
||||
*
|
||||
* @param teamId The team ID to generate a key for
|
||||
* @param url The url to generate a key for
|
||||
*/
|
||||
public static getUnfurlKey(teamId: string, url = "") {
|
||||
return `unfurl:${teamId}:${url}`;
|
||||
}
|
||||
|
||||
public static getCollectionDocumentsKey(collectionId: string) {
|
||||
return `cd:${collectionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets key for caching embed check results. This is a global cache key
|
||||
* (not team-specific) since embed headers are the same for all users.
|
||||
*
|
||||
* @param url The URL to generate a cache key for.
|
||||
* @returns the cache key string.
|
||||
*/
|
||||
public static getEmbedCheckKey(url: string) {
|
||||
return `embed:${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,114 @@ Jane,24,`;
|
||||
expect(result.title).toEqual("");
|
||||
expect(result.text).toContain("Subtitle");
|
||||
});
|
||||
|
||||
it("should convert frontmatter to yaml codeblock", async () => {
|
||||
const md = `---
|
||||
title: Test Document
|
||||
date: 2024-01-15
|
||||
tags: [test, markdown]
|
||||
---
|
||||
|
||||
# My Title
|
||||
|
||||
Content after frontmatter`;
|
||||
const result = await DocumentConverter.convert(
|
||||
md,
|
||||
"test.md",
|
||||
"text/markdown"
|
||||
);
|
||||
|
||||
// Frontmatter should be converted to a YAML codeblock
|
||||
expect(result.text).toContain("```yaml");
|
||||
expect(result.text).toContain("title: Test Document");
|
||||
expect(result.text).toContain("date: 2024-01-15");
|
||||
expect(result.text).toContain("tags: [test, markdown]");
|
||||
expect(result.text).toContain("```");
|
||||
// Content should still be present
|
||||
expect(result.text).toContain("Content after frontmatter");
|
||||
// H1 should be extracted as title
|
||||
expect(result.title).toEqual("My Title");
|
||||
});
|
||||
|
||||
it("should handle markdown without frontmatter", async () => {
|
||||
const md = "# Title\n\nRegular content";
|
||||
const result = await DocumentConverter.convert(
|
||||
md,
|
||||
"test.md",
|
||||
"text/markdown"
|
||||
);
|
||||
|
||||
expect(result.title).toEqual("Title");
|
||||
expect(result.text).toContain("Regular content");
|
||||
expect(result.text).not.toContain("```yaml");
|
||||
});
|
||||
|
||||
it("should handle frontmatter with no content after", async () => {
|
||||
const md = `---
|
||||
title: Only Frontmatter
|
||||
---`;
|
||||
const result = await DocumentConverter.convert(
|
||||
md,
|
||||
"test.md",
|
||||
"text/markdown"
|
||||
);
|
||||
|
||||
expect(result.text).toContain("```yaml");
|
||||
expect(result.text).toContain("title: Only Frontmatter");
|
||||
expect(result.text).toContain("```");
|
||||
expect(result.title).toEqual("");
|
||||
});
|
||||
|
||||
it("should not convert incomplete frontmatter", async () => {
|
||||
const md = `---
|
||||
title: Test
|
||||
Content without closing delimiter`;
|
||||
const result = await DocumentConverter.convert(
|
||||
md,
|
||||
"test.md",
|
||||
"text/markdown"
|
||||
);
|
||||
|
||||
// Should not convert as it's not proper frontmatter
|
||||
expect(result.text).not.toContain("```yaml");
|
||||
expect(result.text).toContain("title: Test");
|
||||
});
|
||||
|
||||
it("should not convert frontmatter if not at start", async () => {
|
||||
const md = `# Title
|
||||
|
||||
Some content
|
||||
|
||||
---
|
||||
title: Test
|
||||
---
|
||||
|
||||
More content`;
|
||||
const result = await DocumentConverter.convert(
|
||||
md,
|
||||
"test.md",
|
||||
"text/markdown"
|
||||
);
|
||||
|
||||
// Should not convert as frontmatter must be at the start
|
||||
expect(result.text).not.toContain("```yaml");
|
||||
});
|
||||
|
||||
it("should handle invalid YAML in frontmatter", async () => {
|
||||
const md = `---
|
||||
invalid: yaml: content: here
|
||||
---
|
||||
|
||||
Content`;
|
||||
const result = await DocumentConverter.convert(
|
||||
md,
|
||||
"test.md",
|
||||
"text/markdown"
|
||||
);
|
||||
|
||||
// Should not convert invalid YAML
|
||||
expect(result.text).not.toContain("```yaml");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { simpleParser } from "mailparser";
|
||||
import mammoth from "mammoth";
|
||||
import type { Node } from "prosemirror-model";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import yaml from "js-yaml";
|
||||
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { schema, serializer } from "@server/editor";
|
||||
import { FileImportError } from "@server/errors";
|
||||
@@ -201,24 +202,30 @@ export class DocumentConverter {
|
||||
fileName: string,
|
||||
mimeType: string
|
||||
): Promise<string> {
|
||||
let markdown: string;
|
||||
|
||||
switch (mimeType) {
|
||||
case "text/plain":
|
||||
case "text/markdown":
|
||||
return this.bufferToString(content);
|
||||
markdown = this.bufferToString(content);
|
||||
break;
|
||||
case "text/csv":
|
||||
return this.csvToMarkdown(content);
|
||||
default:
|
||||
break;
|
||||
default: {
|
||||
const extension = fileName.split(".").pop();
|
||||
switch (extension) {
|
||||
case "md":
|
||||
case "markdown":
|
||||
markdown = this.bufferToString(content);
|
||||
break;
|
||||
default:
|
||||
throw FileImportError(`File type ${mimeType} not supported`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const extension = fileName.split(".").pop();
|
||||
switch (extension) {
|
||||
case "md":
|
||||
case "markdown":
|
||||
return this.bufferToString(content);
|
||||
default:
|
||||
throw FileImportError(`File type ${mimeType} not supported`);
|
||||
}
|
||||
// Process frontmatter and convert it to a YAML codeblock
|
||||
return this.processFrontmatter(markdown);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,4 +411,37 @@ export class DocumentConverter {
|
||||
private static bufferToString(content: Buffer | string): string {
|
||||
return typeof content === "string" ? content : content.toString("utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and convert frontmatter to a YAML codeblock.
|
||||
*
|
||||
* @param content The markdown content that may contain frontmatter.
|
||||
* @returns The markdown content with frontmatter converted to a YAML codeblock.
|
||||
*/
|
||||
private static processFrontmatter(content: string): string {
|
||||
// Frontmatter must start at the beginning of the document
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---(?:\n|$)/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const frontmatterContent = match[1];
|
||||
const remainingContent = content.slice(match[0].length);
|
||||
|
||||
// Validate that the frontmatter is valid YAML
|
||||
try {
|
||||
yaml.load(frontmatterContent);
|
||||
} catch {
|
||||
// If it's not valid YAML, return content unchanged
|
||||
return content;
|
||||
}
|
||||
|
||||
// Convert frontmatter to a YAML codeblock
|
||||
const codeBlockDelimiter = "```";
|
||||
const yamlCodeblock = `${codeBlockDelimiter}yaml\n${frontmatterContent}\n${codeBlockDelimiter}\n\n`;
|
||||
|
||||
return yamlCodeblock + remainingContent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { RedisPrefixHelper } from "./RedisPrefixHelper";
|
||||
|
||||
describe("RedisPrefixHelper", () => {
|
||||
describe("getUnfurlKey", () => {
|
||||
it("should generate key with teamId and url", () => {
|
||||
const teamId = "team-123";
|
||||
const url = "https://example.com";
|
||||
const result = RedisPrefixHelper.getUnfurlKey(teamId, url);
|
||||
expect(result).toBe("unfurl:team-123:https://example.com");
|
||||
});
|
||||
|
||||
it("should generate key with teamId and empty url", () => {
|
||||
const teamId = "team-456";
|
||||
const result = RedisPrefixHelper.getUnfurlKey(teamId);
|
||||
expect(result).toBe("unfurl:team-456:");
|
||||
});
|
||||
|
||||
it("should handle special characters in url", () => {
|
||||
const teamId = "team-789";
|
||||
const url = "https://example.com/path?query=value&other=123";
|
||||
const result = RedisPrefixHelper.getUnfurlKey(teamId, url);
|
||||
expect(result).toBe(
|
||||
"unfurl:team-789:https://example.com/path?query=value&other=123"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCollectionDocumentsKey", () => {
|
||||
it("should generate key with collectionId", () => {
|
||||
const collectionId = "col-abc123";
|
||||
const result = RedisPrefixHelper.getCollectionDocumentsKey(collectionId);
|
||||
expect(result).toBe("cd:col-abc123");
|
||||
});
|
||||
|
||||
it("should handle uuid format", () => {
|
||||
const collectionId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const result = RedisPrefixHelper.getCollectionDocumentsKey(collectionId);
|
||||
expect(result).toBe("cd:550e8400-e29b-41d4-a716-446655440000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmbedCheckKey", () => {
|
||||
it("should generate key with url", () => {
|
||||
const url = "https://example.com/embed";
|
||||
const result = RedisPrefixHelper.getEmbedCheckKey(url);
|
||||
expect(result).toBe("embed:https://example.com/embed");
|
||||
});
|
||||
|
||||
it("should handle urls with query parameters", () => {
|
||||
const url = "https://example.com/video?v=abc123";
|
||||
const result = RedisPrefixHelper.getEmbedCheckKey(url);
|
||||
expect(result).toBe("embed:https://example.com/video?v=abc123");
|
||||
});
|
||||
|
||||
it("should handle urls with fragments", () => {
|
||||
const url = "https://example.com/page#section";
|
||||
const result = RedisPrefixHelper.getEmbedCheckKey(url);
|
||||
expect(result).toBe("embed:https://example.com/page#section");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Helper class for Redis cache key generation.
|
||||
*/
|
||||
export class RedisPrefixHelper {
|
||||
/**
|
||||
* Gets key against which unfurl response for the given url is stored.
|
||||
*
|
||||
* @param teamId The team ID to generate a key for.
|
||||
* @param url The url to generate a key for.
|
||||
*/
|
||||
public static getUnfurlKey(teamId: string, url = "") {
|
||||
return `unfurl:${teamId}:${url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets key for caching collection documents structure.
|
||||
*
|
||||
* @param collectionId The collection ID to generate a key for.
|
||||
* @returns the cache key string.
|
||||
*/
|
||||
public static getCollectionDocumentsKey(collectionId: string) {
|
||||
return `cd:${collectionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets key for caching embed check results. This is a global cache key
|
||||
* (not team-specific) since embed headers are the same for all users.
|
||||
*
|
||||
* @param url The URL to generate a cache key for.
|
||||
* @returns the cache key string.
|
||||
*/
|
||||
public static getEmbedCheckKey(url: string) {
|
||||
return `embed:${url}`;
|
||||
}
|
||||
}
|
||||
@@ -44,20 +44,4 @@ export class CacheHelper {
|
||||
public static async clearData(_prefix: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* These are real methods that don't require mocking as they don't
|
||||
* interact with Redis directly
|
||||
*/
|
||||
public static getUnfurlKey(teamId: string, url = "") {
|
||||
return `unfurl:${teamId}:${url}`;
|
||||
}
|
||||
|
||||
public static getCollectionDocumentsKey(collectionId: string) {
|
||||
return `cd:${collectionId}`;
|
||||
}
|
||||
|
||||
public static getEmbedCheckKey(url: string) {
|
||||
return `embed:${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
TeamPreference,
|
||||
UserPreference,
|
||||
EmailDisplay,
|
||||
NotificationBadgeType,
|
||||
} from "./types";
|
||||
|
||||
export const MAX_AVATAR_DISPLAY = 6;
|
||||
@@ -42,4 +43,5 @@ export const UserPreferenceDefaults: UserPreferences = {
|
||||
[UserPreference.CodeBlockLineNumers]: true,
|
||||
[UserPreference.SortCommentsByOrderInDocument]: true,
|
||||
[UserPreference.EnableSmartText]: true,
|
||||
[UserPreference.NotificationBadge]: NotificationBadgeType.Count,
|
||||
};
|
||||
|
||||
@@ -35,6 +35,10 @@ export type Options = {
|
||||
width?: number;
|
||||
/** Height to use when inserting image */
|
||||
height?: number;
|
||||
/** Alt text / caption to use when inserting image */
|
||||
alt?: string | null;
|
||||
/** Layout class for alignment when inserting image */
|
||||
layoutClass?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
import { videoStyle } from "./Video";
|
||||
|
||||
export type Props = {
|
||||
rtl: boolean;
|
||||
$rtl: boolean;
|
||||
readOnly?: boolean;
|
||||
readOnlyWriteCheckboxes?: boolean;
|
||||
commenting?: boolean;
|
||||
@@ -1101,7 +1101,7 @@ h6:not(.placeholder)::before {
|
||||
}
|
||||
|
||||
.with-emoji {
|
||||
margin-${props.rtl ? "right" : "left"}: -1em;
|
||||
margin-${props.$rtl ? "right" : "left"}: -1em;
|
||||
}
|
||||
|
||||
.emoji img {
|
||||
@@ -1483,6 +1483,50 @@ ol li {
|
||||
}
|
||||
}
|
||||
|
||||
.${EditorStyleHelper.checklistWrapper} {
|
||||
position: relative;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.${EditorStyleHelper.checklistCompletedToggle} {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: 0;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
background: ${props.theme.background};
|
||||
border: 1px solid ${props.theme.buttonNeutralBorder};
|
||||
border-radius: 6px;
|
||||
color: ${props.theme.textSecondary};
|
||||
cursor: var(--pointer);
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: all 100ms ease-in-out;
|
||||
|
||||
&:${hover} {
|
||||
background: ${props.theme.buttonNeutralBackground};
|
||||
color: ${props.theme.text};
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.${EditorStyleHelper.checklistWrapper}:${hover} .${EditorStyleHelper.checklistCompletedToggle},
|
||||
.${EditorStyleHelper.checklistWrapper}:focus-within .${EditorStyleHelper.checklistCompletedToggle} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.${EditorStyleHelper.checklistWrapper}.${EditorStyleHelper.checklistCompletedHidden} .${EditorStyleHelper.checklistCompletedToggle} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.${EditorStyleHelper.checklistWrapper}.${EditorStyleHelper.checklistCompletedHidden} ul.checkbox_list > li.checked {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ul.checkbox_list {
|
||||
padding: 0;
|
||||
margin-left: -24px;
|
||||
@@ -2351,7 +2395,7 @@ table {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin-top: 1px;
|
||||
margin-${props.rtl ? "right" : "left"}: -28px;
|
||||
margin-${props.$rtl ? "right" : "left"}: -28px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover,
|
||||
|
||||
@@ -4,9 +4,13 @@ import type {
|
||||
Schema,
|
||||
Node as ProsemirrorNode,
|
||||
} from "prosemirror-model";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { v4 as generateUuid } from "uuid";
|
||||
import toggleList from "../commands/toggleList";
|
||||
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { listWrappingInputRule } from "../lib/listInputRule";
|
||||
import { findBlockNodes } from "../queries/findChildren";
|
||||
import { CheckboxListView } from "./CheckboxListView";
|
||||
import Node from "./Node";
|
||||
|
||||
export default class CheckboxList extends Node {
|
||||
@@ -18,6 +22,9 @@ export default class CheckboxList extends Node {
|
||||
return {
|
||||
group: "block list",
|
||||
content: "checkbox_item+",
|
||||
attrs: {
|
||||
id: { default: null },
|
||||
},
|
||||
toDOM: () => ["ul", { class: this.name }, 0],
|
||||
parseDOM: [
|
||||
{
|
||||
@@ -27,6 +34,53 @@ export default class CheckboxList extends Node {
|
||||
};
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const userIdentifier = this.editor.props.userId;
|
||||
const dictionary = this.editor.props.dictionary;
|
||||
|
||||
// Plugin to auto-assign IDs to checkbox lists
|
||||
const assignIdsPlugin = new Plugin({
|
||||
appendTransaction: (txs, _oldSt, newSt) => {
|
||||
const hasDocChanges = txs.some((t) => t.docChanged);
|
||||
if (!hasDocChanges) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkboxLists = findBlockNodes(newSt.doc, true).filter(
|
||||
(b) => b.node.type.name === this.name && !b.node.attrs.id
|
||||
);
|
||||
|
||||
if (checkboxLists.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let modifyTx = newSt.tr;
|
||||
checkboxLists.forEach((listBlock) => {
|
||||
modifyTx.setNodeAttribute(listBlock.pos, "id", generateUuid());
|
||||
});
|
||||
return modifyTx;
|
||||
},
|
||||
});
|
||||
|
||||
// Plugin to provide NodeViews
|
||||
const nodeViewPlugin = new Plugin({
|
||||
props: {
|
||||
nodeViews: {
|
||||
[this.name]: (node, view, getPos) =>
|
||||
new CheckboxListView(
|
||||
node,
|
||||
view,
|
||||
getPos,
|
||||
userIdentifier || "",
|
||||
dictionary
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return [assignIdsPlugin, nodeViewPlugin];
|
||||
}
|
||||
|
||||
keys({ type, schema }: { type: NodeType; schema: Schema }) {
|
||||
return {
|
||||
"Shift-Ctrl-7": toggleList(type, schema.nodes.checkbox_item),
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import type { EditorView, NodeView } from "prosemirror-view";
|
||||
import type { Dictionary } from "../../../app/hooks/useDictionary";
|
||||
import { isBrowser } from "../../utils/browser";
|
||||
import Storage from "../../utils/Storage";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
|
||||
/**
|
||||
* Custom NodeView that wraps checkbox lists with a toggle control for
|
||||
* showing/hiding completed items.
|
||||
*/
|
||||
export class CheckboxListView implements NodeView {
|
||||
dom: HTMLElement;
|
||||
contentDOM: HTMLElement;
|
||||
|
||||
private toggleControl: HTMLButtonElement;
|
||||
private node: ProsemirrorNode;
|
||||
private userIdentifier: string;
|
||||
private dictionary: Dictionary;
|
||||
|
||||
constructor(
|
||||
node: ProsemirrorNode,
|
||||
_view: EditorView,
|
||||
_getPos: () => number | undefined,
|
||||
userIdentifier: string,
|
||||
dictionary: Dictionary
|
||||
) {
|
||||
this.node = node;
|
||||
this.userIdentifier = userIdentifier;
|
||||
this.dictionary = dictionary;
|
||||
|
||||
// Build DOM structure
|
||||
const wrapperElement = document.createElement("div");
|
||||
wrapperElement.classList.add(EditorStyleHelper.checklistWrapper);
|
||||
|
||||
this.toggleControl = document.createElement("button");
|
||||
this.toggleControl.classList.add(
|
||||
EditorStyleHelper.checklistCompletedToggle
|
||||
);
|
||||
this.toggleControl.contentEditable = "false";
|
||||
|
||||
if (isBrowser) {
|
||||
this.toggleControl.addEventListener("click", this.handleToggleClick);
|
||||
}
|
||||
|
||||
this.contentDOM = document.createElement("ul");
|
||||
this.contentDOM.classList.add("checkbox_list");
|
||||
|
||||
wrapperElement.appendChild(this.toggleControl);
|
||||
wrapperElement.appendChild(this.contentDOM);
|
||||
this.dom = wrapperElement;
|
||||
|
||||
if (isBrowser) {
|
||||
this.updateToggleState();
|
||||
}
|
||||
}
|
||||
|
||||
private handleToggleClick = (clickEvent: Event) => {
|
||||
if (!isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
clickEvent.preventDefault();
|
||||
clickEvent.stopPropagation();
|
||||
|
||||
const listId = this.node.attrs.id;
|
||||
if (!listId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storageKey = `checklist-${listId}-${this.userIdentifier}-hidden`;
|
||||
const currentlyCollapsed = !!Storage.get(storageKey);
|
||||
Storage.set(storageKey, !currentlyCollapsed);
|
||||
this.updateToggleState();
|
||||
};
|
||||
|
||||
private updateToggleState() {
|
||||
if (!isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listId = this.node.attrs.id;
|
||||
if (!listId) {
|
||||
this.toggleControl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const storageKey = `checklist-${listId}-${this.userIdentifier}-hidden`;
|
||||
const shouldCollapse = !!Storage.get(storageKey);
|
||||
|
||||
// Count completed items
|
||||
let completedItemsCount = 0;
|
||||
this.node.forEach((childNode) => {
|
||||
if (childNode.attrs.checked === true) {
|
||||
completedItemsCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Show/hide button based on completed count
|
||||
if (completedItemsCount === 0) {
|
||||
this.toggleControl.style.display = "none";
|
||||
this.dom.classList.remove(EditorStyleHelper.checklistCompletedHidden);
|
||||
} else {
|
||||
this.toggleControl.style.display = "inline-block";
|
||||
this.toggleControl.textContent = shouldCollapse
|
||||
? this.dictionary.showCompleted(completedItemsCount)
|
||||
: this.dictionary.hideCompleted;
|
||||
|
||||
if (shouldCollapse) {
|
||||
this.dom.classList.add(EditorStyleHelper.checklistCompletedHidden);
|
||||
} else {
|
||||
this.dom.classList.remove(EditorStyleHelper.checklistCompletedHidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(node: ProsemirrorNode) {
|
||||
if (!isBrowser) {
|
||||
return false;
|
||||
}
|
||||
if (node.type.name !== "checkbox_list") {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.node = node;
|
||||
this.updateToggleState();
|
||||
return true;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!isBrowser) {
|
||||
return;
|
||||
}
|
||||
this.toggleControl.removeEventListener("click", this.handleToggleClick);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Token } from "markdown-it";
|
||||
import type { PluginSimple, Token } from "markdown-it";
|
||||
import type {
|
||||
NodeSpec,
|
||||
NodeType,
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import toggleList from "../commands/toggleList";
|
||||
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { listWrappingInputRule } from "../lib/listInputRule";
|
||||
import alphaListsRule from "../rules/alphaLists";
|
||||
import Node from "./Node";
|
||||
|
||||
export default class OrderedList extends Node {
|
||||
@@ -15,6 +16,10 @@ export default class OrderedList extends Node {
|
||||
return "ordered_list";
|
||||
}
|
||||
|
||||
get rulePlugins(): PluginSimple[] {
|
||||
return [alphaListsRule];
|
||||
}
|
||||
|
||||
get schema(): NodeSpec {
|
||||
return {
|
||||
attrs: {
|
||||
@@ -163,11 +168,17 @@ export default class OrderedList extends Node {
|
||||
getAttrs: (tok: Token) => {
|
||||
const start = tok.attrGet("start") || "1";
|
||||
|
||||
let listStyle = "number";
|
||||
if (tok.markup && /^[a-z]/.test(tok.markup)) {
|
||||
listStyle = "lower-alpha";
|
||||
} else if (tok.markup && /^[A-Z]/.test(tok.markup)) {
|
||||
listStyle = "upper-alpha";
|
||||
// Check for data-list-style attribute set by alphaLists plugin
|
||||
const dataListStyle = tok.attrGet("data-list-style");
|
||||
let listStyle = dataListStyle || "number";
|
||||
|
||||
// Fallback to checking markup if data-list-style is not present
|
||||
if (!dataListStyle) {
|
||||
if (tok.markup && /^[a-z]/.test(tok.markup)) {
|
||||
listStyle = "lower-alpha";
|
||||
} else if (tok.markup && /^[A-Z]/.test(tok.markup)) {
|
||||
listStyle = "upper-alpha";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -188,6 +188,9 @@ export default class SimpleImage extends Node {
|
||||
replaceExisting: true,
|
||||
attrs: {
|
||||
width: node.attrs.width,
|
||||
height: node.attrs.height,
|
||||
alt: node.attrs.alt,
|
||||
layoutClass: node.attrs.layoutClass,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Node } from "prosemirror-model";
|
||||
import { TableView as ProsemirrorTableView } from "prosemirror-tables";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
import { TableLayout } from "../types";
|
||||
import { isBrowser } from "../../utils/browser";
|
||||
|
||||
export class TableView extends ProsemirrorTableView {
|
||||
public constructor(
|
||||
@@ -18,24 +19,28 @@ export class TableView extends ProsemirrorTableView {
|
||||
this.scrollable.appendChild(this.table);
|
||||
this.scrollable.classList.add(EditorStyleHelper.tableScrollable);
|
||||
|
||||
this.scrollable.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
this.updateClassList(this.node);
|
||||
},
|
||||
{
|
||||
passive: true,
|
||||
}
|
||||
);
|
||||
if (isBrowser) {
|
||||
this.scrollable.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
this.updateClassList(this.node);
|
||||
},
|
||||
{
|
||||
passive: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.updateClassList(node);
|
||||
|
||||
// We need to wait for the next tick to ensure dom is rendered and scroll shadows are correct.
|
||||
setTimeout(() => {
|
||||
if (this.dom) {
|
||||
this.updateClassList(node);
|
||||
}
|
||||
}, 0);
|
||||
if (isBrowser) {
|
||||
setTimeout(() => {
|
||||
if (this.dom) {
|
||||
this.updateClassList(node);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Set up sticky header handling
|
||||
this.setupStickyHeader();
|
||||
@@ -66,6 +71,10 @@ export class TableView extends ProsemirrorTableView {
|
||||
}
|
||||
|
||||
private updateClassList(node: Node) {
|
||||
if (!isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dom.classList.toggle(
|
||||
EditorStyleHelper.tableFullWidth,
|
||||
node.attrs.layout === TableLayout.fullWidth
|
||||
@@ -108,6 +117,10 @@ export class TableView extends ProsemirrorTableView {
|
||||
* Sets up the scroll listener for sticky header behavior.
|
||||
*/
|
||||
private setupStickyHeader() {
|
||||
if (!isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer setup to ensure DOM is fully rendered
|
||||
setTimeout(() => {
|
||||
this.scrollHandler = () => {
|
||||
@@ -129,6 +142,10 @@ export class TableView extends ProsemirrorTableView {
|
||||
* Cleans up the scroll listener and resets header styles.
|
||||
*/
|
||||
private cleanupStickyHeader() {
|
||||
if (!isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.scrollHandler) {
|
||||
document.removeEventListener("scroll", this.scrollHandler, {
|
||||
capture: true,
|
||||
@@ -145,6 +162,10 @@ export class TableView extends ProsemirrorTableView {
|
||||
* Updates the header row transform to create a sticky effect.
|
||||
*/
|
||||
private updateStickyHeader() {
|
||||
if (!isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headerRow = this.table.querySelector("tr") as HTMLElement | null;
|
||||
if (!headerRow) {
|
||||
return;
|
||||
@@ -179,6 +200,10 @@ export class TableView extends ProsemirrorTableView {
|
||||
* @returns the offset in pixels from the top of the viewport.
|
||||
*/
|
||||
private getHeaderOffset(): number {
|
||||
if (!isBrowser) {
|
||||
return TableView.HEADER_HEIGHT;
|
||||
}
|
||||
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--header-offset"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import markdownit from "markdown-it";
|
||||
import alphaListsRule from "../rules/alphaLists";
|
||||
|
||||
describe("Alpha Lists Plugin", () => {
|
||||
it("should parse lowercase alphabetic lists", () => {
|
||||
const md = markdownit().use(alphaListsRule);
|
||||
const result = md.parse("a. First item\nb. Second item", {});
|
||||
|
||||
// Find ordered_list_open token
|
||||
const listToken = result.find((t) => t.type === "ordered_list_open");
|
||||
expect(listToken).toBeDefined();
|
||||
expect(listToken?.attrGet("data-list-style")).toBe("lower-alpha");
|
||||
});
|
||||
|
||||
it("should parse uppercase alphabetic lists", () => {
|
||||
const md = markdownit().use(alphaListsRule);
|
||||
const result = md.parse("A. First item\nB. Second item", {});
|
||||
|
||||
// Find ordered_list_open token
|
||||
const listToken = result.find((t) => t.type === "ordered_list_open");
|
||||
expect(listToken).toBeDefined();
|
||||
expect(listToken?.attrGet("data-list-style")).toBe("upper-alpha");
|
||||
});
|
||||
|
||||
it("should preserve numeric lists", () => {
|
||||
const md = markdownit().use(alphaListsRule);
|
||||
const result = md.parse("1. First item\n2. Second item", {});
|
||||
|
||||
// Find ordered_list_open token
|
||||
const listToken = result.find((t) => t.type === "ordered_list_open");
|
||||
expect(listToken).toBeDefined();
|
||||
expect(listToken?.attrGet("data-list-style")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle the issue example", () => {
|
||||
const md = markdownit().use(alphaListsRule);
|
||||
const text = `## 3. Step Three
|
||||
|
||||
a. Do this.
|
||||
|
||||
b. Do that.`;
|
||||
|
||||
const result = md.parse(text, {});
|
||||
|
||||
// Check that we have an ordered list
|
||||
const listToken = result.find((t) => t.type === "ordered_list_open");
|
||||
expect(listToken).toBeDefined();
|
||||
expect(listToken?.attrGet("data-list-style")).toBe("lower-alpha");
|
||||
|
||||
// Check that we have two list items
|
||||
const listItems = result.filter((t) => t.type === "list_item_open");
|
||||
expect(listItems.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle multiple separate alpha lists", () => {
|
||||
const md = markdownit().use(alphaListsRule);
|
||||
const text = `a. First list item
|
||||
|
||||
b. Second list item
|
||||
|
||||
Some text in between
|
||||
|
||||
A. Upper list item
|
||||
|
||||
B. Upper list item 2`;
|
||||
|
||||
const result = md.parse(text, {});
|
||||
|
||||
// Check that we have two ordered lists
|
||||
const listTokens = result.filter((t) => t.type === "ordered_list_open");
|
||||
expect(listTokens.length).toBe(2);
|
||||
expect(listTokens[0]?.attrGet("data-list-style")).toBe("lower-alpha");
|
||||
expect(listTokens[1]?.attrGet("data-list-style")).toBe("upper-alpha");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import type MarkdownIt from "markdown-it";
|
||||
|
||||
/**
|
||||
* Markdown-it plugin to enable parsing of alphabetic ordered lists (a., b., c., etc.)
|
||||
*
|
||||
* By default, markdown-it only recognizes numeric ordered lists (1., 2., 3.).
|
||||
* This plugin preprocesses the input to convert alphabetic list markers to numeric
|
||||
* while preserving marker information in the token attributes.
|
||||
*/
|
||||
export default function markdownItAlphaLists(md: MarkdownIt): void {
|
||||
// Preprocess the source to convert alpha markers to numbers
|
||||
md.core.ruler.before("normalize", "alpha_lists_preprocess", (state) => {
|
||||
const lines = state.src.split("\n");
|
||||
const processedLines: string[] = [];
|
||||
const lineMarkers: Array<{
|
||||
lineIndex: number;
|
||||
marker: string;
|
||||
listStyle: string;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Match alphabetic list markers with at least one space after the period
|
||||
const match = line.match(/^(\s*)([a-zA-Z])\.\s+(.*)$/);
|
||||
|
||||
if (match) {
|
||||
const indent = match[1];
|
||||
const letter = match[2];
|
||||
const content = match[3];
|
||||
const isLowercase = letter === letter.toLowerCase();
|
||||
const num = isLowercase
|
||||
? letter.charCodeAt(0) - 96 // a=1, b=2
|
||||
: letter.charCodeAt(0) - 64; // A=1, B=2
|
||||
const listStyle = isLowercase ? "lower-alpha" : "upper-alpha";
|
||||
|
||||
lineMarkers.push({
|
||||
lineIndex: processedLines.length,
|
||||
marker: letter,
|
||||
listStyle,
|
||||
});
|
||||
processedLines.push(`${indent}${num}. ${content}`);
|
||||
} else {
|
||||
processedLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Store marker info for later, including line mapping
|
||||
if (lineMarkers.length > 0) {
|
||||
state.env.alphaListMarkers = lineMarkers;
|
||||
}
|
||||
|
||||
state.src = processedLines.join("\n");
|
||||
});
|
||||
|
||||
// Post-process tokens to add the listStyle attribute
|
||||
md.core.ruler.after("block", "alpha_lists_postprocess", (state) => {
|
||||
if (!state.env.alphaListMarkers || state.env.alphaListMarkers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markers = state.env.alphaListMarkers;
|
||||
|
||||
// Build a map of line numbers to markers for more reliable matching
|
||||
const lineToMarkerMap = new Map<number, typeof markers[0]>();
|
||||
for (const marker of markers) {
|
||||
lineToMarkerMap.set(marker.lineIndex, marker);
|
||||
}
|
||||
|
||||
// Track which markers we've used to handle multiple lists correctly
|
||||
const usedMarkers = new Set<number>();
|
||||
|
||||
for (let i = 0; i < state.tokens.length; i++) {
|
||||
const token = state.tokens[i];
|
||||
|
||||
// Find ordered_list_open tokens and match them with the first list item
|
||||
if (token.type === "ordered_list_open") {
|
||||
// Look ahead to find the first list_item_open token
|
||||
for (let j = i + 1; j < state.tokens.length; j++) {
|
||||
const itemToken = state.tokens[j];
|
||||
|
||||
if (itemToken.type === "list_item_open" && itemToken.map) {
|
||||
const itemLine = itemToken.map[0];
|
||||
|
||||
// Find the marker for this line or nearby lines.
|
||||
// We check up to 2 lines back to handle cases where markdown-it's
|
||||
// line mapping differs slightly from our preprocessing due to blank
|
||||
// lines or formatting differences in list item content.
|
||||
const MAX_LINE_OFFSET = 2;
|
||||
for (let offset = 0; offset <= MAX_LINE_OFFSET; offset++) {
|
||||
const checkLine = itemLine - offset;
|
||||
const marker = lineToMarkerMap.get(checkLine);
|
||||
|
||||
if (marker && !usedMarkers.has(marker.lineIndex)) {
|
||||
// Set the markup to the original letter marker
|
||||
token.markup = marker.marker;
|
||||
|
||||
// Add an attribute to indicate this was an alphabetic list
|
||||
token.attrSet("data-list-style", marker.listStyle);
|
||||
|
||||
// Mark this marker as used
|
||||
usedMarkers.add(marker.lineIndex);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Stop if we hit another list or go too far
|
||||
if (
|
||||
itemToken.type === "ordered_list_open" ||
|
||||
itemToken.type === "bullet_list_open"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the environment
|
||||
delete state.env.alphaListMarkers;
|
||||
});
|
||||
}
|
||||
@@ -59,6 +59,17 @@ export class EditorStyleHelper {
|
||||
/** Toggle block folded state */
|
||||
static readonly toggleBlockFolded = "folded";
|
||||
|
||||
// Checkbox Lists
|
||||
|
||||
/** Checkbox list wrapper */
|
||||
static readonly checklistWrapper = "checklist-wrapper";
|
||||
|
||||
/** Toggle button for showing/hiding completed items */
|
||||
static readonly checklistCompletedToggle = "checklist-completed-toggle";
|
||||
|
||||
/** State when completed items are hidden */
|
||||
static readonly checklistCompletedHidden = "completed-hidden";
|
||||
|
||||
// Tables
|
||||
|
||||
/** Table wrapper */
|
||||
|
||||
@@ -16,6 +16,8 @@ export default class AuthenticationHelper {
|
||||
info: Scope.Read,
|
||||
search: Scope.Read,
|
||||
documents: Scope.Read,
|
||||
drafts: Scope.Read,
|
||||
viewed: Scope.Read,
|
||||
export: Scope.Read,
|
||||
};
|
||||
|
||||
|
||||
@@ -550,6 +550,9 @@
|
||||
"Full width": "Plná šířka",
|
||||
"Bulleted list": "Odrážkový seznam",
|
||||
"Todo list": "Seznam úkolů",
|
||||
"Show {{ count }} completed": "Show {{ count }} completed",
|
||||
"Show {{ count }} completed_plural": "Show {{ count }} completed",
|
||||
"Hide completed": "Hide completed",
|
||||
"Code block": "Blok kódu",
|
||||
"Copied to clipboard": "Zkopírováno do schránky",
|
||||
"Code": "Kód",
|
||||
@@ -629,6 +632,14 @@
|
||||
"Delete embed": "Odstranit vložený prvek",
|
||||
"Formatting controls": "Prvky formátování",
|
||||
"Distribute columns": "Rozložit sloupce",
|
||||
"Delete Emoji": "Odstranit emoji",
|
||||
"Emoji deleted": "Emoji bylo odstraněno",
|
||||
"I'm sure – Delete": "I'm sure – Delete",
|
||||
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Opravdu chcete odstranit emoji <em>{{emojiName}}</em>? V dokumentech a kolekcích jej již nebude možné používat.",
|
||||
"Group members": "Členové skupiny",
|
||||
"Edit group": "Upravit skupinu",
|
||||
"Delete group": "Odstranit skupinu",
|
||||
"Members": "Členové",
|
||||
"Could not import file": "Soubor se nepodařilo importovat",
|
||||
"Unsubscribed from document": "Odběr upozornění na dokument byl zrušen",
|
||||
"Unsubscribed from collection": "Odběr upozornění na kolekci byl zrušen",
|
||||
@@ -638,26 +649,28 @@
|
||||
"Authentication": "Ověřování",
|
||||
"Security": "Zabezpečení",
|
||||
"Features": "Funkce",
|
||||
"Members": "Členové",
|
||||
"API Keys": "API klíče",
|
||||
"Applications": "Aplikace",
|
||||
"Shared Links": "Sdílené odkazy",
|
||||
"Import": "Importovat",
|
||||
"Install": "Instalovat",
|
||||
"Integrations": "Integrace",
|
||||
"Change name": "Změnit jméno",
|
||||
"Change email": "Změnit e-mail",
|
||||
"Suspend user": "Pozastavit uživatele",
|
||||
"An error occurred while sending the invite": "Při odesílání pozvánky došlo k chybě.",
|
||||
"Change role": "Změnit roli",
|
||||
"Resend invite": "Znovu odeslat pozvánku",
|
||||
"Revoke invite": "Zrušit pozvání",
|
||||
"Activate user": "Aktivovat uživatele",
|
||||
"API key": "API klíč",
|
||||
"Show path to document": "Zobrazit cestu k dokumentu",
|
||||
"Collection menu": "Nabídka kolekce",
|
||||
"Comment options": "Možnosti komentáře",
|
||||
"Enable viewer insights": "Povolit analytiku zobrazení",
|
||||
"Enable embeds": "Povolit vkládání (embeds)",
|
||||
"Delete Emoji": "Odstranit emoji",
|
||||
"Emoji deleted": "Emoji bylo odstraněno",
|
||||
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Opravdu chcete odstranit emoji <em>{{emojiName}}</em>? V dokumentech a kolekcích jej již nebude možné používat.",
|
||||
"Emoji options": "Emoji options",
|
||||
"File": "Soubor",
|
||||
"Group members": "Členové skupiny",
|
||||
"Edit group": "Upravit skupinu",
|
||||
"Delete group": "Odstranit skupinu",
|
||||
"Group options": "Nastavení skupin",
|
||||
"Cancel": "Zrušit",
|
||||
"Import menu options": "Možnosti nabídky importu",
|
||||
@@ -672,14 +685,6 @@
|
||||
"Headings you add to the document will appear here": "Zde se zobrazí nadpisy přidané do dokumentu",
|
||||
"Contents": "Obsah",
|
||||
"Table of contents": "Obsah",
|
||||
"Change name": "Změnit jméno",
|
||||
"Change email": "Změnit e-mail",
|
||||
"Suspend user": "Pozastavit uživatele",
|
||||
"An error occurred while sending the invite": "Při odesílání pozvánky došlo k chybě.",
|
||||
"Change role": "Změnit roli",
|
||||
"Resend invite": "Znovu odeslat pozvánku",
|
||||
"Revoke invite": "Zrušit pozvání",
|
||||
"Activate user": "Aktivovat uživatele",
|
||||
"User options": "Možnosti uživatele",
|
||||
"template": "šablona",
|
||||
"document": "dokument",
|
||||
@@ -1106,21 +1111,25 @@
|
||||
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Opravdu chcete pokračovat? Odstraněním skupiny <em>{{groupName}}</em> ztratí její členové přístup ke kolekcím a dokumentům, se kterými je skupina spojena.",
|
||||
"Add people to {{groupName}}": "Přidat lidi do {{groupName}}",
|
||||
"{{userName}} was removed from the group": "Uživatel {{userName}} byl ze skupiny odebrán",
|
||||
"All permissions": "All permissions",
|
||||
"Group admin": "Správce skupiny",
|
||||
"Member": "Člen",
|
||||
"Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.": "Spravujte členy skupiny <em>{{groupName}}</em>. Členové skupiny budou mít přístup ke všem kolekcím, do kterých je skupina zařazena.",
|
||||
"Add people": "Přidat lidi",
|
||||
"Listing members of the <em>{{groupName}}</em> group.": "Seznam členů skupiny <em>{{groupName}}</em>.",
|
||||
"Search by name": "Hledat podle jména",
|
||||
"Search members": "Search members",
|
||||
"Filter by permissions": "Filter by permissions",
|
||||
"No members matching your filters": "No members matching your filters",
|
||||
"This group has no members.": "Skupina nemá žádné členy.",
|
||||
"{{userName}} was added to the group": "Uživatel {{userName}} byl přidán do skupiny",
|
||||
"Could not add user": "Uživatele se nepodařilo přidat",
|
||||
"Add members below to give them access to the group. Need to add someone who’s not yet a member?": "Přidejte členy do skupiny. Chcete přidat někoho, kdo ještě není členem?",
|
||||
"Invite them to {{teamName}}": "Pozvat do {{teamName}}",
|
||||
"Ask an admin to invite them first": "Požádejte administrátora o pozvání uživatele.",
|
||||
"Search by name": "Hledat podle jména",
|
||||
"Search people": "Hledat lidi",
|
||||
"No people matching your search": "Hledání neodpovídají žádní uživatelé",
|
||||
"No people left to add": "Nezbývají žádní uživatelé k přidání",
|
||||
"Group admin": "Správce skupiny",
|
||||
"Member": "Člen",
|
||||
"Date created": "Datum vytvoření",
|
||||
"Crop Image": "Oříznout obrázek",
|
||||
"Crop image": "Oříznout obrázek",
|
||||
@@ -1234,6 +1243,8 @@
|
||||
"Manage when and where you receive email notifications.": "Správa e-mailových upozornění.",
|
||||
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "E-mailová integrace je deaktivována. Pro povolení upozornění nastavte proměnné prostředí a restartujte server.",
|
||||
"Preferences saved": "Předvolby byly uloženy",
|
||||
"Unread count": "Unread count",
|
||||
"Unread indicator": "Unread indicator",
|
||||
"Delete account": "Odstranit účet",
|
||||
"Manage settings that affect your personal experience.": "Spravujte osobní nastavení.",
|
||||
"Language": "Jazyk",
|
||||
@@ -1248,6 +1259,8 @@
|
||||
"Automatically return to the document you were last viewing when the app is re-opened.": "Při otevření aplikace se automaticky vrátit k naposledy zobrazenému dokumentu.",
|
||||
"Smart text replacements": "Chytré nahrazování textu",
|
||||
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Automatické formátování textu (nahrazování zkratek symboly, pomlčkami, chytrými uvozovkami atd.).",
|
||||
"Notification badge": "Notification badge",
|
||||
"Choose how unread notifications are indicated on the app icon.": "Choose how unread notifications are indicated on the app icon.",
|
||||
"You may delete your account at any time, note that this is unrecoverable": "Účet můžete kdykoli odstranit. Tuto akci nelze vrátit zpět.",
|
||||
"Profile saved": "Profil byl uložen",
|
||||
"Profile picture updated": "Profilový obrázek byl aktualizován",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user