feat: Comment resolving (#7115)
This commit is contained in:
@@ -41,6 +41,7 @@
|
|||||||
"@typescript-eslint/no-shadow": [
|
"@typescript-eslint/no-shadow": [
|
||||||
"warn",
|
"warn",
|
||||||
{
|
{
|
||||||
|
"allow": ["transaction"],
|
||||||
"hoist": "all",
|
"hoist": "all",
|
||||||
"ignoreTypeValueShadow": true
|
"ignoreTypeValueShadow": true
|
||||||
}
|
}
|
||||||
@@ -139,4 +140,4 @@
|
|||||||
"typescript": {}
|
"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)[];
|
actions?: (Action | MenuSeparator | MenuHeading)[];
|
||||||
context?: Partial<ActionContext>;
|
context?: Partial<ActionContext>;
|
||||||
items?: TMenuItem[];
|
items?: TMenuItem[];
|
||||||
|
showIcons?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Disclosure = styled(ExpandedIcon)`
|
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({
|
const ctx = useActionContext({
|
||||||
isContextMenu: true,
|
isContextMenu: true,
|
||||||
});
|
});
|
||||||
@@ -124,7 +125,8 @@ function Template({ items, actions, context, ...menu }: Props) {
|
|||||||
if (
|
if (
|
||||||
iconIsPresentInAnyMenuItem &&
|
iconIsPresentInAnyMenuItem &&
|
||||||
item.type !== "separator" &&
|
item.type !== "separator" &&
|
||||||
item.type !== "heading"
|
item.type !== "heading" &&
|
||||||
|
showIcons !== false
|
||||||
) {
|
) {
|
||||||
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
|
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
|
||||||
}
|
}
|
||||||
@@ -138,7 +140,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
|||||||
key={index}
|
key={index}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
selected={item.selected}
|
selected={item.selected}
|
||||||
icon={item.icon}
|
icon={showIcons !== false ? item.icon : undefined}
|
||||||
{...menu}
|
{...menu}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
@@ -156,7 +158,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
|||||||
selected={item.selected}
|
selected={item.selected}
|
||||||
level={item.level}
|
level={item.level}
|
||||||
target={item.href.startsWith("#") ? undefined : "_blank"}
|
target={item.href.startsWith("#") ? undefined : "_blank"}
|
||||||
icon={item.icon}
|
icon={showIcons !== false ? item.icon : undefined}
|
||||||
{...menu}
|
{...menu}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
@@ -174,7 +176,7 @@ function Template({ items, actions, context, ...menu }: Props) {
|
|||||||
selected={item.selected}
|
selected={item.selected}
|
||||||
dangerous={item.dangerous}
|
dangerous={item.dangerous}
|
||||||
key={index}
|
key={index}
|
||||||
icon={item.icon}
|
icon={showIcons !== false ? item.icon : undefined}
|
||||||
{...menu}
|
{...menu}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
@@ -190,7 +192,12 @@ function Template({ items, actions, context, ...menu }: Props) {
|
|||||||
id={`${item.title}-${index}`}
|
id={`${item.title}-${index}`}
|
||||||
templateItems={item.items}
|
templateItems={item.items}
|
||||||
parentMenuState={menu}
|
parentMenuState={menu}
|
||||||
title={<Title title={item.title} icon={item.icon} />}
|
title={
|
||||||
|
<Title
|
||||||
|
title={item.title}
|
||||||
|
icon={showIcons !== false ? item.icon : undefined}
|
||||||
|
/>
|
||||||
|
}
|
||||||
{...menu}
|
{...menu}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -640,27 +640,56 @@ export class Editor extends React.PureComponent<
|
|||||||
public getComments = () => ProsemirrorHelper.getComments(this.view.state.doc);
|
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
|
* @param commentId The id of the comment to remove
|
||||||
*/
|
*/
|
||||||
public removeComment = (commentId: string) => {
|
public removeComment = (commentId: string) => {
|
||||||
const { state, dispatch } = this.view;
|
const { state, dispatch } = this.view;
|
||||||
let found = false;
|
|
||||||
state.doc.descendants((node, pos) => {
|
state.doc.descendants((node, pos) => {
|
||||||
if (!node.isInline || found) {
|
if (!node.isInline) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mark = node.marks.find(
|
const mark = node.marks.find(
|
||||||
(mark) =>
|
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
||||||
mark.type === state.schema.marks.comment &&
|
|
||||||
mark.attrs.id === commentId
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mark) {
|
if (mark) {
|
||||||
dispatch(state.tr.removeMark(pos, pos + node.nodeSize, 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`
|
css`
|
||||||
#comment-${props.focusedCommentId} {
|
#comment-${props.focusedCommentId} {
|
||||||
background: ${transparentize(0.5, props.theme.brand.marine)};
|
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,
|
tooltip: dictionary.comment,
|
||||||
icon: <CommentIcon />,
|
icon: <CommentIcon />,
|
||||||
label: isCodeBlock ? dictionary.comment : undefined,
|
label: isCodeBlock ? dictionary.comment : undefined,
|
||||||
active: isMarkActive(schema.marks.comment),
|
active: isMarkActive(schema.marks.comment, { resolved: false }),
|
||||||
visible: !isMobile || !isEmpty,
|
visible: !isMobile || !isEmpty,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { CopyIcon, EditIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMenuState } from "reakit/Menu";
|
import { useMenuState } from "reakit/Menu";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import EventBoundary from "@shared/components/EventBoundary";
|
import EventBoundary from "@shared/components/EventBoundary";
|
||||||
import Comment from "~/models/Comment";
|
import Comment from "~/models/Comment";
|
||||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
|
||||||
import ContextMenu from "~/components/ContextMenu";
|
import ContextMenu from "~/components/ContextMenu";
|
||||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
|
||||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
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 usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { commentPath, urlify } from "~/utils/routeHelpers";
|
import { commentPath, urlify } from "~/utils/routeHelpers";
|
||||||
@@ -24,24 +30,26 @@ type Props = {
|
|||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
/** Callback when the comment has been deleted */
|
/** Callback when the comment has been deleted */
|
||||||
onDelete: () => void;
|
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({
|
const menu = useMenuState({
|
||||||
modal: true,
|
modal: true,
|
||||||
});
|
});
|
||||||
const { documents, dialogs } = useStores();
|
const { documents } = useStores();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const can = usePolicy(comment);
|
const can = usePolicy(comment);
|
||||||
|
const context = useActionContext({ isContextMenu: true });
|
||||||
const document = documents.get(comment.documentId);
|
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(() => {
|
const handleCopyLink = React.useCallback(() => {
|
||||||
if (document) {
|
if (document) {
|
||||||
copy(urlify(commentPath(document, comment)));
|
copy(urlify(commentPath(document, comment)));
|
||||||
@@ -58,24 +66,46 @@ function CommentMenu({ comment, onEdit, onDelete, className }: Props) {
|
|||||||
{...menu}
|
{...menu}
|
||||||
/>
|
/>
|
||||||
</EventBoundary>
|
</EventBoundary>
|
||||||
|
|
||||||
<ContextMenu {...menu} aria-label={t("Comment options")}>
|
<ContextMenu {...menu} aria-label={t("Comment options")}>
|
||||||
{can.update && (
|
<Template
|
||||||
<MenuItem {...menu} onClick={onEdit}>
|
{...menu}
|
||||||
{t("Edit")}
|
items={[
|
||||||
</MenuItem>
|
{
|
||||||
)}
|
type: "button",
|
||||||
<MenuItem {...menu} onClick={handleCopyLink}>
|
title: `${t("Edit")}…`,
|
||||||
{t("Copy link")}
|
icon: <EditIcon />,
|
||||||
</MenuItem>
|
onClick: onEdit,
|
||||||
{can.delete && (
|
visible: can.update,
|
||||||
<>
|
},
|
||||||
<Separator />
|
actionToMenuItem(
|
||||||
<MenuItem {...menu} onClick={handleDelete} dangerous>
|
resolveCommentFactory({
|
||||||
{t("Delete")}
|
comment,
|
||||||
</MenuItem>
|
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>
|
</ContextMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, observable } from "mobx";
|
|||||||
import { now } from "mobx-utils";
|
import { now } from "mobx-utils";
|
||||||
import type { ProsemirrorData } from "@shared/types";
|
import type { ProsemirrorData } from "@shared/types";
|
||||||
import User from "~/models/User";
|
import User from "~/models/User";
|
||||||
|
import Document from "./Document";
|
||||||
import Model from "./base/Model";
|
import Model from "./base/Model";
|
||||||
import Field from "./decorators/Field";
|
import Field from "./decorators/Field";
|
||||||
import Relation from "./decorators/Relation";
|
import Relation from "./decorators/Relation";
|
||||||
@@ -34,7 +35,7 @@ class Comment extends Model {
|
|||||||
*/
|
*/
|
||||||
@Field
|
@Field
|
||||||
@observable
|
@observable
|
||||||
parentCommentId: string;
|
parentCommentId: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The comment that this comment is a reply to.
|
* The comment that this comment is a reply to.
|
||||||
@@ -43,33 +44,86 @@ class Comment extends Model {
|
|||||||
parentComment?: Comment;
|
parentComment?: Comment;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The document to which this comment belongs.
|
* The document ID to which this comment belongs.
|
||||||
*/
|
*/
|
||||||
@Field
|
@Field
|
||||||
@observable
|
@observable
|
||||||
documentId: string;
|
documentId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The document that this comment belongs to.
|
||||||
|
*/
|
||||||
|
@Relation(() => Document, { onDelete: "cascade" })
|
||||||
|
document: Document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user who created this comment.
|
||||||
|
*/
|
||||||
@Relation(() => User)
|
@Relation(() => User)
|
||||||
createdBy: User;
|
createdBy: User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the user who created this comment.
|
||||||
|
*/
|
||||||
createdById: string;
|
createdById: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date and time that this comment was resolved, if it has been resolved.
|
||||||
|
*/
|
||||||
@observable
|
@observable
|
||||||
resolvedAt: string;
|
resolvedAt: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user who resolved this comment, if it has been resolved.
|
||||||
|
*/
|
||||||
@Relation(() => User)
|
@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.
|
* An array of users that are currently typing a reply in this comments thread.
|
||||||
*/
|
*/
|
||||||
@computed
|
@computed
|
||||||
get currentlyTypingUsers(): User[] {
|
public get currentlyTypingUsers(): User[] {
|
||||||
return Array.from(this.typingUsers.entries())
|
return Array.from(this.typingUsers.entries())
|
||||||
.filter(([, lastReceivedDate]) => lastReceivedDate > subSeconds(now(), 3))
|
.filter(([, lastReceivedDate]) => lastReceivedDate > subSeconds(now(), 3))
|
||||||
.map(([userId]) => this.store.rootStore.users.get(userId))
|
.map(([userId]) => this.store.rootStore.users.get(userId))
|
||||||
.filter(Boolean) as User[];
|
.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;
|
export default Comment;
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ function CommentForm({
|
|||||||
thread ??
|
thread ??
|
||||||
new Comment(
|
new Comment(
|
||||||
{
|
{
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
documentId,
|
documentId,
|
||||||
data: draft,
|
data: draft,
|
||||||
},
|
},
|
||||||
@@ -139,6 +140,7 @@ function CommentForm({
|
|||||||
|
|
||||||
const comment = new Comment(
|
const comment = new Comment(
|
||||||
{
|
{
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
parentCommentId: thread?.id,
|
parentCommentId: thread?.id,
|
||||||
documentId,
|
documentId,
|
||||||
data: draft,
|
data: draft,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import throttle from "lodash/throttle";
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
@@ -70,6 +70,7 @@ function CommentThread({
|
|||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
const location = useLocation();
|
||||||
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
||||||
const [, setIsTyping] = useTypingIndicator({
|
const [, setIsTyping] = useTypingIndicator({
|
||||||
document,
|
document,
|
||||||
@@ -92,7 +93,8 @@ function CommentThread({
|
|||||||
!(event.target as HTMLElement).classList.contains("comment")
|
!(event.target as HTMLElement).classList.contains("comment")
|
||||||
) {
|
) {
|
||||||
history.replace({
|
history.replace({
|
||||||
pathname: window.location.pathname,
|
search: location.search,
|
||||||
|
pathname: location.pathname,
|
||||||
state: { commentId: undefined },
|
state: { commentId: undefined },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -100,7 +102,8 @@ function CommentThread({
|
|||||||
|
|
||||||
const handleClickThread = () => {
|
const handleClickThread = () => {
|
||||||
history.replace({
|
history.replace({
|
||||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
search: location.search,
|
||||||
|
pathname: location.pathname.replace(/\/history$/, ""),
|
||||||
state: { commentId: thread.id },
|
state: { commentId: thread.id },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -177,6 +180,7 @@ function CommentThread({
|
|||||||
highlightedText={index === 0 ? highlightedText : undefined}
|
highlightedText={index === 0 ? highlightedText : undefined}
|
||||||
comment={comment}
|
comment={comment}
|
||||||
onDelete={() => editor?.removeComment(comment.id)}
|
onDelete={() => editor?.removeComment(comment.id)}
|
||||||
|
onUpdate={(attrs) => editor?.updateComment(comment.id, attrs)}
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
firstOfThread={index === 0}
|
firstOfThread={index === 0}
|
||||||
lastOfThread={index === commentsInThread.length - 1 && !draft}
|
lastOfThread={index === commentsInThread.length - 1 && !draft}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
|
import EventBoundary from "@shared/components/EventBoundary";
|
||||||
import { s } from "@shared/styles";
|
import { s } from "@shared/styles";
|
||||||
import { ProsemirrorData } from "@shared/types";
|
import { ProsemirrorData } from "@shared/types";
|
||||||
import { dateToRelative } from "@shared/utils/date";
|
import { dateToRelative } from "@shared/utils/date";
|
||||||
@@ -76,6 +77,8 @@ type Props = {
|
|||||||
canReply: boolean;
|
canReply: boolean;
|
||||||
/** Callback when the comment has been deleted */
|
/** Callback when the comment has been deleted */
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
/** Callback when the comment has been updated */
|
||||||
|
onUpdate: (attrs: { resolved: boolean }) => void;
|
||||||
/** Text to highlight at the top of the comment */
|
/** Text to highlight at the top of the comment */
|
||||||
highlightedText?: string;
|
highlightedText?: string;
|
||||||
};
|
};
|
||||||
@@ -89,6 +92,7 @@ function CommentThreadItem({
|
|||||||
previousCommentCreatedAt,
|
previousCommentCreatedAt,
|
||||||
canReply,
|
canReply,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onUpdate,
|
||||||
highlightedText,
|
highlightedText,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -97,7 +101,9 @@ function CommentThreadItem({
|
|||||||
const showAuthor = firstOfAuthor;
|
const showAuthor = firstOfAuthor;
|
||||||
const showTime = useShowTime(comment.createdAt, previousCommentCreatedAt);
|
const showTime = useShowTime(comment.createdAt, previousCommentCreatedAt);
|
||||||
const showEdited =
|
const showEdited =
|
||||||
comment.updatedAt && comment.updatedAt !== comment.createdAt;
|
comment.updatedAt &&
|
||||||
|
comment.updatedAt !== comment.createdAt &&
|
||||||
|
!comment.isResolved;
|
||||||
const [isEditing, setEditing, setReadOnly] = useBoolean();
|
const [isEditing, setEditing, setReadOnly] = useBoolean();
|
||||||
const formRef = React.useRef<HTMLFormElement>(null);
|
const formRef = React.useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
@@ -198,14 +204,17 @@ function CommentThreadItem({
|
|||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</Body>
|
</Body>
|
||||||
{!isEditing && (
|
<EventBoundary>
|
||||||
<Menu
|
{!isEditing && (
|
||||||
comment={comment}
|
<Menu
|
||||||
onEdit={setEditing}
|
comment={comment}
|
||||||
onDelete={onDelete}
|
onEdit={setEditing}
|
||||||
dir={dir}
|
onDelete={onDelete}
|
||||||
/>
|
onUpdate={onUpdate}
|
||||||
)}
|
dir={dir}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</EventBoundary>
|
||||||
</Bubble>
|
</Bubble>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { AnimatePresence } from "framer-motion";
|
import { AnimatePresence } from "framer-motion";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { DoneIcon } from "outline-icons";
|
||||||
|
import queryString from "query-string";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useRouteMatch } from "react-router-dom";
|
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
import { ProsemirrorData } from "@shared/types";
|
import { ProsemirrorData } from "@shared/types";
|
||||||
|
import Button from "~/components/Button";
|
||||||
import Empty from "~/components/Empty";
|
import Empty from "~/components/Empty";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import Scrollable from "~/components/Scrollable";
|
import Scrollable from "~/components/Scrollable";
|
||||||
|
import Tooltip from "~/components/Tooltip";
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||||
import useKeyDown from "~/hooks/useKeyDown";
|
import useKeyDown from "~/hooks/useKeyDown";
|
||||||
import usePersistedState from "~/hooks/usePersistedState";
|
import usePersistedState from "~/hooks/usePersistedState";
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
|
import useQuery from "~/hooks/useQuery";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
import { bigPulse } from "~/styles/animations";
|
||||||
import CommentForm from "./CommentForm";
|
import CommentForm from "./CommentForm";
|
||||||
import CommentThread from "./CommentThread";
|
import CommentThread from "./CommentThread";
|
||||||
import Sidebar from "./SidebarLayout";
|
import Sidebar from "./SidebarLayout";
|
||||||
@@ -22,7 +28,11 @@ function Comments() {
|
|||||||
const { ui, comments, documents } = useStores();
|
const { ui, comments, documents } = useStores();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
|
const location = useLocation();
|
||||||
|
const history = useHistory();
|
||||||
const match = useRouteMatch<{ documentSlug: string }>();
|
const match = useRouteMatch<{ documentSlug: string }>();
|
||||||
|
const params = useQuery();
|
||||||
|
const [pulse, setPulse] = React.useState(false);
|
||||||
const document = documents.getByUrl(match.params.documentSlug);
|
const document = documents.getByUrl(match.params.documentSlug);
|
||||||
const focusedComment = useFocusedComment();
|
const focusedComment = useFocusedComment();
|
||||||
const can = usePolicy(document);
|
const can = usePolicy(document);
|
||||||
@@ -34,18 +44,75 @@ function Comments() {
|
|||||||
undefined
|
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) {
|
if (!document) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const threads = comments
|
const threads = (
|
||||||
.threadsInDocument(document.id)
|
viewingResolved
|
||||||
.filter((thread) => !thread.isNew || thread.createdById === user.id);
|
? resolvedThreads
|
||||||
|
: comments.unresolvedThreadsInDocument(document.id)
|
||||||
|
).filter((thread) => thread.createdById === user.id);
|
||||||
const hasComments = threads.length > 0;
|
const hasComments = threads.length > 0;
|
||||||
|
|
||||||
|
const toggleViewingResolved = () => {
|
||||||
|
history.push({
|
||||||
|
search: queryString.stringify({
|
||||||
|
...queryString.parse(location.search),
|
||||||
|
resolved: viewingResolved ? undefined : "",
|
||||||
|
}),
|
||||||
|
pathname: location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar
|
<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)}
|
onClose={() => ui.collapseComments(document?.id)}
|
||||||
scrollable={false}
|
scrollable={false}
|
||||||
>
|
>
|
||||||
@@ -68,13 +135,17 @@ function Comments() {
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<NoComments align="center" justify="center" auto>
|
<NoComments align="center" justify="center" auto>
|
||||||
<PositionedEmpty>{t("No comments yet")}</PositionedEmpty>
|
<PositionedEmpty>
|
||||||
|
{viewingResolved
|
||||||
|
? t("No resolved comments")
|
||||||
|
: t("No comments yet")}
|
||||||
|
</PositionedEmpty>
|
||||||
</NoComments>
|
</NoComments>
|
||||||
)}
|
)}
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
</Scrollable>
|
</Scrollable>
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{!focusedComment && can.comment && (
|
{!focusedComment && can.comment && !viewingResolved && (
|
||||||
<NewCommentForm
|
<NewCommentForm
|
||||||
draft={draft}
|
draft={draft}
|
||||||
onSaveDraft={onSaveDraft}
|
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)`
|
const PositionedEmpty = styled(Empty)`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(50vh - 30px);
|
top: calc(50vh - 30px);
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ function DataLoader({ match, children }: Props) {
|
|||||||
// when viewing a public share link
|
// when viewing a public share link
|
||||||
if (can.read && !document.isDeleted) {
|
if (can.read && !document.isDeleted) {
|
||||||
if (team.getPreference(TeamPreference.Commenting)) {
|
if (team.getPreference(TeamPreference.Commenting)) {
|
||||||
void comments.fetchPage({
|
void comments.fetchAll({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
|||||||
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
|
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
|
||||||
|
|
||||||
const insightsPath = documentInsightsPath(document);
|
const insightsPath = documentInsightsPath(document);
|
||||||
const commentsCount = comments.filter({ documentId: document.id }).length;
|
const commentsCount = comments.unresolvedCommentsInDocumentCount(document.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Meta document={document} revision={revision} to={to} replace {...rest}>
|
<Meta document={document} revision={revision} to={to} replace {...rest}>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import useMobile from "~/hooks/useMobile";
|
|||||||
import { draggableOnDesktop } from "~/styles";
|
import { draggableOnDesktop } from "~/styles";
|
||||||
import { fadeIn } from "~/styles/animations";
|
import { fadeIn } from "~/styles/animations";
|
||||||
|
|
||||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
|
||||||
/* The title of the sidebar */
|
/* The title of the sidebar */
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
/* The content of the sidebar */
|
/* 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"),
|
title: t("Formatting"),
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import invariant from "invariant";
|
||||||
import orderBy from "lodash/orderBy";
|
import orderBy from "lodash/orderBy";
|
||||||
import { action, computed } from "mobx";
|
import { action, computed } from "mobx";
|
||||||
import Comment from "~/models/Comment";
|
import Comment from "~/models/Comment";
|
||||||
|
import { client } from "~/utils/ApiClient";
|
||||||
import RootStore from "./RootStore";
|
import RootStore from "./RootStore";
|
||||||
import Store from "./base/Store";
|
import Store from "./base/Store";
|
||||||
|
|
||||||
@@ -29,12 +31,54 @@ export default class CommentsStore extends Store<Comment> {
|
|||||||
threadsInDocument(documentId: string): Comment[] {
|
threadsInDocument(documentId: string): Comment[] {
|
||||||
return this.filter(
|
return this.filter(
|
||||||
(comment: Comment) =>
|
(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
|
* @param commentId ID of the comment to get replies for
|
||||||
* @returns Array of comments
|
* @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
|
@action
|
||||||
setTyping({
|
setTyping({
|
||||||
commentId,
|
commentId,
|
||||||
|
|||||||
@@ -116,6 +116,12 @@ export const pulse = keyframes`
|
|||||||
100% { transform: scale(1); }
|
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
|
* The duration of the sidebar appearing animation in ms
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -158,7 +158,7 @@
|
|||||||
"node-fetch": "2.7.0",
|
"node-fetch": "2.7.0",
|
||||||
"nodemailer": "^6.9.14",
|
"nodemailer": "^6.9.14",
|
||||||
"octokit": "^3.2.1",
|
"octokit": "^3.2.1",
|
||||||
"outline-icons": "^3.4.1",
|
"outline-icons": "^3.5.0",
|
||||||
"oy-vey": "^0.12.1",
|
"oy-vey": "^0.12.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-google-oauth2": "^0.2.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 { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||||
import { CommentValidation } from "@shared/validations";
|
import { CommentValidation } from "@shared/validations";
|
||||||
import { schema } from "@server/editor";
|
import { schema } from "@server/editor";
|
||||||
|
import { ValidationError } from "@server/errors";
|
||||||
import Document from "./Document";
|
import Document from "./Document";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
import ParanoidModel from "./base/ParanoidModel";
|
import ParanoidModel from "./base/ParanoidModel";
|
||||||
@@ -26,6 +27,11 @@ import TextLength from "./validators/TextLength";
|
|||||||
as: "createdBy",
|
as: "createdBy",
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "resolvedBy",
|
||||||
|
paranoid: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
@Table({ tableName: "comments", modelName: "comment" })
|
@Table({ tableName: "comments", modelName: "comment" })
|
||||||
@@ -54,12 +60,15 @@ class Comment extends ParanoidModel<
|
|||||||
@Column(DataType.UUID)
|
@Column(DataType.UUID)
|
||||||
createdById: string;
|
createdById: string;
|
||||||
|
|
||||||
|
@Column(DataType.DATE)
|
||||||
|
resolvedAt: Date | null;
|
||||||
|
|
||||||
@BelongsTo(() => User, "resolvedById")
|
@BelongsTo(() => User, "resolvedById")
|
||||||
resolvedBy: User;
|
resolvedBy: User | null;
|
||||||
|
|
||||||
@ForeignKey(() => User)
|
@ForeignKey(() => User)
|
||||||
@Column(DataType.UUID)
|
@Column(DataType.UUID)
|
||||||
resolvedById: string;
|
resolvedById: string | null;
|
||||||
|
|
||||||
@BelongsTo(() => Document, "documentId")
|
@BelongsTo(() => Document, "documentId")
|
||||||
document: Document;
|
document: Document;
|
||||||
@@ -75,6 +84,51 @@ class Comment extends ParanoidModel<
|
|||||||
@Column(DataType.UUID)
|
@Column(DataType.UUID)
|
||||||
parentCommentId: string;
|
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() {
|
public toPlainText() {
|
||||||
const node = Node.fromJSON(schema, this.data);
|
const node = Node.fromJSON(schema, this.data);
|
||||||
return ProsemirrorHelper.toPlainText(node, schema);
|
return ProsemirrorHelper.toPlainText(node, schema);
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ export default function onerror(app: Koa) {
|
|||||||
requestErrorHandler(err, this);
|
requestErrorHandler(err, this);
|
||||||
|
|
||||||
if (!(err instanceof InternalError)) {
|
if (!(err instanceof InternalError)) {
|
||||||
|
if (env.ENVIRONMENT === "test") {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
err = InternalError();
|
err = InternalError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,22 @@ allow(User, "read", Comment, (actor, comment) =>
|
|||||||
isTeamModel(actor, comment?.createdBy)
|
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) =>
|
allow(User, ["update", "delete"], Comment, (actor, comment) =>
|
||||||
and(
|
and(
|
||||||
isTeamModel(actor, comment?.createdBy),
|
isTeamModel(actor, comment?.createdBy),
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export default function present(comment: Comment) {
|
|||||||
parentCommentId: comment.parentCommentId,
|
parentCommentId: comment.parentCommentId,
|
||||||
createdBy: presentUser(comment.createdBy),
|
createdBy: presentUser(comment.createdBy),
|
||||||
createdById: comment.createdById,
|
createdById: comment.createdById,
|
||||||
|
resolvedAt: comment.resolvedAt,
|
||||||
|
resolvedBy: comment.resolvedBy ? presentUser(comment.resolvedBy) : null,
|
||||||
|
resolvedById: comment.resolvedById,
|
||||||
createdAt: comment.createdAt,
|
createdAt: comment.createdAt,
|
||||||
updatedAt: comment.updatedAt,
|
updatedAt: comment.updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ export default class WebsocketsProcessor {
|
|||||||
|
|
||||||
case "comments.delete": {
|
case "comments.delete": {
|
||||||
const comment = await Comment.findByPk(event.modelId, {
|
const comment = await Comment.findByPk(event.modelId, {
|
||||||
|
paranoid: false,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Document.scope(["withoutState", "withDrafts"]),
|
model: Document.scope(["withoutState", "withDrafts"]),
|
||||||
|
|||||||
@@ -26,3 +26,30 @@ exports[`#comments.list should require authentication 1`] = `
|
|||||||
"status": 401,
|
"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 {
|
import {
|
||||||
buildAdmin,
|
buildAdmin,
|
||||||
buildCollection,
|
buildCollection,
|
||||||
buildComment,
|
buildComment,
|
||||||
buildDocument,
|
buildDocument,
|
||||||
|
buildResolvedComment,
|
||||||
buildTeam,
|
buildTeam,
|
||||||
buildUser,
|
buildUser,
|
||||||
} from "@server/test/factories";
|
} from "@server/test/factories";
|
||||||
@@ -10,6 +12,73 @@ import { getTestServer } from "@server/test/support";
|
|||||||
|
|
||||||
const server = getTestServer();
|
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", () => {
|
describe("#comments.list", () => {
|
||||||
it("should require authentication", async () => {
|
it("should require authentication", async () => {
|
||||||
const res = await server.post("/api/comments.list");
|
const res = await server.post("/api/comments.list");
|
||||||
@@ -29,6 +98,10 @@ describe("#comments.list", () => {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
});
|
});
|
||||||
|
await buildResolvedComment(user, {
|
||||||
|
userId: user.id,
|
||||||
|
documentId: document.id,
|
||||||
|
});
|
||||||
const res = await server.post("/api/comments.list", {
|
const res = await server.post("/api/comments.list", {
|
||||||
body: {
|
body: {
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
@@ -38,13 +111,14 @@ describe("#comments.list", () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.length).toEqual(1);
|
expect(body.data.length).toEqual(2);
|
||||||
expect(body.data[0].id).toEqual(comment.id);
|
expect(body.data[1].id).toEqual(comment.id);
|
||||||
expect(body.policies.length).toEqual(1);
|
expect(body.policies.length).toEqual(2);
|
||||||
expect(body.policies[0].abilities.read).toEqual(true);
|
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 team = await buildTeam();
|
||||||
const user = await buildUser({ teamId: team.id });
|
const user = await buildUser({ teamId: team.id });
|
||||||
const collection = await buildCollection({
|
const collection = await buildCollection({
|
||||||
@@ -75,7 +149,71 @@ describe("#comments.list", () => {
|
|||||||
expect(body.policies[0].abilities.read).toEqual(true);
|
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 team = await buildTeam();
|
||||||
const user = await buildUser({ teamId: team.id });
|
const user = await buildUser({ teamId: team.id });
|
||||||
const collection1 = await buildCollection({
|
const collection1 = await buildCollection({
|
||||||
@@ -310,65 +448,37 @@ describe("#comments.create", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("#comments.info", () => {
|
describe("#comments.update", () => {
|
||||||
it("should require authentication", async () => {
|
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();
|
const body = await res.json();
|
||||||
expect(res.status).toEqual(401);
|
expect(res.status).toEqual(401);
|
||||||
expect(body).toMatchSnapshot();
|
expect(body).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return comment info", async () => {
|
it("should update an existing comment", async () => {
|
||||||
const team = await buildTeam();
|
const team = await buildTeam();
|
||||||
const user = await buildUser({ teamId: team.id });
|
const user = await buildUser({ teamId: team.id });
|
||||||
const user2 = await buildUser({ teamId: team.id });
|
|
||||||
const document = await buildDocument({
|
const document = await buildDocument({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const comment = await buildComment({
|
const comment = await buildComment({
|
||||||
userId: user2.id,
|
userId: user.id,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
});
|
});
|
||||||
const res = await server.post("/api/comments.info", {
|
|
||||||
|
const res = await server.post("/api/comments.update", {
|
||||||
body: {
|
body: {
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
|
data: comment.data,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
|
||||||
expect(res.status).toEqual(200);
|
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.data.data).toEqual(comment.data);
|
||||||
expect(body.policies.length).toEqual(1);
|
expect(body.policies.length).toEqual(1);
|
||||||
expect(body.policies[0].abilities.read).toEqual(true);
|
expect(body.policies[0].abilities.read).toEqual(true);
|
||||||
@@ -376,3 +486,115 @@ describe("#comments.info", () => {
|
|||||||
expect(body.policies[0].abilities.delete).toEqual(true);
|
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 Router from "koa-router";
|
||||||
import { FindOptions, Op } from "sequelize";
|
import { FindOptions, Op, WhereOptions } from "sequelize";
|
||||||
import { TeamPreference } from "@shared/types";
|
import { CommentStatusFilter, TeamPreference } from "@shared/types";
|
||||||
import commentCreator from "@server/commands/commentCreator";
|
import commentCreator from "@server/commands/commentCreator";
|
||||||
import commentDestroyer from "@server/commands/commentDestroyer";
|
import commentDestroyer from "@server/commands/commentDestroyer";
|
||||||
import commentUpdater from "@server/commands/commentUpdater";
|
import commentUpdater from "@server/commands/commentUpdater";
|
||||||
import { ValidationError } from "@server/errors";
|
|
||||||
import auth from "@server/middlewares/authentication";
|
import auth from "@server/middlewares/authentication";
|
||||||
|
import { feature } from "@server/middlewares/feature";
|
||||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||||
import { transaction } from "@server/middlewares/transaction";
|
import { transaction } from "@server/middlewares/transaction";
|
||||||
import validate from "@server/middlewares/validate";
|
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 { authorize } from "@server/policies";
|
||||||
import { presentComment, presentPolicies } from "@server/presenters";
|
import { presentComment, presentPolicies } from "@server/presenters";
|
||||||
import { APIContext } from "@server/types";
|
import { APIContext } from "@server/types";
|
||||||
@@ -24,7 +23,7 @@ router.post(
|
|||||||
"comments.create",
|
"comments.create",
|
||||||
rateLimiter(RateLimiterStrategy.TenPerMinute),
|
rateLimiter(RateLimiterStrategy.TenPerMinute),
|
||||||
auth(),
|
auth(),
|
||||||
checkCommentingEnabled(),
|
feature(TeamPreference.Commenting),
|
||||||
validate(T.CommentsCreateSchema),
|
validate(T.CommentsCreateSchema),
|
||||||
transaction(),
|
transaction(),
|
||||||
async (ctx: APIContext<T.CommentsCreateReq>) => {
|
async (ctx: APIContext<T.CommentsCreateReq>) => {
|
||||||
@@ -58,7 +57,7 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"comments.info",
|
"comments.info",
|
||||||
auth(),
|
auth(),
|
||||||
checkCommentingEnabled(),
|
feature(TeamPreference.Commenting),
|
||||||
validate(T.CommentsInfoSchema),
|
validate(T.CommentsInfoSchema),
|
||||||
async (ctx: APIContext<T.CommentsInfoReq>) => {
|
async (ctx: APIContext<T.CommentsInfoReq>) => {
|
||||||
const { id } = ctx.input.body;
|
const { id } = ctx.input.body;
|
||||||
@@ -67,14 +66,11 @@ router.post(
|
|||||||
const comment = await Comment.findByPk(id, {
|
const comment = await Comment.findByPk(id, {
|
||||||
rejectOnEmpty: true,
|
rejectOnEmpty: true,
|
||||||
});
|
});
|
||||||
|
const document = await Document.findByPk(comment.documentId, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
authorize(user, "read", comment);
|
authorize(user, "read", comment);
|
||||||
|
authorize(user, "read", document);
|
||||||
if (comment.documentId) {
|
|
||||||
const document = await Document.findByPk(comment.documentId, {
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
authorize(user, "read", document);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentComment(comment),
|
data: presentComment(comment),
|
||||||
@@ -87,13 +83,45 @@ router.post(
|
|||||||
"comments.list",
|
"comments.list",
|
||||||
auth(),
|
auth(),
|
||||||
pagination(),
|
pagination(),
|
||||||
checkCommentingEnabled(),
|
feature(TeamPreference.Commenting),
|
||||||
validate(T.CommentsListSchema),
|
validate(T.CommentsListSchema),
|
||||||
async (ctx: APIContext<T.CommentsListReq>) => {
|
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 { 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> = {
|
const params: FindOptions<Comment> = {
|
||||||
|
where,
|
||||||
order: [[sort, direction]],
|
order: [[sort, direction]],
|
||||||
offset: ctx.state.pagination.offset,
|
offset: ctx.state.pagination.offset,
|
||||||
limit: ctx.state.pagination.limit,
|
limit: ctx.state.pagination.limit,
|
||||||
@@ -103,12 +131,7 @@ router.post(
|
|||||||
if (documentId) {
|
if (documentId) {
|
||||||
const document = await Document.findByPk(documentId, { userId: user.id });
|
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||||
authorize(user, "read", document);
|
authorize(user, "read", document);
|
||||||
comments = await Comment.findAll({
|
comments = await Comment.findAll(params);
|
||||||
where: {
|
|
||||||
documentId: document.id,
|
|
||||||
},
|
|
||||||
...params,
|
|
||||||
});
|
|
||||||
} else if (collectionId) {
|
} else if (collectionId) {
|
||||||
const collection = await Collection.findByPk(collectionId);
|
const collection = await Collection.findByPk(collectionId);
|
||||||
authorize(user, "read", collection);
|
authorize(user, "read", collection);
|
||||||
@@ -153,7 +176,7 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"comments.update",
|
"comments.update",
|
||||||
auth(),
|
auth(),
|
||||||
checkCommentingEnabled(),
|
feature(TeamPreference.Commenting),
|
||||||
validate(T.CommentsUpdateSchema),
|
validate(T.CommentsUpdateSchema),
|
||||||
transaction(),
|
transaction(),
|
||||||
async (ctx: APIContext<T.CommentsUpdateReq>) => {
|
async (ctx: APIContext<T.CommentsUpdateReq>) => {
|
||||||
@@ -194,7 +217,7 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"comments.delete",
|
"comments.delete",
|
||||||
auth(),
|
auth(),
|
||||||
checkCommentingEnabled(),
|
feature(TeamPreference.Commenting),
|
||||||
validate(T.CommentsDeleteSchema),
|
validate(T.CommentsDeleteSchema),
|
||||||
transaction(),
|
transaction(),
|
||||||
async (ctx: APIContext<T.CommentsDeleteReq>) => {
|
async (ctx: APIContext<T.CommentsDeleteReq>) => {
|
||||||
@@ -226,19 +249,98 @@ router.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function checkCommentingEnabled() {
|
router.post(
|
||||||
return async function checkCommentingEnabledMiddleware(
|
"comments.resolve",
|
||||||
ctx: APIContext,
|
auth(),
|
||||||
next: Next
|
feature(TeamPreference.Commenting),
|
||||||
) {
|
validate(T.CommentsResolveSchema),
|
||||||
if (!ctx.state.auth.user.team.getPreference(TeamPreference.Commenting)) {
|
transaction(),
|
||||||
throw ValidationError("Commenting is currently disabled");
|
async (ctx: APIContext<T.CommentsResolveReq>) => {
|
||||||
}
|
const { id } = ctx.input.body;
|
||||||
return next();
|
const { user } = ctx.state.auth;
|
||||||
};
|
const { transaction } = ctx.state;
|
||||||
}
|
|
||||||
|
|
||||||
// router.post("comments.resolve", auth(), async (ctx) => {
|
const comment = await Comment.findByPk(id, {
|
||||||
// router.post("comments.unresolve", auth(), async (ctx) => {
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { CommentStatusFilter } from "@shared/types";
|
||||||
import { BaseSchema, ProsemirrorSchema } from "@server/routes/api/schema";
|
import { BaseSchema, ProsemirrorSchema } from "@server/routes/api/schema";
|
||||||
|
|
||||||
const BaseIdSchema = z.object({
|
const BaseIdSchema = z.object({
|
||||||
@@ -57,7 +58,12 @@ export const CommentsListSchema = BaseSchema.extend({
|
|||||||
body: CommentsSortParamsSchema.extend({
|
body: CommentsSortParamsSchema.extend({
|
||||||
/** Id of a document to list comments for */
|
/** Id of a document to list comments for */
|
||||||
documentId: z.string().optional(),
|
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 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: {
|
export async function buildComment(overrides: {
|
||||||
userId: string;
|
userId: string;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
|
parentCommentId?: string;
|
||||||
|
resolvedById?: string;
|
||||||
}) {
|
}) {
|
||||||
const comment = await Comment.create({
|
const comment = await Comment.create({
|
||||||
|
resolvedById: overrides.resolvedById,
|
||||||
|
parentCommentId: overrides.parentCommentId,
|
||||||
documentId: overrides.documentId,
|
documentId: overrides.documentId,
|
||||||
data: {
|
data: {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
@@ -427,6 +431,16 @@ export async function buildComment(overrides: {
|
|||||||
return comment;
|
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(
|
export async function buildFileOperation(
|
||||||
overrides: Partial<FileOperation> = {}
|
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";
|
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?.(
|
dispatch?.(
|
||||||
state.tr.setSelection(
|
state.tr.setSelection(
|
||||||
TextSelection.create(state.doc, state.tr.selection.from)
|
TextSelection.create(state.doc, state.tr.selection.from)
|
||||||
@@ -8,5 +13,3 @@ const collapseSelection = (): Command => (state, dispatch) => {
|
|||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default collapseSelection;
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { chainTransactions } from "../lib/chainTransactions";
|
import { chainTransactions } from "../lib/chainTransactions";
|
||||||
import { getCellsInColumn, isHeaderEnabled } from "../queries/table";
|
import { getCellsInColumn, isHeaderEnabled } from "../queries/table";
|
||||||
import { TableLayout } from "../types";
|
import { TableLayout } from "../types";
|
||||||
import collapseSelection from "./collapseSelection";
|
import { collapseSelection } from "./collapseSelection";
|
||||||
|
|
||||||
export function createTable({
|
export function createTable({
|
||||||
rowsCount,
|
rowsCount,
|
||||||
|
|||||||
@@ -857,14 +857,16 @@ h6 {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-marker {
|
.${EditorStyleHelper.comment} {
|
||||||
border-bottom: 2px solid ${props.theme.commentMarkBackground};
|
&:not([data-resolved]) {
|
||||||
transition: background 100ms ease-in-out;
|
border-bottom: 2px solid ${props.theme.commentMarkBackground};
|
||||||
border-radius: 2px;
|
transition: background 100ms ease-in-out;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
${props.readOnly ? "cursor: var(--pointer);" : ""}
|
${props.readOnly ? "cursor: var(--pointer);" : ""}
|
||||||
background: ${props.theme.commentMarkBackground};
|
background: ${props.theme.commentMarkBackground};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1768,7 +1770,7 @@ del[data-operation-index] {
|
|||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-marker {
|
.${EditorStyleHelper.comment} {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { toggleMark } from "prosemirror-commands";
|
|||||||
import { MarkSpec, MarkType, Schema, Mark as PMMark } from "prosemirror-model";
|
import { MarkSpec, MarkType, Schema, Mark as PMMark } from "prosemirror-model";
|
||||||
import { Command, Plugin } from "prosemirror-state";
|
import { Command, Plugin } from "prosemirror-state";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
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 { chainTransactions } from "../lib/chainTransactions";
|
||||||
import { isMarkActive } from "../queries/isMarkActive";
|
import { isMarkActive } from "../queries/isMarkActive";
|
||||||
|
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||||
import Mark from "./Mark";
|
import Mark from "./Mark";
|
||||||
|
|
||||||
export default class Comment extends Mark {
|
export default class Comment extends Mark {
|
||||||
@@ -17,11 +19,14 @@ export default class Comment extends Mark {
|
|||||||
attrs: {
|
attrs: {
|
||||||
id: {},
|
id: {},
|
||||||
userId: {},
|
userId: {},
|
||||||
|
resolved: {
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
inclusive: false,
|
inclusive: false,
|
||||||
parseDOM: [
|
parseDOM: [
|
||||||
{
|
{
|
||||||
tag: "span.comment-marker",
|
tag: `.${EditorStyleHelper.comment}`,
|
||||||
getAttrs: (dom: HTMLSpanElement) => {
|
getAttrs: (dom: HTMLSpanElement) => {
|
||||||
// Ignore comment markers from other documents
|
// Ignore comment markers from other documents
|
||||||
const documentId = dom.getAttribute("data-document-id");
|
const documentId = dom.getAttribute("data-document-id");
|
||||||
@@ -32,6 +37,7 @@ export default class Comment extends Mark {
|
|||||||
return {
|
return {
|
||||||
id: dom.getAttribute("id")?.replace("comment-", ""),
|
id: dom.getAttribute("id")?.replace("comment-", ""),
|
||||||
userId: dom.getAttribute("data-user-id"),
|
userId: dom.getAttribute("data-user-id"),
|
||||||
|
resolved: !!dom.getAttribute("data-resolved"),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -39,8 +45,9 @@ export default class Comment extends Mark {
|
|||||||
toDOM: (node) => [
|
toDOM: (node) => [
|
||||||
"span",
|
"span",
|
||||||
{
|
{
|
||||||
class: "comment-marker",
|
class: EditorStyleHelper.comment,
|
||||||
id: `comment-${node.attrs.id}`,
|
id: `comment-${node.attrs.id}`,
|
||||||
|
"data-resolved": node.attrs.resolved ? "true" : undefined,
|
||||||
"data-user-id": node.attrs.userId,
|
"data-user-id": node.attrs.userId,
|
||||||
"data-document-id": this.editor?.props.id,
|
"data-document-id": this.editor?.props.id,
|
||||||
},
|
},
|
||||||
@@ -56,7 +63,11 @@ export default class Comment extends Mark {
|
|||||||
return this.options.onCreateCommentMark
|
return this.options.onCreateCommentMark
|
||||||
? {
|
? {
|
||||||
"Mod-Alt-m": (state, dispatch) => {
|
"Mod-Alt-m": (state, dispatch) => {
|
||||||
if (isMarkActive(state.schema.marks.comment)(state)) {
|
if (
|
||||||
|
isMarkActive(state.schema.marks.comment, { resolved: false })(
|
||||||
|
state
|
||||||
|
)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,12 +88,14 @@ export default class Comment extends Mark {
|
|||||||
commands({ type }: { type: MarkType; schema: Schema }) {
|
commands({ type }: { type: MarkType; schema: Schema }) {
|
||||||
return this.options.onCreateCommentMark
|
return this.options.onCreateCommentMark
|
||||||
? (): Command => (state, dispatch) => {
|
? (): Command => (state, dispatch) => {
|
||||||
if (isMarkActive(state.schema.marks.comment)(state)) {
|
if (
|
||||||
|
isMarkActive(state.schema.marks.comment, { resolved: false })(state)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
chainTransactions(
|
chainTransactions(
|
||||||
toggleMark(type, {
|
addMark(type, {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
userId: this.options.userId,
|
userId: this.options.userId,
|
||||||
}),
|
}),
|
||||||
@@ -152,13 +165,16 @@ export default class Comment extends Mark {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const comment = event.target.closest(".comment-marker");
|
const comment = event.target.closest(
|
||||||
|
`.${EditorStyleHelper.comment}`
|
||||||
|
);
|
||||||
if (!comment) {
|
if (!comment) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const commentId = comment.id.replace("comment-", "");
|
const commentId = comment.id.replace("comment-", "");
|
||||||
if (commentId) {
|
const resolved = comment.getAttribute("data-resolved");
|
||||||
|
if (commentId && !resolved) {
|
||||||
this.options?.onClickCommentMark?.(commentId);
|
this.options?.onClickCommentMark?.(commentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
* Class names and values used by the editor.
|
* Class names and values used by the editor.
|
||||||
*/
|
*/
|
||||||
export class EditorStyleHelper {
|
export class EditorStyleHelper {
|
||||||
|
// Comments
|
||||||
|
|
||||||
|
static readonly comment = "comment-marker";
|
||||||
|
|
||||||
// Tables
|
// Tables
|
||||||
|
|
||||||
/** Table wrapper */
|
/** Table wrapper */
|
||||||
@@ -34,6 +38,8 @@ export class EditorStyleHelper {
|
|||||||
/** Shadow on the left side of the table */
|
/** Shadow on the left side of the table */
|
||||||
static readonly tableShadowLeft = "table-shadow-left";
|
static readonly tableShadowLeft = "table-shadow-left";
|
||||||
|
|
||||||
|
// Global
|
||||||
|
|
||||||
/** Minimum padding around editor */
|
/** Minimum padding around editor */
|
||||||
static readonly padding = 32;
|
static readonly padding = 32;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
"Delete": "Delete",
|
"Delete": "Delete",
|
||||||
"Delete collection": "Delete collection",
|
"Delete collection": "Delete collection",
|
||||||
"New template": "New template",
|
"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",
|
"Copy ID": "Copy ID",
|
||||||
"Clear IndexedDB cache": "Clear IndexedDB cache",
|
"Clear IndexedDB cache": "Clear IndexedDB cache",
|
||||||
"IndexedDB cache cleared": "IndexedDB cache cleared",
|
"IndexedDB cache cleared": "IndexedDB cache cleared",
|
||||||
@@ -469,7 +473,6 @@
|
|||||||
"Sort in sidebar": "Sort in sidebar",
|
"Sort in sidebar": "Sort in sidebar",
|
||||||
"Alphabetical sort": "Alphabetical sort",
|
"Alphabetical sort": "Alphabetical sort",
|
||||||
"Manual sort": "Manual sort",
|
"Manual sort": "Manual sort",
|
||||||
"Delete comment": "Delete comment",
|
|
||||||
"Comment options": "Comment options",
|
"Comment options": "Comment options",
|
||||||
"Document restored": "Document restored",
|
"Document restored": "Document restored",
|
||||||
"Document options": "Document options",
|
"Document options": "Document options",
|
||||||
@@ -551,6 +554,10 @@
|
|||||||
"Post": "Post",
|
"Post": "Post",
|
||||||
"Cancel": "Cancel",
|
"Cancel": "Cancel",
|
||||||
"Upload image": "Upload image",
|
"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",
|
"No comments yet": "No comments yet",
|
||||||
"Error updating comment": "Error updating comment",
|
"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?",
|
"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",
|
"Publish document and exit": "Publish document and exit",
|
||||||
"Save document": "Save document",
|
"Save document": "Save document",
|
||||||
"Cancel editing": "Cancel editing",
|
"Cancel editing": "Cancel editing",
|
||||||
|
"Collaboration": "Collaboration",
|
||||||
"Formatting": "Formatting",
|
"Formatting": "Formatting",
|
||||||
"Paragraph": "Paragraph",
|
"Paragraph": "Paragraph",
|
||||||
"Large header": "Large header",
|
"Large header": "Large header",
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export enum StatusFilter {
|
|||||||
Draft = "draft",
|
Draft = "draft",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum CommentStatusFilter {
|
||||||
|
Resolved = "resolved",
|
||||||
|
Unresolved = "unresolved",
|
||||||
|
}
|
||||||
|
|
||||||
export enum Client {
|
export enum Client {
|
||||||
Web = "web",
|
Web = "web",
|
||||||
Desktop = "desktop",
|
Desktop = "desktop",
|
||||||
|
|||||||
126
yarn.lock
126
yarn.lock
@@ -8173,54 +8173,7 @@ error-ex@^1.3.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-arrayish "^0.2.1"
|
is-arrayish "^0.2.1"
|
||||||
|
|
||||||
es-abstract@^1.22.1, es-abstract@^1.22.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.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:
|
|
||||||
version "1.23.3"
|
version "1.23.3"
|
||||||
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0"
|
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0"
|
||||||
integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==
|
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"
|
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
|
||||||
integrity "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="
|
integrity "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="
|
||||||
|
|
||||||
outline-icons@^3.4.1:
|
outline-icons@^3.5.0:
|
||||||
version "3.4.1"
|
version "3.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.4.1.tgz#2a7c17f7d2b132359a6cc00f449371fa0adb3450"
|
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.5.0.tgz#acc3896a3f0eae2ab70fe693d1d1a924cced6e0f"
|
||||||
integrity sha512-H6FRWVLNammxqNpA1n5ktN4T6eAhuLyTI6A8d0mukkz7y/CDCWiffcLetlWhZf9m/jv/EU8ZCOwVSY3CmVeU6Q==
|
integrity sha512-zZAbnR6gjXI4KLEmVj3EsdrlVG3YXBmZ1clY5O1zI5LfaLXQvUAThV/z5MxZpMwcNVYOZMRyXv/W1Sy0TNwCsA==
|
||||||
|
|
||||||
oy-vey@^0.12.1:
|
oy-vey@^0.12.1:
|
||||||
version "0.12.1"
|
version "0.12.1"
|
||||||
@@ -14128,16 +14081,7 @@ set-function-length@^1.2.1:
|
|||||||
gopd "^1.0.1"
|
gopd "^1.0.1"
|
||||||
has-property-descriptors "^1.0.2"
|
has-property-descriptors "^1.0.2"
|
||||||
|
|
||||||
set-function-name@^2.0.1:
|
set-function-name@^2.0.1, set-function-name@^2.0.2:
|
||||||
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:
|
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985"
|
resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985"
|
||||||
integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==
|
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"
|
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680"
|
||||||
integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==
|
integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==
|
||||||
|
|
||||||
side-channel@^1.0.4:
|
side-channel@^1.0.4, side-channel@^1.0.6:
|
||||||
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:
|
|
||||||
version "1.0.6"
|
version "1.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2"
|
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2"
|
||||||
integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==
|
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"
|
set-function-name "^2.0.2"
|
||||||
side-channel "^1.0.6"
|
side-channel "^1.0.6"
|
||||||
|
|
||||||
string.prototype.trim@^1.2.8:
|
string.prototype.trim@^1.2.8, string.prototype.trim@^1.2.9:
|
||||||
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:
|
|
||||||
version "1.2.9"
|
version "1.2.9"
|
||||||
resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4"
|
resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4"
|
||||||
integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==
|
integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==
|
||||||
@@ -14568,16 +14494,7 @@ string.prototype.trim@^1.2.9:
|
|||||||
es-abstract "^1.23.0"
|
es-abstract "^1.23.0"
|
||||||
es-object-atoms "^1.0.0"
|
es-object-atoms "^1.0.0"
|
||||||
|
|
||||||
string.prototype.trimend@^1.0.7:
|
string.prototype.trimend@^1.0.7, string.prototype.trimend@^1.0.8:
|
||||||
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:
|
|
||||||
version "1.0.8"
|
version "1.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz#3651b8513719e8a9f48de7f2f77640b26652b229"
|
resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz#3651b8513719e8a9f48de7f2f77640b26652b229"
|
||||||
integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==
|
integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==
|
||||||
@@ -14586,16 +14503,7 @@ string.prototype.trimend@^1.0.8:
|
|||||||
define-properties "^1.2.1"
|
define-properties "^1.2.1"
|
||||||
es-object-atoms "^1.0.0"
|
es-object-atoms "^1.0.0"
|
||||||
|
|
||||||
string.prototype.trimstart@^1.0.7:
|
string.prototype.trimstart@^1.0.7, string.prototype.trimstart@^1.0.8:
|
||||||
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:
|
|
||||||
version "1.0.8"
|
version "1.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde"
|
resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde"
|
||||||
integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==
|
integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==
|
||||||
@@ -15190,19 +15098,7 @@ typed-array-byte-offset@^1.0.2:
|
|||||||
has-proto "^1.0.3"
|
has-proto "^1.0.3"
|
||||||
is-typed-array "^1.1.13"
|
is-typed-array "^1.1.13"
|
||||||
|
|
||||||
typed-array-length@^1.0.5:
|
typed-array-length@^1.0.5, typed-array-length@^1.0.6:
|
||||||
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:
|
|
||||||
version "1.0.6"
|
version "1.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3"
|
resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3"
|
||||||
integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==
|
integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==
|
||||||
|
|||||||
Reference in New Issue
Block a user