mirror of
https://github.com/outline/outline.git
synced 2025-03-14 10:07:11 +00:00
feat: Comment resolving (#7115)
This commit is contained in:
@ -41,6 +41,7 @@
|
||||
"@typescript-eslint/no-shadow": [
|
||||
"warn",
|
||||
{
|
||||
"allow": ["transaction"],
|
||||
"hoist": "all",
|
||||
"ignoreTypeValueShadow": true
|
||||
}
|
||||
@ -139,4 +140,4 @@
|
||||
"typescript": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
86
app/actions/definitions/comments.tsx
Normal file
86
app/actions/definitions/comments.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { DoneIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import history from "~/utils/history";
|
||||
import { createAction } from "..";
|
||||
import { DocumentSection } from "../sections";
|
||||
|
||||
export const deleteCommentFactory = ({
|
||||
comment,
|
||||
onDelete,
|
||||
}: {
|
||||
comment: Comment;
|
||||
onDelete: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete comment",
|
||||
section: DocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "trash",
|
||||
dangerous: true,
|
||||
visible: () => stores.policies.abilities(comment.id).delete,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete comment"),
|
||||
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const resolveCommentFactory = ({
|
||||
comment,
|
||||
onResolve,
|
||||
}: {
|
||||
comment: Comment;
|
||||
onResolve: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as resolved"),
|
||||
analyticsName: "Resolve thread",
|
||||
section: DocumentSection,
|
||||
icon: <DoneIcon outline />,
|
||||
visible: () => stores.policies.abilities(comment.id).resolve,
|
||||
perform: async ({ t }) => {
|
||||
await comment.resolve();
|
||||
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: null,
|
||||
});
|
||||
|
||||
onResolve();
|
||||
toast.success(t("Thread resolved"));
|
||||
},
|
||||
});
|
||||
|
||||
export const unresolveCommentFactory = ({
|
||||
comment,
|
||||
onUnresolve,
|
||||
}: {
|
||||
comment: Comment;
|
||||
onUnresolve: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Mark as unresolved"),
|
||||
analyticsName: "Unresolve thread",
|
||||
section: DocumentSection,
|
||||
icon: <DoneIcon outline />,
|
||||
visible: () => stores.policies.abilities(comment.id).unresolve,
|
||||
perform: async () => {
|
||||
await comment.unresolve();
|
||||
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: null,
|
||||
});
|
||||
|
||||
onUnresolve();
|
||||
},
|
||||
});
|
@ -30,6 +30,7 @@ type Props = Omit<MenuStateReturn, "items"> & {
|
||||
actions?: (Action | MenuSeparator | MenuHeading)[];
|
||||
context?: Partial<ActionContext>;
|
||||
items?: TMenuItem[];
|
||||
showIcons?: boolean;
|
||||
};
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
@ -98,7 +99,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
function Template({ items, actions, context, ...menu }: Props) {
|
||||
function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
const ctx = useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
@ -124,7 +125,8 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
if (
|
||||
iconIsPresentInAnyMenuItem &&
|
||||
item.type !== "separator" &&
|
||||
item.type !== "heading"
|
||||
item.type !== "heading" &&
|
||||
showIcons !== false
|
||||
) {
|
||||
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
|
||||
}
|
||||
@ -138,7 +140,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
icon={item.icon}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
@ -156,7 +158,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
selected={item.selected}
|
||||
level={item.level}
|
||||
target={item.href.startsWith("#") ? undefined : "_blank"}
|
||||
icon={item.icon}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
@ -174,7 +176,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
key={index}
|
||||
icon={item.icon}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
@ -190,7 +192,12 @@ function Template({ items, actions, context, ...menu }: Props) {
|
||||
id={`${item.title}-${index}`}
|
||||
templateItems={item.items}
|
||||
parentMenuState={menu}
|
||||
title={<Title title={item.title} icon={item.icon} />}
|
||||
title={
|
||||
<Title
|
||||
title={item.title}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
/>
|
||||
}
|
||||
{...menu}
|
||||
/>
|
||||
);
|
||||
|
@ -640,27 +640,56 @@ export class Editor extends React.PureComponent<
|
||||
public getComments = () => ProsemirrorHelper.getComments(this.view.state.doc);
|
||||
|
||||
/**
|
||||
* Remove a specific comment mark from the document.
|
||||
* Remove all marks related to a specific comment from the document.
|
||||
*
|
||||
* @param commentId The id of the comment to remove
|
||||
*/
|
||||
public removeComment = (commentId: string) => {
|
||||
const { state, dispatch } = this.view;
|
||||
let found = false;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (!node.isInline || found) {
|
||||
if (!node.isInline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mark = node.marks.find(
|
||||
(mark) =>
|
||||
mark.type === state.schema.marks.comment &&
|
||||
mark.attrs.id === commentId
|
||||
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
||||
);
|
||||
|
||||
if (mark) {
|
||||
dispatch(state.tr.removeMark(pos, pos + node.nodeSize, mark));
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update all marks related to a specific comment in the document.
|
||||
*
|
||||
* @param commentId The id of the comment to remove
|
||||
* @param attrs The attributes to update
|
||||
*/
|
||||
public updateComment = (commentId: string, attrs: { resolved: boolean }) => {
|
||||
const { state, dispatch } = this.view;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (!node.isInline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mark = node.marks.find(
|
||||
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
||||
);
|
||||
|
||||
if (mark) {
|
||||
const from = pos;
|
||||
const to = pos + node.nodeSize;
|
||||
const newMark = state.schema.marks.comment.create({
|
||||
...mark.attrs,
|
||||
...attrs,
|
||||
});
|
||||
dispatch(
|
||||
state.tr.removeMark(from, to, mark).addMark(from, to, newMark)
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -808,6 +837,7 @@ const EditorContainer = styled(Styles)<{
|
||||
css`
|
||||
#comment-${props.focusedCommentId} {
|
||||
background: ${transparentize(0.5, props.theme.brand.marine)};
|
||||
border-bottom: 2px solid ${props.theme.commentMarkBackground};
|
||||
}
|
||||
`}
|
||||
|
||||
|
@ -209,7 +209,7 @@ export default function formattingMenuItems(
|
||||
tooltip: dictionary.comment,
|
||||
icon: <CommentIcon />,
|
||||
label: isCodeBlock ? dictionary.comment : undefined,
|
||||
active: isMarkActive(schema.marks.comment),
|
||||
active: isMarkActive(schema.marks.comment, { resolved: false }),
|
||||
visible: !isMobile || !isEmpty,
|
||||
},
|
||||
{
|
||||
|
@ -1,16 +1,22 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, EditIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import { toast } from "sonner";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Separator from "~/components/ContextMenu/Separator";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import {
|
||||
deleteCommentFactory,
|
||||
resolveCommentFactory,
|
||||
unresolveCommentFactory,
|
||||
} from "~/actions/definitions/comments";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { commentPath, urlify } from "~/utils/routeHelpers";
|
||||
@ -24,24 +30,26 @@ type Props = {
|
||||
onEdit: () => void;
|
||||
/** Callback when the comment has been deleted */
|
||||
onDelete: () => void;
|
||||
/** Callback when the comment has been updated */
|
||||
onUpdate: (attrs: { resolved: boolean }) => void;
|
||||
};
|
||||
|
||||
function CommentMenu({ comment, onEdit, onDelete, className }: Props) {
|
||||
function CommentMenu({
|
||||
comment,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
className,
|
||||
}: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const { documents, dialogs } = useStores();
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(comment);
|
||||
const context = useActionContext({ isContextMenu: true });
|
||||
const document = documents.get(comment.documentId);
|
||||
|
||||
const handleDelete = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete comment"),
|
||||
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
|
||||
});
|
||||
}, [dialogs, comment, onDelete, t]);
|
||||
|
||||
const handleCopyLink = React.useCallback(() => {
|
||||
if (document) {
|
||||
copy(urlify(commentPath(document, comment)));
|
||||
@ -58,24 +66,46 @@ function CommentMenu({ comment, onEdit, onDelete, className }: Props) {
|
||||
{...menu}
|
||||
/>
|
||||
</EventBoundary>
|
||||
|
||||
<ContextMenu {...menu} aria-label={t("Comment options")}>
|
||||
{can.update && (
|
||||
<MenuItem {...menu} onClick={onEdit}>
|
||||
{t("Edit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem {...menu} onClick={handleCopyLink}>
|
||||
{t("Copy link")}
|
||||
</MenuItem>
|
||||
{can.delete && (
|
||||
<>
|
||||
<Separator />
|
||||
<MenuItem {...menu} onClick={handleDelete} dangerous>
|
||||
{t("Delete")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
onClick: onEdit,
|
||||
visible: can.update,
|
||||
},
|
||||
actionToMenuItem(
|
||||
resolveCommentFactory({
|
||||
comment,
|
||||
onResolve: () => onUpdate({ resolved: true }),
|
||||
}),
|
||||
context
|
||||
),
|
||||
actionToMenuItem(
|
||||
unresolveCommentFactory({
|
||||
comment,
|
||||
onUnresolve: () => onUpdate({ resolved: false }),
|
||||
}),
|
||||
context
|
||||
),
|
||||
{
|
||||
type: "button",
|
||||
icon: <CopyIcon />,
|
||||
title: t("Copy link"),
|
||||
onClick: handleCopyLink,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(
|
||||
deleteCommentFactory({ comment, onDelete }),
|
||||
context
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -3,6 +3,7 @@ import { computed, observable } from "mobx";
|
||||
import { now } from "mobx-utils";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import Document from "./Document";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
@ -34,7 +35,7 @@ class Comment extends Model {
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
parentCommentId: string;
|
||||
parentCommentId: string | null;
|
||||
|
||||
/**
|
||||
* The comment that this comment is a reply to.
|
||||
@ -43,33 +44,86 @@ class Comment extends Model {
|
||||
parentComment?: Comment;
|
||||
|
||||
/**
|
||||
* The document to which this comment belongs.
|
||||
* The document ID to which this comment belongs.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
documentId: string;
|
||||
|
||||
/**
|
||||
* The document that this comment belongs to.
|
||||
*/
|
||||
@Relation(() => Document, { onDelete: "cascade" })
|
||||
document: Document;
|
||||
|
||||
/**
|
||||
* The user who created this comment.
|
||||
*/
|
||||
@Relation(() => User)
|
||||
createdBy: User;
|
||||
|
||||
/**
|
||||
* The ID of the user who created this comment.
|
||||
*/
|
||||
createdById: string;
|
||||
|
||||
/**
|
||||
* The date and time that this comment was resolved, if it has been resolved.
|
||||
*/
|
||||
@observable
|
||||
resolvedAt: string;
|
||||
|
||||
/**
|
||||
* The user who resolved this comment, if it has been resolved.
|
||||
*/
|
||||
@Relation(() => User)
|
||||
resolvedBy: User;
|
||||
resolvedBy: User | null;
|
||||
|
||||
/**
|
||||
* The ID of the user who resolved this comment, if it has been resolved.
|
||||
*/
|
||||
resolvedById: string | null;
|
||||
|
||||
/**
|
||||
* An array of users that are currently typing a reply in this comments thread.
|
||||
*/
|
||||
@computed
|
||||
get currentlyTypingUsers(): User[] {
|
||||
public get currentlyTypingUsers(): User[] {
|
||||
return Array.from(this.typingUsers.entries())
|
||||
.filter(([, lastReceivedDate]) => lastReceivedDate > subSeconds(now(), 3))
|
||||
.map(([userId]) => this.store.rootStore.users.get(userId))
|
||||
.filter(Boolean) as User[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the comment is resolved
|
||||
*/
|
||||
@computed
|
||||
public get isResolved() {
|
||||
return !!this.resolvedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the comment is a reply to another comment.
|
||||
*/
|
||||
@computed
|
||||
public get isReply() {
|
||||
return !!this.parentCommentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the comment
|
||||
*/
|
||||
public resolve() {
|
||||
return this.store.rootStore.comments.resolve(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unresolve the comment
|
||||
*/
|
||||
public unresolve() {
|
||||
return this.store.rootStore.comments.unresolve(this.id);
|
||||
}
|
||||
}
|
||||
|
||||
export default Comment;
|
||||
|
@ -106,6 +106,7 @@ function CommentForm({
|
||||
thread ??
|
||||
new Comment(
|
||||
{
|
||||
createdAt: new Date().toISOString(),
|
||||
documentId,
|
||||
data: draft,
|
||||
},
|
||||
@ -139,6 +140,7 @@ function CommentForm({
|
||||
|
||||
const comment = new Comment(
|
||||
{
|
||||
createdAt: new Date().toISOString(),
|
||||
parentCommentId: thread?.id,
|
||||
documentId,
|
||||
data: draft,
|
||||
|
@ -2,7 +2,7 @@ import throttle from "lodash/throttle";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@ -70,6 +70,7 @@ function CommentThread({
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
||||
const [, setIsTyping] = useTypingIndicator({
|
||||
document,
|
||||
@ -92,7 +93,8 @@ function CommentThread({
|
||||
!(event.target as HTMLElement).classList.contains("comment")
|
||||
) {
|
||||
history.replace({
|
||||
pathname: window.location.pathname,
|
||||
search: location.search,
|
||||
pathname: location.pathname,
|
||||
state: { commentId: undefined },
|
||||
});
|
||||
}
|
||||
@ -100,7 +102,8 @@ function CommentThread({
|
||||
|
||||
const handleClickThread = () => {
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
search: location.search,
|
||||
pathname: location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId: thread.id },
|
||||
});
|
||||
};
|
||||
@ -177,6 +180,7 @@ function CommentThread({
|
||||
highlightedText={index === 0 ? highlightedText : undefined}
|
||||
comment={comment}
|
||||
onDelete={() => editor?.removeComment(comment.id)}
|
||||
onUpdate={(attrs) => editor?.updateComment(comment.id, attrs)}
|
||||
key={comment.id}
|
||||
firstOfThread={index === 0}
|
||||
lastOfThread={index === commentsInThread.length - 1 && !draft}
|
||||
|
@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import { dateToRelative } from "@shared/utils/date";
|
||||
@ -76,6 +77,8 @@ type Props = {
|
||||
canReply: boolean;
|
||||
/** Callback when the comment has been deleted */
|
||||
onDelete: () => void;
|
||||
/** Callback when the comment has been updated */
|
||||
onUpdate: (attrs: { resolved: boolean }) => void;
|
||||
/** Text to highlight at the top of the comment */
|
||||
highlightedText?: string;
|
||||
};
|
||||
@ -89,6 +92,7 @@ function CommentThreadItem({
|
||||
previousCommentCreatedAt,
|
||||
canReply,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
highlightedText,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
@ -97,7 +101,9 @@ function CommentThreadItem({
|
||||
const showAuthor = firstOfAuthor;
|
||||
const showTime = useShowTime(comment.createdAt, previousCommentCreatedAt);
|
||||
const showEdited =
|
||||
comment.updatedAt && comment.updatedAt !== comment.createdAt;
|
||||
comment.updatedAt &&
|
||||
comment.updatedAt !== comment.createdAt &&
|
||||
!comment.isResolved;
|
||||
const [isEditing, setEditing, setReadOnly] = useBoolean();
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
@ -198,14 +204,17 @@ function CommentThreadItem({
|
||||
</Flex>
|
||||
)}
|
||||
</Body>
|
||||
{!isEditing && (
|
||||
<Menu
|
||||
comment={comment}
|
||||
onEdit={setEditing}
|
||||
onDelete={onDelete}
|
||||
dir={dir}
|
||||
/>
|
||||
)}
|
||||
<EventBoundary>
|
||||
{!isEditing && (
|
||||
<Menu
|
||||
comment={comment}
|
||||
onEdit={setEditing}
|
||||
onDelete={onDelete}
|
||||
onUpdate={onUpdate}
|
||||
dir={dir}
|
||||
/>
|
||||
)}
|
||||
</EventBoundary>
|
||||
</Bubble>
|
||||
</Flex>
|
||||
);
|
||||
|
@ -1,19 +1,25 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { bigPulse } from "~/styles/animations";
|
||||
import CommentForm from "./CommentForm";
|
||||
import CommentThread from "./CommentThread";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
@ -22,7 +28,11 @@ function Comments() {
|
||||
const { ui, comments, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const params = useQuery();
|
||||
const [pulse, setPulse] = React.useState(false);
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const focusedComment = useFocusedComment();
|
||||
const can = usePolicy(document);
|
||||
@ -34,18 +44,75 @@ function Comments() {
|
||||
undefined
|
||||
);
|
||||
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const resolvedThreads = document
|
||||
? comments.resolvedThreadsInDocument(document.id)
|
||||
: [];
|
||||
const resolvedThreadsCount = resolvedThreads.length;
|
||||
|
||||
React.useEffect(() => {
|
||||
setPulse(true);
|
||||
const timeout = setTimeout(() => setPulse(false), 250);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
setPulse(false);
|
||||
};
|
||||
}, [resolvedThreadsCount]);
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const threads = comments
|
||||
.threadsInDocument(document.id)
|
||||
.filter((thread) => !thread.isNew || thread.createdById === user.id);
|
||||
const threads = (
|
||||
viewingResolved
|
||||
? resolvedThreads
|
||||
: comments.unresolvedThreadsInDocument(document.id)
|
||||
).filter((thread) => thread.createdById === user.id);
|
||||
const hasComments = threads.length > 0;
|
||||
|
||||
const toggleViewingResolved = () => {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: viewingResolved ? undefined : "",
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
title={t("Comments")}
|
||||
title={
|
||||
<Flex align="center" justify="space-between" auto>
|
||||
{viewingResolved ? (
|
||||
<React.Fragment key="resolved">
|
||||
<span>{t("Resolved comments")}</span>
|
||||
<Tooltip delay={500} content={t("View comments")}>
|
||||
<ResolvedButton
|
||||
neutral
|
||||
borderOnHover
|
||||
icon={<DoneIcon />}
|
||||
onClick={toggleViewingResolved}
|
||||
/>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<span>{t("Comments")}</span>
|
||||
<Tooltip delay={250} content={t("View resolved comments")}>
|
||||
<ResolvedButton
|
||||
neutral
|
||||
borderOnHover
|
||||
icon={<DoneIcon outline />}
|
||||
onClick={toggleViewingResolved}
|
||||
$pulse={pulse}
|
||||
/>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
onClose={() => ui.collapseComments(document?.id)}
|
||||
scrollable={false}
|
||||
>
|
||||
@ -68,13 +135,17 @@ function Comments() {
|
||||
))
|
||||
) : (
|
||||
<NoComments align="center" justify="center" auto>
|
||||
<PositionedEmpty>{t("No comments yet")}</PositionedEmpty>
|
||||
<PositionedEmpty>
|
||||
{viewingResolved
|
||||
? t("No resolved comments")
|
||||
: t("No comments yet")}
|
||||
</PositionedEmpty>
|
||||
</NoComments>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Scrollable>
|
||||
<AnimatePresence initial={false}>
|
||||
{!focusedComment && can.comment && (
|
||||
{!focusedComment && can.comment && !viewingResolved && (
|
||||
<NewCommentForm
|
||||
draft={draft}
|
||||
onSaveDraft={onSaveDraft}
|
||||
@ -91,6 +162,14 @@ function Comments() {
|
||||
);
|
||||
}
|
||||
|
||||
const ResolvedButton = styled(Button)<{ $pulse: boolean }>`
|
||||
${(props) =>
|
||||
props.$pulse &&
|
||||
css`
|
||||
animation: ${bigPulse} 250ms 1;
|
||||
`}
|
||||
`;
|
||||
|
||||
const PositionedEmpty = styled(Empty)`
|
||||
position: absolute;
|
||||
top: calc(50vh - 30px);
|
||||
|
@ -186,7 +186,7 @@ function DataLoader({ match, children }: Props) {
|
||||
// when viewing a public share link
|
||||
if (can.read && !document.isDeleted) {
|
||||
if (team.getPreference(TeamPreference.Commenting)) {
|
||||
void comments.fetchPage({
|
||||
void comments.fetchAll({
|
||||
documentId: document.id,
|
||||
limit: 100,
|
||||
});
|
||||
|
@ -37,7 +37,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
|
||||
|
||||
const insightsPath = documentInsightsPath(document);
|
||||
const commentsCount = comments.filter({ documentId: document.id }).length;
|
||||
const commentsCount = comments.unresolvedCommentsInDocumentCount(document.id);
|
||||
|
||||
return (
|
||||
<Meta document={document} revision={revision} to={to} replace {...rest}>
|
||||
|
@ -13,7 +13,7 @@ import useMobile from "~/hooks/useMobile";
|
||||
import { draggableOnDesktop } from "~/styles";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||
type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
|
||||
/* The title of the sidebar */
|
||||
title: React.ReactNode;
|
||||
/* The content of the sidebar */
|
||||
|
@ -114,6 +114,19 @@ function KeyboardShortcuts() {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("Collaboration"),
|
||||
items: [
|
||||
{
|
||||
shortcut: (
|
||||
<>
|
||||
<Key symbol>{metaDisplay}</Key> + <Key>Alt</Key> + <Key>m</Key>
|
||||
</>
|
||||
),
|
||||
label: t("Comment"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("Formatting"),
|
||||
items: [
|
||||
|
@ -1,6 +1,8 @@
|
||||
import invariant from "invariant";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { action, computed } from "mobx";
|
||||
import Comment from "~/models/Comment";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import RootStore from "./RootStore";
|
||||
import Store from "./base/Store";
|
||||
|
||||
@ -29,12 +31,54 @@ export default class CommentsStore extends Store<Comment> {
|
||||
threadsInDocument(documentId: string): Comment[] {
|
||||
return this.filter(
|
||||
(comment: Comment) =>
|
||||
comment.documentId === documentId && !comment.parentCommentId
|
||||
comment.documentId === documentId &&
|
||||
!comment.parentCommentId &&
|
||||
(!comment.isNew ||
|
||||
comment.createdById === this.rootStore.auth.currentUserId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of comments that are replies to the given comment.
|
||||
* Returns a list of resolved comments in a document that are not replies to other
|
||||
* comments.
|
||||
*
|
||||
* @param documentId ID of the document to get comments for
|
||||
* @returns Array of comments
|
||||
*/
|
||||
resolvedThreadsInDocument(documentId: string): Comment[] {
|
||||
return this.threadsInDocument(documentId).filter(
|
||||
(comment: Comment) => comment.isResolved === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of comments in a document that are not replies to other
|
||||
* comments.
|
||||
*
|
||||
* @param documentId ID of the document to get comments for
|
||||
* @returns Array of comments
|
||||
*/
|
||||
unresolvedThreadsInDocument(documentId: string): Comment[] {
|
||||
return this.threadsInDocument(documentId).filter(
|
||||
(comment: Comment) => comment.isResolved === false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total number of unresolbed comments in the given document.
|
||||
*
|
||||
* @param documentId ID of the document to get comments for
|
||||
* @returns A number of comments
|
||||
*/
|
||||
unresolvedCommentsInDocumentCount(documentId: string): number {
|
||||
return this.unresolvedThreadsInDocument(documentId).reduce(
|
||||
(memo, thread) => memo + this.inThread(thread.id).length,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of comments that includes the given thread ID and any of it's replies.
|
||||
*
|
||||
* @param commentId ID of the comment to get replies for
|
||||
* @returns Array of comments
|
||||
@ -46,6 +90,40 @@ export default class CommentsStore extends Store<Comment> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a comment thread with the given ID.
|
||||
*
|
||||
* @param id ID of the comment to resolve
|
||||
* @returns Resolved comment
|
||||
*/
|
||||
@action
|
||||
resolve = async (id: string): Promise<Comment> => {
|
||||
const res = await client.post("/comments.resolve", {
|
||||
id,
|
||||
});
|
||||
invariant(res?.data, "Comment not available");
|
||||
this.addPolicies(res.policies);
|
||||
this.add(res.data);
|
||||
return this.data.get(res.data.id) as Comment;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unresolve a comment thread with the given ID.
|
||||
*
|
||||
* @param id ID of the comment to unresolve
|
||||
* @returns Unresolved comment
|
||||
*/
|
||||
@action
|
||||
unresolve = async (id: string): Promise<Comment> => {
|
||||
const res = await client.post("/comments.unresolve", {
|
||||
id,
|
||||
});
|
||||
invariant(res?.data, "Comment not available");
|
||||
this.addPolicies(res.policies);
|
||||
this.add(res.data);
|
||||
return this.data.get(res.data.id) as Comment;
|
||||
};
|
||||
|
||||
@action
|
||||
setTyping({
|
||||
commentId,
|
||||
|
@ -116,6 +116,12 @@ export const pulse = keyframes`
|
||||
100% { transform: scale(1); }
|
||||
`;
|
||||
|
||||
export const bigPulse = keyframes`
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); }
|
||||
100% { transform: scale(1); }
|
||||
`;
|
||||
|
||||
/**
|
||||
* The duration of the sidebar appearing animation in ms
|
||||
*/
|
||||
|
@ -158,7 +158,7 @@
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^6.9.14",
|
||||
"octokit": "^3.2.1",
|
||||
"outline-icons": "^3.4.1",
|
||||
"outline-icons": "^3.5.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth2": "^0.2.0",
|
||||
|
19
server/middlewares/feature.ts
Normal file
19
server/middlewares/feature.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Next } from "koa";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
/**
|
||||
* Middleware to check if a feature is enabled for the team.
|
||||
*
|
||||
* @param preference The preference to check
|
||||
* @returns The middleware function
|
||||
*/
|
||||
export function feature(preference: TeamPreference) {
|
||||
return async function featureEnabledMiddleware(ctx: APIContext, next: Next) {
|
||||
if (!ctx.state.auth.user.team.getPreference(preference)) {
|
||||
throw ValidationError(`${preference} is currently disabled`);
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
@ -13,6 +13,7 @@ import type { ProsemirrorData } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { CommentValidation } from "@shared/validations";
|
||||
import { schema } from "@server/editor";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
@ -26,6 +27,11 @@ import TextLength from "./validators/TextLength";
|
||||
as: "createdBy",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "resolvedBy",
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@Table({ tableName: "comments", modelName: "comment" })
|
||||
@ -54,12 +60,15 @@ class Comment extends ParanoidModel<
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
|
||||
@Column(DataType.DATE)
|
||||
resolvedAt: Date | null;
|
||||
|
||||
@BelongsTo(() => User, "resolvedById")
|
||||
resolvedBy: User;
|
||||
resolvedBy: User | null;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
resolvedById: string;
|
||||
resolvedById: string | null;
|
||||
|
||||
@BelongsTo(() => Document, "documentId")
|
||||
document: Document;
|
||||
@ -75,6 +84,51 @@ class Comment extends ParanoidModel<
|
||||
@Column(DataType.UUID)
|
||||
parentCommentId: string;
|
||||
|
||||
// methods
|
||||
|
||||
/**
|
||||
* Resolve the comment. Note this does not save the comment to the database.
|
||||
*
|
||||
* @param resolvedBy The user who resolved the comment
|
||||
*/
|
||||
public resolve(resolvedBy: User) {
|
||||
if (this.isResolved) {
|
||||
throw ValidationError("Comment is already resolved");
|
||||
}
|
||||
if (this.parentCommentId) {
|
||||
throw ValidationError("Cannot resolve a reply");
|
||||
}
|
||||
|
||||
this.resolvedById = resolvedBy.id;
|
||||
this.resolvedBy = resolvedBy;
|
||||
this.resolvedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unresolve the comment. Note this does not save the comment to the database.
|
||||
*/
|
||||
public unresolve() {
|
||||
if (!this.isResolved) {
|
||||
throw ValidationError("Comment is not resolved");
|
||||
}
|
||||
|
||||
this.resolvedById = null;
|
||||
this.resolvedBy = null;
|
||||
this.resolvedAt = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the comment is resolved
|
||||
*/
|
||||
public get isResolved() {
|
||||
return !!this.resolvedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the comment data to plain text
|
||||
*
|
||||
* @returns The plain text representation of the comment data
|
||||
*/
|
||||
public toPlainText() {
|
||||
const node = Node.fromJSON(schema, this.data);
|
||||
return ProsemirrorHelper.toPlainText(node, schema);
|
||||
|
@ -73,6 +73,10 @@ export default function onerror(app: Koa) {
|
||||
requestErrorHandler(err, this);
|
||||
|
||||
if (!(err instanceof InternalError)) {
|
||||
if (env.ENVIRONMENT === "test") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
}
|
||||
err = InternalError();
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,22 @@ allow(User, "read", Comment, (actor, comment) =>
|
||||
isTeamModel(actor, comment?.createdBy)
|
||||
);
|
||||
|
||||
allow(User, "resolve", Comment, (actor, comment) =>
|
||||
and(
|
||||
isTeamModel(actor, comment?.createdBy),
|
||||
comment?.parentCommentId === null,
|
||||
comment?.resolvedById === null
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "unresolve", Comment, (actor, comment) =>
|
||||
and(
|
||||
isTeamModel(actor, comment?.createdBy),
|
||||
comment?.parentCommentId === null,
|
||||
comment?.resolvedById !== null
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, ["update", "delete"], Comment, (actor, comment) =>
|
||||
and(
|
||||
isTeamModel(actor, comment?.createdBy),
|
||||
|
@ -9,6 +9,9 @@ export default function present(comment: Comment) {
|
||||
parentCommentId: comment.parentCommentId,
|
||||
createdBy: presentUser(comment.createdBy),
|
||||
createdById: comment.createdById,
|
||||
resolvedAt: comment.resolvedAt,
|
||||
resolvedBy: comment.resolvedBy ? presentUser(comment.resolvedBy) : null,
|
||||
resolvedById: comment.resolvedById,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
};
|
||||
|
@ -424,6 +424,7 @@ export default class WebsocketsProcessor {
|
||||
|
||||
case "comments.delete": {
|
||||
const comment = await Comment.findByPk(event.modelId, {
|
||||
paranoid: false,
|
||||
include: [
|
||||
{
|
||||
model: Document.scope(["withoutState", "withDrafts"]),
|
||||
|
@ -26,3 +26,30 @@ exports[`#comments.list should require authentication 1`] = `
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#comments.resolve should require authentication 1`] = `
|
||||
{
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#comments.unresolve should require authentication 1`] = `
|
||||
{
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#comments.update should require authentication 1`] = `
|
||||
{
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { CommentStatusFilter } from "@shared/types";
|
||||
import {
|
||||
buildAdmin,
|
||||
buildCollection,
|
||||
buildComment,
|
||||
buildDocument,
|
||||
buildResolvedComment,
|
||||
buildTeam,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
@ -10,6 +12,73 @@ import { getTestServer } from "@server/test/support";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("#comments.info", () => {
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/comments.info");
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should return comment info", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const user2 = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const comment = await buildComment({
|
||||
userId: user2.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
const res = await server.post("/api/comments.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: comment.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(comment.id);
|
||||
expect(body.data.data).toEqual(comment.data);
|
||||
expect(body.policies.length).toEqual(1);
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
expect(body.policies[0].abilities.update).toEqual(false);
|
||||
expect(body.policies[0].abilities.delete).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return comment info for admin", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildAdmin({ teamId: team.id });
|
||||
const user2 = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const comment = await buildComment({
|
||||
userId: user2.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
const res = await server.post("/api/comments.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: comment.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(comment.id);
|
||||
expect(body.data.data).toEqual(comment.data);
|
||||
expect(body.policies.length).toEqual(1);
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
expect(body.policies[0].abilities.update).toEqual(true);
|
||||
expect(body.policies[0].abilities.delete).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#comments.list", () => {
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/comments.list");
|
||||
@ -29,6 +98,10 @@ describe("#comments.list", () => {
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
await buildResolvedComment(user, {
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
const res = await server.post("/api/comments.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
@ -38,13 +111,14 @@ describe("#comments.list", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(comment.id);
|
||||
expect(body.policies.length).toEqual(1);
|
||||
expect(body.data.length).toEqual(2);
|
||||
expect(body.data[1].id).toEqual(comment.id);
|
||||
expect(body.policies.length).toEqual(2);
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
expect(body.policies[1].abilities.read).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return all comments for a collection", async () => {
|
||||
it("should return unresolved comments for a collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
@ -75,7 +149,71 @@ describe("#comments.list", () => {
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return all comments", async () => {
|
||||
it("should return unresolved comments for a parentCommentId", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const comment = await buildComment({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
const childComment = await buildComment({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
parentCommentId: comment.id,
|
||||
});
|
||||
const res = await server.post("/api/comments.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
parentCommentId: comment.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(childComment.id);
|
||||
expect(body.policies.length).toEqual(1);
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return resolved comments for a statusFilter", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await buildComment({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
const resolved = await buildResolvedComment(user, {
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
const res = await server.post("/api/comments.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
statusFilter: [CommentStatusFilter.Resolved],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(resolved.id);
|
||||
expect(body.policies.length).toEqual(1);
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
expect(body.policies[0].abilities.unresolve).toEqual(true);
|
||||
expect(body.policies[0].abilities.resolve).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return all unresolved comments", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection1 = await buildCollection({
|
||||
@ -310,65 +448,37 @@ describe("#comments.create", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#comments.info", () => {
|
||||
describe("#comments.update", () => {
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/comments.info");
|
||||
const res = await server.post("/api/comments.update");
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should return comment info", async () => {
|
||||
it("should update an existing comment", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const user2 = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const comment = await buildComment({
|
||||
userId: user2.id,
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
const res = await server.post("/api/comments.info", {
|
||||
|
||||
const res = await server.post("/api/comments.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: comment.id,
|
||||
data: comment.data,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(comment.id);
|
||||
expect(body.data.data).toEqual(comment.data);
|
||||
expect(body.policies.length).toEqual(1);
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
expect(body.policies[0].abilities.update).toEqual(false);
|
||||
expect(body.policies[0].abilities.delete).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return comment info for admin", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildAdmin({ teamId: team.id });
|
||||
const user2 = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const comment = await buildComment({
|
||||
userId: user2.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
const res = await server.post("/api/comments.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: comment.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(comment.id);
|
||||
expect(body.data.data).toEqual(comment.data);
|
||||
expect(body.policies.length).toEqual(1);
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
@ -376,3 +486,115 @@ describe("#comments.info", () => {
|
||||
expect(body.policies[0].abilities.delete).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#comments.resolve", () => {
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/comments.resolve");
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should allow resolving a comment thread", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const comment = await buildComment({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/comments.resolve", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: comment.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.resolvedAt).toBeTruthy();
|
||||
expect(body.data.resolvedById).toEqual(user.id);
|
||||
expect(body.data.resolvedBy.id).toEqual(user.id);
|
||||
expect(body.policies.length).toEqual(1);
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
expect(body.policies[0].abilities.update).toEqual(true);
|
||||
expect(body.policies[0].abilities.delete).toEqual(true);
|
||||
expect(body.policies[0].abilities.unresolve).toEqual(true);
|
||||
expect(body.policies[0].abilities.resolve).toEqual(false);
|
||||
});
|
||||
|
||||
it("should not allow resolving a child comment", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const parentComment = await buildComment({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const comment = await buildComment({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
parentCommentId: parentComment.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/comments.resolve", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: comment.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#comments.unresolve", () => {
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/comments.unresolve");
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should allow unresolving a comment", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const comment = await buildResolvedComment(user, {
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/comments.unresolve", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: comment.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.resolvedAt).toEqual(null);
|
||||
expect(body.data.resolvedBy).toEqual(null);
|
||||
expect(body.data.resolvedById).toEqual(null);
|
||||
expect(body.policies.length).toEqual(1);
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
expect(body.policies[0].abilities.update).toEqual(true);
|
||||
expect(body.policies[0].abilities.delete).toEqual(true);
|
||||
expect(body.policies[0].abilities.resolve).toEqual(true);
|
||||
expect(body.policies[0].abilities.unresolve).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
@ -1,16 +1,15 @@
|
||||
import { Next } from "koa";
|
||||
import Router from "koa-router";
|
||||
import { FindOptions, Op } from "sequelize";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { FindOptions, Op, WhereOptions } from "sequelize";
|
||||
import { CommentStatusFilter, TeamPreference } from "@shared/types";
|
||||
import commentCreator from "@server/commands/commentCreator";
|
||||
import commentDestroyer from "@server/commands/commentDestroyer";
|
||||
import commentUpdater from "@server/commands/commentUpdater";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { feature } from "@server/middlewares/feature";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Document, Comment, Collection } from "@server/models";
|
||||
import { Document, Comment, Collection, Event } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentComment, presentPolicies } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
@ -24,7 +23,7 @@ router.post(
|
||||
"comments.create",
|
||||
rateLimiter(RateLimiterStrategy.TenPerMinute),
|
||||
auth(),
|
||||
checkCommentingEnabled(),
|
||||
feature(TeamPreference.Commenting),
|
||||
validate(T.CommentsCreateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CommentsCreateReq>) => {
|
||||
@ -58,7 +57,7 @@ router.post(
|
||||
router.post(
|
||||
"comments.info",
|
||||
auth(),
|
||||
checkCommentingEnabled(),
|
||||
feature(TeamPreference.Commenting),
|
||||
validate(T.CommentsInfoSchema),
|
||||
async (ctx: APIContext<T.CommentsInfoReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
@ -67,14 +66,11 @@ router.post(
|
||||
const comment = await Comment.findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
const document = await Document.findByPk(comment.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", comment);
|
||||
|
||||
if (comment.documentId) {
|
||||
const document = await Document.findByPk(comment.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
}
|
||||
authorize(user, "read", document);
|
||||
|
||||
ctx.body = {
|
||||
data: presentComment(comment),
|
||||
@ -87,13 +83,45 @@ router.post(
|
||||
"comments.list",
|
||||
auth(),
|
||||
pagination(),
|
||||
checkCommentingEnabled(),
|
||||
feature(TeamPreference.Commenting),
|
||||
validate(T.CommentsListSchema),
|
||||
async (ctx: APIContext<T.CommentsListReq>) => {
|
||||
const { sort, direction, documentId, collectionId } = ctx.input.body;
|
||||
const {
|
||||
sort,
|
||||
direction,
|
||||
documentId,
|
||||
parentCommentId,
|
||||
statusFilter,
|
||||
collectionId,
|
||||
} = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const statusQuery = [];
|
||||
|
||||
if (statusFilter?.includes(CommentStatusFilter.Resolved)) {
|
||||
statusQuery.push({ resolvedById: { [Op.not]: null } });
|
||||
}
|
||||
if (statusFilter?.includes(CommentStatusFilter.Unresolved)) {
|
||||
statusQuery.push({ resolvedById: null });
|
||||
}
|
||||
|
||||
const where: WhereOptions<Comment> = {
|
||||
[Op.and]: [],
|
||||
};
|
||||
if (documentId) {
|
||||
// @ts-expect-error ignore
|
||||
where[Op.and].push({ documentId });
|
||||
}
|
||||
if (parentCommentId) {
|
||||
// @ts-expect-error ignore
|
||||
where[Op.and].push({ parentCommentId });
|
||||
}
|
||||
if (statusQuery.length) {
|
||||
// @ts-expect-error ignore
|
||||
where[Op.and].push({ [Op.or]: statusQuery });
|
||||
}
|
||||
|
||||
const params: FindOptions<Comment> = {
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
@ -103,12 +131,7 @@ router.post(
|
||||
if (documentId) {
|
||||
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||
authorize(user, "read", document);
|
||||
comments = await Comment.findAll({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
...params,
|
||||
});
|
||||
comments = await Comment.findAll(params);
|
||||
} else if (collectionId) {
|
||||
const collection = await Collection.findByPk(collectionId);
|
||||
authorize(user, "read", collection);
|
||||
@ -153,7 +176,7 @@ router.post(
|
||||
router.post(
|
||||
"comments.update",
|
||||
auth(),
|
||||
checkCommentingEnabled(),
|
||||
feature(TeamPreference.Commenting),
|
||||
validate(T.CommentsUpdateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CommentsUpdateReq>) => {
|
||||
@ -194,7 +217,7 @@ router.post(
|
||||
router.post(
|
||||
"comments.delete",
|
||||
auth(),
|
||||
checkCommentingEnabled(),
|
||||
feature(TeamPreference.Commenting),
|
||||
validate(T.CommentsDeleteSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CommentsDeleteReq>) => {
|
||||
@ -226,19 +249,98 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
function checkCommentingEnabled() {
|
||||
return async function checkCommentingEnabledMiddleware(
|
||||
ctx: APIContext,
|
||||
next: Next
|
||||
) {
|
||||
if (!ctx.state.auth.user.team.getPreference(TeamPreference.Commenting)) {
|
||||
throw ValidationError("Commenting is currently disabled");
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
router.post(
|
||||
"comments.resolve",
|
||||
auth(),
|
||||
feature(TeamPreference.Commenting),
|
||||
validate(T.CommentsResolveSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CommentsResolveReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
// router.post("comments.resolve", auth(), async (ctx) => {
|
||||
// router.post("comments.unresolve", auth(), async (ctx) => {
|
||||
const comment = await Comment.findByPk(id, {
|
||||
transaction,
|
||||
rejectOnEmpty: true,
|
||||
lock: {
|
||||
level: transaction.LOCK.UPDATE,
|
||||
of: Comment,
|
||||
},
|
||||
});
|
||||
const document = await Document.findByPk(comment.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "resolve", comment);
|
||||
authorize(user, "update", document);
|
||||
|
||||
comment.resolve(user);
|
||||
const changes = comment.changeset;
|
||||
await comment.save({ transaction });
|
||||
|
||||
await Event.createFromContext(
|
||||
ctx,
|
||||
{
|
||||
name: "comments.update",
|
||||
modelId: comment.id,
|
||||
documentId: comment.documentId,
|
||||
changes,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
data: presentComment(comment),
|
||||
policies: presentPolicies(user, [comment]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"comments.unresolve",
|
||||
auth(),
|
||||
feature(TeamPreference.Commenting),
|
||||
validate(T.CommentsUnresolveSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CommentsUnresolveReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const comment = await Comment.findByPk(id, {
|
||||
transaction,
|
||||
rejectOnEmpty: true,
|
||||
lock: {
|
||||
level: transaction.LOCK.UPDATE,
|
||||
of: Comment,
|
||||
},
|
||||
});
|
||||
const document = await Document.findByPk(comment.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "unresolve", comment);
|
||||
authorize(user, "update", document);
|
||||
|
||||
comment.unresolve();
|
||||
const changes = comment.changeset;
|
||||
await comment.save({ transaction });
|
||||
|
||||
await Event.createFromContext(
|
||||
ctx,
|
||||
{
|
||||
name: "comments.update",
|
||||
modelId: comment.id,
|
||||
documentId: comment.documentId,
|
||||
changes,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
data: presentComment(comment),
|
||||
policies: presentPolicies(user, [comment]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { CommentStatusFilter } from "@shared/types";
|
||||
import { BaseSchema, ProsemirrorSchema } from "@server/routes/api/schema";
|
||||
|
||||
const BaseIdSchema = z.object({
|
||||
@ -57,7 +58,12 @@ export const CommentsListSchema = BaseSchema.extend({
|
||||
body: CommentsSortParamsSchema.extend({
|
||||
/** Id of a document to list comments for */
|
||||
documentId: z.string().optional(),
|
||||
collectionId: z.string().uuid().optional(),
|
||||
/** Id of a collection to list comments for */
|
||||
collectionId: z.string().optional(),
|
||||
/** Id of a parent comment to list comments for */
|
||||
parentCommentId: z.string().uuid().optional(),
|
||||
/** Comment statuses to include in results */
|
||||
statusFilter: z.nativeEnum(CommentStatusFilter).array().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
@ -68,3 +74,15 @@ export const CommentsInfoSchema = z.object({
|
||||
});
|
||||
|
||||
export type CommentsInfoReq = z.infer<typeof CommentsInfoSchema>;
|
||||
|
||||
export const CommentsResolveSchema = z.object({
|
||||
body: BaseIdSchema,
|
||||
});
|
||||
|
||||
export type CommentsResolveReq = z.infer<typeof CommentsResolveSchema>;
|
||||
|
||||
export const CommentsUnresolveSchema = z.object({
|
||||
body: BaseIdSchema,
|
||||
});
|
||||
|
||||
export type CommentsUnresolveReq = z.infer<typeof CommentsUnresolveSchema>;
|
||||
|
@ -403,8 +403,12 @@ export async function buildDocument(
|
||||
export async function buildComment(overrides: {
|
||||
userId: string;
|
||||
documentId: string;
|
||||
parentCommentId?: string;
|
||||
resolvedById?: string;
|
||||
}) {
|
||||
const comment = await Comment.create({
|
||||
resolvedById: overrides.resolvedById,
|
||||
parentCommentId: overrides.parentCommentId,
|
||||
documentId: overrides.documentId,
|
||||
data: {
|
||||
type: "doc",
|
||||
@ -427,6 +431,16 @@ export async function buildComment(overrides: {
|
||||
return comment;
|
||||
}
|
||||
|
||||
export async function buildResolvedComment(
|
||||
user: User,
|
||||
overrides: Parameters<typeof buildComment>[0]
|
||||
) {
|
||||
const comment = await buildComment(overrides);
|
||||
comment.resolve(user);
|
||||
await comment.save();
|
||||
return comment;
|
||||
}
|
||||
|
||||
export async function buildFileOperation(
|
||||
overrides: Partial<FileOperation> = {}
|
||||
) {
|
||||
|
20
shared/editor/commands/addMark.ts
Normal file
20
shared/editor/commands/addMark.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Attrs, MarkType } from "prosemirror-model";
|
||||
import { Command } from "prosemirror-state";
|
||||
|
||||
/**
|
||||
* A prosemirror command to create a mark at the current selection.
|
||||
*
|
||||
* @returns A prosemirror command.
|
||||
*/
|
||||
export const addMark =
|
||||
(type: MarkType, attrs?: Attrs | null): Command =>
|
||||
(state, dispatch) => {
|
||||
dispatch?.(
|
||||
state.tr.addMark(
|
||||
state.selection.from,
|
||||
state.selection.to,
|
||||
type.create(attrs)
|
||||
)
|
||||
);
|
||||
return true;
|
||||
};
|
@ -1,6 +1,11 @@
|
||||
import { Command, TextSelection } from "prosemirror-state";
|
||||
|
||||
const collapseSelection = (): Command => (state, dispatch) => {
|
||||
/**
|
||||
* A prosemirror command to collapse the current selection to a cursor at the start of the selection.
|
||||
*
|
||||
* @returns A prosemirror command.
|
||||
*/
|
||||
export const collapseSelection = (): Command => (state, dispatch) => {
|
||||
dispatch?.(
|
||||
state.tr.setSelection(
|
||||
TextSelection.create(state.doc, state.tr.selection.from)
|
||||
@ -8,5 +13,3 @@ const collapseSelection = (): Command => (state, dispatch) => {
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
export default collapseSelection;
|
||||
|
@ -14,7 +14,7 @@ import {
|
||||
import { chainTransactions } from "../lib/chainTransactions";
|
||||
import { getCellsInColumn, isHeaderEnabled } from "../queries/table";
|
||||
import { TableLayout } from "../types";
|
||||
import collapseSelection from "./collapseSelection";
|
||||
import { collapseSelection } from "./collapseSelection";
|
||||
|
||||
export function createTable({
|
||||
rowsCount,
|
||||
|
@ -857,14 +857,16 @@ h6 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.comment-marker {
|
||||
border-bottom: 2px solid ${props.theme.commentMarkBackground};
|
||||
transition: background 100ms ease-in-out;
|
||||
border-radius: 2px;
|
||||
.${EditorStyleHelper.comment} {
|
||||
&:not([data-resolved]) {
|
||||
border-bottom: 2px solid ${props.theme.commentMarkBackground};
|
||||
transition: background 100ms ease-in-out;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
${props.readOnly ? "cursor: var(--pointer);" : ""}
|
||||
background: ${props.theme.commentMarkBackground};
|
||||
&:hover {
|
||||
${props.readOnly ? "cursor: var(--pointer);" : ""}
|
||||
background: ${props.theme.commentMarkBackground};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1768,7 +1770,7 @@ del[data-operation-index] {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.comment-marker {
|
||||
.${EditorStyleHelper.comment} {
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
@ -2,9 +2,11 @@ import { toggleMark } from "prosemirror-commands";
|
||||
import { MarkSpec, MarkType, Schema, Mark as PMMark } from "prosemirror-model";
|
||||
import { Command, Plugin } from "prosemirror-state";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import collapseSelection from "../commands/collapseSelection";
|
||||
import { addMark } from "../commands/addMark";
|
||||
import { collapseSelection } from "../commands/collapseSelection";
|
||||
import { chainTransactions } from "../lib/chainTransactions";
|
||||
import { isMarkActive } from "../queries/isMarkActive";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
import Mark from "./Mark";
|
||||
|
||||
export default class Comment extends Mark {
|
||||
@ -17,11 +19,14 @@ export default class Comment extends Mark {
|
||||
attrs: {
|
||||
id: {},
|
||||
userId: {},
|
||||
resolved: {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
inclusive: false,
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "span.comment-marker",
|
||||
tag: `.${EditorStyleHelper.comment}`,
|
||||
getAttrs: (dom: HTMLSpanElement) => {
|
||||
// Ignore comment markers from other documents
|
||||
const documentId = dom.getAttribute("data-document-id");
|
||||
@ -32,6 +37,7 @@ export default class Comment extends Mark {
|
||||
return {
|
||||
id: dom.getAttribute("id")?.replace("comment-", ""),
|
||||
userId: dom.getAttribute("data-user-id"),
|
||||
resolved: !!dom.getAttribute("data-resolved"),
|
||||
};
|
||||
},
|
||||
},
|
||||
@ -39,8 +45,9 @@ export default class Comment extends Mark {
|
||||
toDOM: (node) => [
|
||||
"span",
|
||||
{
|
||||
class: "comment-marker",
|
||||
class: EditorStyleHelper.comment,
|
||||
id: `comment-${node.attrs.id}`,
|
||||
"data-resolved": node.attrs.resolved ? "true" : undefined,
|
||||
"data-user-id": node.attrs.userId,
|
||||
"data-document-id": this.editor?.props.id,
|
||||
},
|
||||
@ -56,7 +63,11 @@ export default class Comment extends Mark {
|
||||
return this.options.onCreateCommentMark
|
||||
? {
|
||||
"Mod-Alt-m": (state, dispatch) => {
|
||||
if (isMarkActive(state.schema.marks.comment)(state)) {
|
||||
if (
|
||||
isMarkActive(state.schema.marks.comment, { resolved: false })(
|
||||
state
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -77,12 +88,14 @@ export default class Comment extends Mark {
|
||||
commands({ type }: { type: MarkType; schema: Schema }) {
|
||||
return this.options.onCreateCommentMark
|
||||
? (): Command => (state, dispatch) => {
|
||||
if (isMarkActive(state.schema.marks.comment)(state)) {
|
||||
if (
|
||||
isMarkActive(state.schema.marks.comment, { resolved: false })(state)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chainTransactions(
|
||||
toggleMark(type, {
|
||||
addMark(type, {
|
||||
id: uuidv4(),
|
||||
userId: this.options.userId,
|
||||
}),
|
||||
@ -152,13 +165,16 @@ export default class Comment extends Mark {
|
||||
return false;
|
||||
}
|
||||
|
||||
const comment = event.target.closest(".comment-marker");
|
||||
const comment = event.target.closest(
|
||||
`.${EditorStyleHelper.comment}`
|
||||
);
|
||||
if (!comment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const commentId = comment.id.replace("comment-", "");
|
||||
if (commentId) {
|
||||
const resolved = comment.getAttribute("data-resolved");
|
||||
if (commentId && !resolved) {
|
||||
this.options?.onClickCommentMark?.(commentId);
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,10 @@
|
||||
* Class names and values used by the editor.
|
||||
*/
|
||||
export class EditorStyleHelper {
|
||||
// Comments
|
||||
|
||||
static readonly comment = "comment-marker";
|
||||
|
||||
// Tables
|
||||
|
||||
/** Table wrapper */
|
||||
@ -34,6 +38,8 @@ export class EditorStyleHelper {
|
||||
/** Shadow on the left side of the table */
|
||||
static readonly tableShadowLeft = "table-shadow-left";
|
||||
|
||||
// Global
|
||||
|
||||
/** Minimum padding around editor */
|
||||
static readonly padding = 32;
|
||||
|
||||
|
@ -14,6 +14,10 @@
|
||||
"Delete": "Delete",
|
||||
"Delete collection": "Delete collection",
|
||||
"New template": "New template",
|
||||
"Delete comment": "Delete comment",
|
||||
"Mark as resolved": "Mark as resolved",
|
||||
"Thread resolved": "Thread resolved",
|
||||
"Mark as unresolved": "Mark as unresolved",
|
||||
"Copy ID": "Copy ID",
|
||||
"Clear IndexedDB cache": "Clear IndexedDB cache",
|
||||
"IndexedDB cache cleared": "IndexedDB cache cleared",
|
||||
@ -469,7 +473,6 @@
|
||||
"Sort in sidebar": "Sort in sidebar",
|
||||
"Alphabetical sort": "Alphabetical sort",
|
||||
"Manual sort": "Manual sort",
|
||||
"Delete comment": "Delete comment",
|
||||
"Comment options": "Comment options",
|
||||
"Document restored": "Document restored",
|
||||
"Document options": "Document options",
|
||||
@ -551,6 +554,10 @@
|
||||
"Post": "Post",
|
||||
"Cancel": "Cancel",
|
||||
"Upload image": "Upload image",
|
||||
"Resolved comments": "Resolved comments",
|
||||
"View comments": "View comments",
|
||||
"View resolved comments": "View resolved comments",
|
||||
"No resolved comments": "No resolved comments",
|
||||
"No comments yet": "No comments yet",
|
||||
"Error updating comment": "Error updating comment",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
|
||||
@ -700,6 +707,7 @@
|
||||
"Publish document and exit": "Publish document and exit",
|
||||
"Save document": "Save document",
|
||||
"Cancel editing": "Cancel editing",
|
||||
"Collaboration": "Collaboration",
|
||||
"Formatting": "Formatting",
|
||||
"Paragraph": "Paragraph",
|
||||
"Large header": "Large header",
|
||||
|
@ -13,6 +13,11 @@ export enum StatusFilter {
|
||||
Draft = "draft",
|
||||
}
|
||||
|
||||
export enum CommentStatusFilter {
|
||||
Resolved = "resolved",
|
||||
Unresolved = "unresolved",
|
||||
}
|
||||
|
||||
export enum Client {
|
||||
Web = "web",
|
||||
Desktop = "desktop",
|
||||
|
126
yarn.lock
126
yarn.lock
@ -8173,54 +8173,7 @@ error-ex@^1.3.1:
|
||||
dependencies:
|
||||
is-arrayish "^0.2.1"
|
||||
|
||||
es-abstract@^1.22.1, es-abstract@^1.22.3:
|
||||
version "1.22.5"
|
||||
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.22.5.tgz#1417df4e97cc55f09bf7e58d1e614bc61cb8df46"
|
||||
integrity sha512-oW69R+4q2wG+Hc3KZePPZxOiisRIqfKBVo/HLx94QcJeWGU/8sZhCvc829rd1kS366vlJbzBfXf9yWwf0+Ko7w==
|
||||
dependencies:
|
||||
array-buffer-byte-length "^1.0.1"
|
||||
arraybuffer.prototype.slice "^1.0.3"
|
||||
available-typed-arrays "^1.0.7"
|
||||
call-bind "^1.0.7"
|
||||
es-define-property "^1.0.0"
|
||||
es-errors "^1.3.0"
|
||||
es-set-tostringtag "^2.0.3"
|
||||
es-to-primitive "^1.2.1"
|
||||
function.prototype.name "^1.1.6"
|
||||
get-intrinsic "^1.2.4"
|
||||
get-symbol-description "^1.0.2"
|
||||
globalthis "^1.0.3"
|
||||
gopd "^1.0.1"
|
||||
has-property-descriptors "^1.0.2"
|
||||
has-proto "^1.0.3"
|
||||
has-symbols "^1.0.3"
|
||||
hasown "^2.0.1"
|
||||
internal-slot "^1.0.7"
|
||||
is-array-buffer "^3.0.4"
|
||||
is-callable "^1.2.7"
|
||||
is-negative-zero "^2.0.3"
|
||||
is-regex "^1.1.4"
|
||||
is-shared-array-buffer "^1.0.3"
|
||||
is-string "^1.0.7"
|
||||
is-typed-array "^1.1.13"
|
||||
is-weakref "^1.0.2"
|
||||
object-inspect "^1.13.1"
|
||||
object-keys "^1.1.1"
|
||||
object.assign "^4.1.5"
|
||||
regexp.prototype.flags "^1.5.2"
|
||||
safe-array-concat "^1.1.0"
|
||||
safe-regex-test "^1.0.3"
|
||||
string.prototype.trim "^1.2.8"
|
||||
string.prototype.trimend "^1.0.7"
|
||||
string.prototype.trimstart "^1.0.7"
|
||||
typed-array-buffer "^1.0.2"
|
||||
typed-array-byte-length "^1.0.1"
|
||||
typed-array-byte-offset "^1.0.2"
|
||||
typed-array-length "^1.0.5"
|
||||
unbox-primitive "^1.0.2"
|
||||
which-typed-array "^1.1.14"
|
||||
|
||||
es-abstract@^1.23.0, es-abstract@^1.23.2, es-abstract@^1.23.3:
|
||||
es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2, es-abstract@^1.23.3:
|
||||
version "1.23.3"
|
||||
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0"
|
||||
integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==
|
||||
@ -12406,10 +12359,10 @@ os-tmpdir@~1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
|
||||
integrity "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="
|
||||
|
||||
outline-icons@^3.4.1:
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.4.1.tgz#2a7c17f7d2b132359a6cc00f449371fa0adb3450"
|
||||
integrity sha512-H6FRWVLNammxqNpA1n5ktN4T6eAhuLyTI6A8d0mukkz7y/CDCWiffcLetlWhZf9m/jv/EU8ZCOwVSY3CmVeU6Q==
|
||||
outline-icons@^3.5.0:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.5.0.tgz#acc3896a3f0eae2ab70fe693d1d1a924cced6e0f"
|
||||
integrity sha512-zZAbnR6gjXI4KLEmVj3EsdrlVG3YXBmZ1clY5O1zI5LfaLXQvUAThV/z5MxZpMwcNVYOZMRyXv/W1Sy0TNwCsA==
|
||||
|
||||
oy-vey@^0.12.1:
|
||||
version "0.12.1"
|
||||
@ -14128,16 +14081,7 @@ set-function-length@^1.2.1:
|
||||
gopd "^1.0.1"
|
||||
has-property-descriptors "^1.0.2"
|
||||
|
||||
set-function-name@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.1.tgz#12ce38b7954310b9f61faa12701620a0c882793a"
|
||||
integrity "sha1-Es44t5VDELn2H6oScBYgoMiCeTo= sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA=="
|
||||
dependencies:
|
||||
define-data-property "^1.0.1"
|
||||
functions-have-names "^1.2.3"
|
||||
has-property-descriptors "^1.0.0"
|
||||
|
||||
set-function-name@^2.0.2:
|
||||
set-function-name@^2.0.1, set-function-name@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985"
|
||||
integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==
|
||||
@ -14189,16 +14133,7 @@ shell-quote@^1.8.1:
|
||||
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680"
|
||||
integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==
|
||||
|
||||
side-channel@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
|
||||
integrity "sha1-785cj9wQTudRslxY1CkAEfpeos8= sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw=="
|
||||
dependencies:
|
||||
call-bind "^1.0.0"
|
||||
get-intrinsic "^1.0.2"
|
||||
object-inspect "^1.9.0"
|
||||
|
||||
side-channel@^1.0.6:
|
||||
side-channel@^1.0.4, side-channel@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2"
|
||||
integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==
|
||||
@ -14549,16 +14484,7 @@ string.prototype.matchall@^4.0.11, string.prototype.matchall@^4.0.6:
|
||||
set-function-name "^2.0.2"
|
||||
side-channel "^1.0.6"
|
||||
|
||||
string.prototype.trim@^1.2.8:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz#f9ac6f8af4bd55ddfa8895e6aea92a96395393bd"
|
||||
integrity sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
define-properties "^1.2.0"
|
||||
es-abstract "^1.22.1"
|
||||
|
||||
string.prototype.trim@^1.2.9:
|
||||
string.prototype.trim@^1.2.8, string.prototype.trim@^1.2.9:
|
||||
version "1.2.9"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4"
|
||||
integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==
|
||||
@ -14568,16 +14494,7 @@ string.prototype.trim@^1.2.9:
|
||||
es-abstract "^1.23.0"
|
||||
es-object-atoms "^1.0.0"
|
||||
|
||||
string.prototype.trimend@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz#1bb3afc5008661d73e2dc015cd4853732d6c471e"
|
||||
integrity sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
define-properties "^1.2.0"
|
||||
es-abstract "^1.22.1"
|
||||
|
||||
string.prototype.trimend@^1.0.8:
|
||||
string.prototype.trimend@^1.0.7, string.prototype.trimend@^1.0.8:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz#3651b8513719e8a9f48de7f2f77640b26652b229"
|
||||
integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==
|
||||
@ -14586,16 +14503,7 @@ string.prototype.trimend@^1.0.8:
|
||||
define-properties "^1.2.1"
|
||||
es-object-atoms "^1.0.0"
|
||||
|
||||
string.prototype.trimstart@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz#d4cdb44b83a4737ffbac2d406e405d43d0184298"
|
||||
integrity sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
define-properties "^1.2.0"
|
||||
es-abstract "^1.22.1"
|
||||
|
||||
string.prototype.trimstart@^1.0.8:
|
||||
string.prototype.trimstart@^1.0.7, string.prototype.trimstart@^1.0.8:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde"
|
||||
integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==
|
||||
@ -15190,19 +15098,7 @@ typed-array-byte-offset@^1.0.2:
|
||||
has-proto "^1.0.3"
|
||||
is-typed-array "^1.1.13"
|
||||
|
||||
typed-array-length@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.5.tgz#57d44da160296d8663fd63180a1802ebf25905d5"
|
||||
integrity sha512-yMi0PlwuznKHxKmcpoOdeLwxBoVPkqZxd7q2FgMkmD3bNwvF5VW0+UlUQ1k1vmktTu4Yu13Q0RIxEP8+B+wloA==
|
||||
dependencies:
|
||||
call-bind "^1.0.7"
|
||||
for-each "^0.3.3"
|
||||
gopd "^1.0.1"
|
||||
has-proto "^1.0.3"
|
||||
is-typed-array "^1.1.13"
|
||||
possible-typed-array-names "^1.0.0"
|
||||
|
||||
typed-array-length@^1.0.6:
|
||||
typed-array-length@^1.0.5, typed-array-length@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3"
|
||||
integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==
|
||||
|
Reference in New Issue
Block a user