Skip to content

Commit 117c4f5

Browse files
authored
feat: Comment resolving (outline#7115)
1 parent f345573 commit 117c4f5

38 files changed

+1106
-271
lines changed

.eslintrc

+2-1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@typescript-eslint/no-shadow": [
4242
"warn",
4343
{
44+
"allow": ["transaction"],
4445
"hoist": "all",
4546
"ignoreTypeValueShadow": true
4647
}
@@ -139,4 +140,4 @@
139140
"typescript": {}
140141
}
141142
}
142-
}
143+
}

app/actions/definitions/comments.tsx

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { DoneIcon, TrashIcon } from "outline-icons";
2+
import * as React from "react";
3+
import { toast } from "sonner";
4+
import stores from "~/stores";
5+
import Comment from "~/models/Comment";
6+
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
7+
import history from "~/utils/history";
8+
import { createAction } from "..";
9+
import { DocumentSection } from "../sections";
10+
11+
export const deleteCommentFactory = ({
12+
comment,
13+
onDelete,
14+
}: {
15+
comment: Comment;
16+
onDelete: () => void;
17+
}) =>
18+
createAction({
19+
name: ({ t }) => `${t("Delete")}…`,
20+
analyticsName: "Delete comment",
21+
section: DocumentSection,
22+
icon: <TrashIcon />,
23+
keywords: "trash",
24+
dangerous: true,
25+
visible: () => stores.policies.abilities(comment.id).delete,
26+
perform: ({ t, event }) => {
27+
event?.preventDefault();
28+
event?.stopPropagation();
29+
30+
stores.dialogs.openModal({
31+
title: t("Delete comment"),
32+
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
33+
});
34+
},
35+
});
36+
37+
export const resolveCommentFactory = ({
38+
comment,
39+
onResolve,
40+
}: {
41+
comment: Comment;
42+
onResolve: () => void;
43+
}) =>
44+
createAction({
45+
name: ({ t }) => t("Mark as resolved"),
46+
analyticsName: "Resolve thread",
47+
section: DocumentSection,
48+
icon: <DoneIcon outline />,
49+
visible: () => stores.policies.abilities(comment.id).resolve,
50+
perform: async ({ t }) => {
51+
await comment.resolve();
52+
53+
history.replace({
54+
...history.location,
55+
state: null,
56+
});
57+
58+
onResolve();
59+
toast.success(t("Thread resolved"));
60+
},
61+
});
62+
63+
export const unresolveCommentFactory = ({
64+
comment,
65+
onUnresolve,
66+
}: {
67+
comment: Comment;
68+
onUnresolve: () => void;
69+
}) =>
70+
createAction({
71+
name: ({ t }) => t("Mark as unresolved"),
72+
analyticsName: "Unresolve thread",
73+
section: DocumentSection,
74+
icon: <DoneIcon outline />,
75+
visible: () => stores.policies.abilities(comment.id).unresolve,
76+
perform: async () => {
77+
await comment.unresolve();
78+
79+
history.replace({
80+
...history.location,
81+
state: null,
82+
});
83+
84+
onUnresolve();
85+
},
86+
});

app/components/ContextMenu/Template.tsx

+13-6
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type Props = Omit<MenuStateReturn, "items"> & {
3030
actions?: (Action | MenuSeparator | MenuHeading)[];
3131
context?: Partial<ActionContext>;
3232
items?: TMenuItem[];
33+
showIcons?: boolean;
3334
};
3435

3536
const Disclosure = styled(ExpandedIcon)`
@@ -98,7 +99,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
9899
});
99100
}
100101

101-
function Template({ items, actions, context, ...menu }: Props) {
102+
function Template({ items, actions, context, showIcons, ...menu }: Props) {
102103
const ctx = useActionContext({
103104
isContextMenu: true,
104105
});
@@ -124,7 +125,8 @@ function Template({ items, actions, context, ...menu }: Props) {
124125
if (
125126
iconIsPresentInAnyMenuItem &&
126127
item.type !== "separator" &&
127-
item.type !== "heading"
128+
item.type !== "heading" &&
129+
showIcons !== false
128130
) {
129131
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
130132
}
@@ -138,7 +140,7 @@ function Template({ items, actions, context, ...menu }: Props) {
138140
key={index}
139141
disabled={item.disabled}
140142
selected={item.selected}
141-
icon={item.icon}
143+
icon={showIcons !== false ? item.icon : undefined}
142144
{...menu}
143145
>
144146
{item.title}
@@ -156,7 +158,7 @@ function Template({ items, actions, context, ...menu }: Props) {
156158
selected={item.selected}
157159
level={item.level}
158160
target={item.href.startsWith("#") ? undefined : "_blank"}
159-
icon={item.icon}
161+
icon={showIcons !== false ? item.icon : undefined}
160162
{...menu}
161163
>
162164
{item.title}
@@ -174,7 +176,7 @@ function Template({ items, actions, context, ...menu }: Props) {
174176
selected={item.selected}
175177
dangerous={item.dangerous}
176178
key={index}
177-
icon={item.icon}
179+
icon={showIcons !== false ? item.icon : undefined}
178180
{...menu}
179181
>
180182
{item.title}
@@ -190,7 +192,12 @@ function Template({ items, actions, context, ...menu }: Props) {
190192
id={`${item.title}-${index}`}
191193
templateItems={item.items}
192194
parentMenuState={menu}
193-
title={<Title title={item.title} icon={item.icon} />}
195+
title={
196+
<Title
197+
title={item.title}
198+
icon={showIcons !== false ? item.icon : undefined}
199+
/>
200+
}
194201
{...menu}
195202
/>
196203
);

app/editor/index.tsx

+37-7
Original file line numberDiff line numberDiff line change
@@ -640,27 +640,56 @@ export class Editor extends React.PureComponent<
640640
public getComments = () => ProsemirrorHelper.getComments(this.view.state.doc);
641641

642642
/**
643-
* Remove a specific comment mark from the document.
643+
* Remove all marks related to a specific comment from the document.
644644
*
645645
* @param commentId The id of the comment to remove
646646
*/
647647
public removeComment = (commentId: string) => {
648648
const { state, dispatch } = this.view;
649-
let found = false;
649+
650650
state.doc.descendants((node, pos) => {
651-
if (!node.isInline || found) {
651+
if (!node.isInline) {
652652
return;
653653
}
654654

655655
const mark = node.marks.find(
656-
(mark) =>
657-
mark.type === state.schema.marks.comment &&
658-
mark.attrs.id === commentId
656+
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
659657
);
660658

661659
if (mark) {
662660
dispatch(state.tr.removeMark(pos, pos + node.nodeSize, mark));
663-
found = true;
661+
}
662+
});
663+
};
664+
665+
/**
666+
* Update all marks related to a specific comment in the document.
667+
*
668+
* @param commentId The id of the comment to remove
669+
* @param attrs The attributes to update
670+
*/
671+
public updateComment = (commentId: string, attrs: { resolved: boolean }) => {
672+
const { state, dispatch } = this.view;
673+
674+
state.doc.descendants((node, pos) => {
675+
if (!node.isInline) {
676+
return;
677+
}
678+
679+
const mark = node.marks.find(
680+
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
681+
);
682+
683+
if (mark) {
684+
const from = pos;
685+
const to = pos + node.nodeSize;
686+
const newMark = state.schema.marks.comment.create({
687+
...mark.attrs,
688+
...attrs,
689+
});
690+
dispatch(
691+
state.tr.removeMark(from, to, mark).addMark(from, to, newMark)
692+
);
664693
}
665694
});
666695
};
@@ -808,6 +837,7 @@ const EditorContainer = styled(Styles)<{
808837
css`
809838
#comment-${props.focusedCommentId} {
810839
background: ${transparentize(0.5, props.theme.brand.marine)};
840+
border-bottom: 2px solid ${props.theme.commentMarkBackground};
811841
}
812842
`}
813843

app/editor/menus/formatting.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ export default function formattingMenuItems(
209209
tooltip: dictionary.comment,
210210
icon: <CommentIcon />,
211211
label: isCodeBlock ? dictionary.comment : undefined,
212-
active: isMarkActive(schema.marks.comment),
212+
active: isMarkActive(schema.marks.comment, { resolved: false }),
213213
visible: !isMobile || !isEmpty,
214214
},
215215
{

app/menus/CommentMenu.tsx

+59-29
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
import copy from "copy-to-clipboard";
22
import { observer } from "mobx-react";
3+
import { CopyIcon, EditIcon } from "outline-icons";
34
import * as React from "react";
45
import { useTranslation } from "react-i18next";
56
import { useMenuState } from "reakit/Menu";
67
import { toast } from "sonner";
78
import EventBoundary from "@shared/components/EventBoundary";
89
import Comment from "~/models/Comment";
9-
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
1010
import ContextMenu from "~/components/ContextMenu";
11-
import MenuItem from "~/components/ContextMenu/MenuItem";
1211
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
13-
import Separator from "~/components/ContextMenu/Separator";
12+
import Template from "~/components/ContextMenu/Template";
13+
import { actionToMenuItem } from "~/actions";
14+
import {
15+
deleteCommentFactory,
16+
resolveCommentFactory,
17+
unresolveCommentFactory,
18+
} from "~/actions/definitions/comments";
19+
import useActionContext from "~/hooks/useActionContext";
1420
import usePolicy from "~/hooks/usePolicy";
1521
import useStores from "~/hooks/useStores";
1622
import { commentPath, urlify } from "~/utils/routeHelpers";
@@ -24,24 +30,26 @@ type Props = {
2430
onEdit: () => void;
2531
/** Callback when the comment has been deleted */
2632
onDelete: () => void;
33+
/** Callback when the comment has been updated */
34+
onUpdate: (attrs: { resolved: boolean }) => void;
2735
};
2836

29-
function CommentMenu({ comment, onEdit, onDelete, className }: Props) {
37+
function CommentMenu({
38+
comment,
39+
onEdit,
40+
onDelete,
41+
onUpdate,
42+
className,
43+
}: Props) {
3044
const menu = useMenuState({
3145
modal: true,
3246
});
33-
const { documents, dialogs } = useStores();
47+
const { documents } = useStores();
3448
const { t } = useTranslation();
3549
const can = usePolicy(comment);
50+
const context = useActionContext({ isContextMenu: true });
3651
const document = documents.get(comment.documentId);
3752

38-
const handleDelete = React.useCallback(() => {
39-
dialogs.openModal({
40-
title: t("Delete comment"),
41-
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
42-
});
43-
}, [dialogs, comment, onDelete, t]);
44-
4553
const handleCopyLink = React.useCallback(() => {
4654
if (document) {
4755
copy(urlify(commentPath(document, comment)));
@@ -58,24 +66,46 @@ function CommentMenu({ comment, onEdit, onDelete, className }: Props) {
5866
{...menu}
5967
/>
6068
</EventBoundary>
61-
6269
<ContextMenu {...menu} aria-label={t("Comment options")}>
63-
{can.update && (
64-
<MenuItem {...menu} onClick={onEdit}>
65-
{t("Edit")}
66-
</MenuItem>
67-
)}
68-
<MenuItem {...menu} onClick={handleCopyLink}>
69-
{t("Copy link")}
70-
</MenuItem>
71-
{can.delete && (
72-
<>
73-
<Separator />
74-
<MenuItem {...menu} onClick={handleDelete} dangerous>
75-
{t("Delete")}
76-
</MenuItem>
77-
</>
78-
)}
70+
<Template
71+
{...menu}
72+
items={[
73+
{
74+
type: "button",
75+
title: `${t("Edit")}…`,
76+
icon: <EditIcon />,
77+
onClick: onEdit,
78+
visible: can.update,
79+
},
80+
actionToMenuItem(
81+
resolveCommentFactory({
82+
comment,
83+
onResolve: () => onUpdate({ resolved: true }),
84+
}),
85+
context
86+
),
87+
actionToMenuItem(
88+
unresolveCommentFactory({
89+
comment,
90+
onUnresolve: () => onUpdate({ resolved: false }),
91+
}),
92+
context
93+
),
94+
{
95+
type: "button",
96+
icon: <CopyIcon />,
97+
title: t("Copy link"),
98+
onClick: handleCopyLink,
99+
},
100+
{
101+
type: "separator",
102+
},
103+
actionToMenuItem(
104+
deleteCommentFactory({ comment, onDelete }),
105+
context
106+
),
107+
]}
108+
/>
79109
</ContextMenu>
80110
</>
81111
);

0 commit comments

Comments
 (0)