feat: allow sort by position for comments (#7770)

* feat: allow sort by position for comments

* wait for prosemirror nodes to load

* Move to menu

* remove sort; rename enum

* asc sort for in-thread display

* revert sort

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Hemachandar
2024-10-23 08:48:33 +05:30
committed by GitHub
parent 0d7ce76c21
commit 57e9abd77f
11 changed files with 199 additions and 82 deletions
+8
View File
@@ -11,6 +11,9 @@ class DocumentContext {
/** The editor instance for this document */
editor?: Editor;
@observable
isEditorInitialized: boolean = false;
@observable
headings: Heading[] = [];
@@ -31,6 +34,11 @@ class DocumentContext {
this.updateState();
};
@action
setEditorInitialized = (initialized: boolean) => {
this.isEditorInitialized = initialized;
};
@action
updateState = () => {
this.updateHeadings();
+3 -3
View File
@@ -10,7 +10,7 @@ import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import Button, { Inner } from "~/components/Button";
import Button, { Props as ButtonProps, Inner } from "~/components/Button";
import Text from "~/components/Text";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
@@ -33,7 +33,7 @@ export type Option = {
divider?: boolean;
};
export type Props = {
export type Props = Omit<ButtonProps<any>, "onChange"> & {
id?: string;
name?: string;
value?: string | null;
@@ -313,7 +313,7 @@ const StyledButton = styled(Button)<{ $nude?: boolean }>`
margin-bottom: 16px;
display: block;
width: 100%;
cursor: default;
cursor: var(--pointer);
&:hover:not(:disabled) {
background: ${s("buttonNeutralBackground")};
+25
View File
@@ -91,6 +91,10 @@ export type Props = {
scrollTo?: string;
/** Callback for handling uploaded images, should return the url of uploaded file */
uploadFile?: (file: File) => Promise<string>;
/** Callback when prosemirror nodes are initialized on document mount. */
onInit?: () => void;
/** Callback when prosemirror nodes are destroyed on document unmount. */
onDestroy?: () => void;
/** Callback when editor is blurred, as native input */
onBlur?: () => void;
/** Callback when editor is focused, as native input */
@@ -176,6 +180,7 @@ export class Editor extends React.PureComponent<
linkToolbarOpen: false,
};
isInitialized = false;
isBlurred = true;
extensions: ExtensionManager;
elementRef = React.createRef<HTMLDivElement>();
@@ -283,6 +288,7 @@ export class Editor extends React.PureComponent<
window.removeEventListener("theme-changed", this.dispatchThemeChanged);
this.view?.destroy();
this.mutationObserver?.disconnect();
this.handleEditorDestroy();
}
private init() {
@@ -480,6 +486,9 @@ export class Editor extends React.PureComponent<
(self.props.canComment && transactions.some(isEditingComment)))
) {
self.handleChange();
// Wait for the first transaction to initialize the nodes.
// This is bound to happen always - New / empty document has a "paragraph" node in it.
self.handleEditorInit();
}
self.calculateDir();
@@ -740,6 +749,22 @@ export class Editor extends React.PureComponent<
);
};
private handleEditorInit = () => {
if (!this.props.onInit || this.isInitialized) {
return;
}
this.props.onInit();
this.isInitialized = true;
};
private handleEditorDestroy = () => {
if (!this.props.onDestroy) {
return;
}
this.props.onDestroy();
};
private handleEditorBlur = () => {
this.setState({ isEditorFocused: false });
return false;
@@ -0,0 +1,86 @@
import queryString from "query-string";
import React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import InputSelect from "~/components/InputSelect";
import useCurrentUser from "~/hooks/useCurrentUser";
import useQuery from "~/hooks/useQuery";
import { CommentSortType } from "~/types";
const CommentSortMenu = () => {
const { t } = useTranslation();
const location = useLocation();
const history = useHistory();
const user = useCurrentUser();
const params = useQuery();
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved
? "resolved"
: user.getPreference(UserPreference.SortCommentsByOrderInDocument)
? CommentSortType.OrderInDocument
: CommentSortType.MostRecent;
const handleSortTypeChange = (type: CommentSortType) => {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
type === CommentSortType.OrderInDocument
);
void user.save();
};
const showResolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
});
};
const showUnresolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
});
};
return (
<Select
style={{ margin: 0 }}
ariaLabel={t("Sort comments")}
value={value}
onChange={(ev) => {
if (ev === "resolved") {
showResolved();
} else {
handleSortTypeChange(ev as CommentSortType);
showUnresolved();
}
}}
borderOnHover
options={[
{ value: CommentSortType.MostRecent, label: t("Most recent") },
{ value: CommentSortType.OrderInDocument, label: t("Order in doc") },
{
divider: true,
value: "resolved",
label: t("Resolved"),
},
]}
/>
);
};
const Select = styled(InputSelect)`
color: ${s("textSecondary")};
`;
export default CommentSortMenu;
+23 -69
View File
@@ -1,36 +1,34 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import queryString from "query-string";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import styled, { css } from "styled-components";
import { ProsemirrorData } from "@shared/types";
import Button from "~/components/Button";
import { useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { ProsemirrorData, UserPreference } from "@shared/types";
import { useDocumentContext } from "~/components/DocumentContext";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import useKeyDown from "~/hooks/useKeyDown";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { bigPulse } from "~/styles/animations";
import { CommentSortOption, CommentSortType } from "~/types";
import CommentForm from "./CommentForm";
import CommentSortMenu from "./CommentSortMenu";
import CommentThread from "./CommentThread";
import Sidebar from "./SidebarLayout";
function Comments() {
const { ui, comments, documents } = useStores();
const user = useCurrentUser();
const { editor, isEditorInitialized } = useDocumentContext();
const { t } = useTranslation();
const location = useLocation();
const history = useHistory();
const match = useRouteMatch<{ documentSlug: string }>();
const params = useQuery();
const [pulse, setPulse] = React.useState(false);
const document = documents.getByUrl(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
@@ -42,71 +40,35 @@ function Comments() {
undefined
);
const sortOption: CommentSortOption = user.getPreference(
UserPreference.SortCommentsByOrderInDocument
)
? {
type: CommentSortType.OrderInDocument,
referencedCommentIds: editor?.getComments().map((c) => c.id) ?? [],
}
: { type: CommentSortType.MostRecent };
const viewingResolved = params.get("resolved") === "";
const resolvedThreads = document
? comments.resolvedThreadsInDocument(document.id)
? comments.resolvedThreadsInDocument(document.id, sortOption)
: [];
const resolvedThreadsCount = resolvedThreads.length;
React.useEffect(() => {
setPulse(true);
const timeout = setTimeout(() => setPulse(false), 250);
return () => {
clearTimeout(timeout);
setPulse(false);
};
}, [resolvedThreadsCount]);
if (!document) {
if (!document || !isEditorInitialized) {
return null;
}
const threads = viewingResolved
? resolvedThreads
: comments.unresolvedThreadsInDocument(document.id);
: comments.unresolvedThreadsInDocument(document.id, sortOption);
const hasComments = threads.length > 0;
const toggleViewingResolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: viewingResolved ? undefined : "",
}),
pathname: location.pathname,
});
};
return (
<Sidebar
title={
<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>
)}
<span>{t("Comments")}</span>
<CommentSortMenu />
</Flex>
}
onClose={() => ui.collapseComments(document?.id)}
@@ -158,14 +120,6 @@ function Comments() {
);
}
const ResolvedButton = styled(Button)<{ $pulse: boolean }>`
${(props) =>
props.$pulse &&
css`
animation: ${bigPulse} 250ms 1;
`}
`;
const PositionedEmpty = styled(Empty)`
position: absolute;
top: calc(50vh - 30px);
+7 -1
View File
@@ -175,7 +175,11 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
[comments]
);
const { setEditor, updateState: updateDocState } = useDocumentContext();
const {
setEditor,
setEditorInitialized,
updateState: updateDocState,
} = useDocumentContext();
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
@@ -241,6 +245,8 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
? handleRemoveComment
: undefined
}
onInit={() => setEditorInitialized(true)}
onDestroy={() => setEditorInitialized(false)}
onChange={updateDocState}
extensions={extensions}
editorStyle={editorStyle}
+31 -6
View File
@@ -1,7 +1,11 @@
import invariant from "invariant";
import compact from "lodash/compact";
import differenceBy from "lodash/differenceBy";
import keyBy from "lodash/keyBy";
import orderBy from "lodash/orderBy";
import { action, computed } from "mobx";
import Comment from "~/models/Comment";
import { CommentSortOption, CommentSortType } from "~/types";
import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore";
import Store from "./base/Store";
@@ -28,14 +32,29 @@ export default class CommentsStore extends Store<Comment> {
* @param documentId ID of the document to get comments for
* @returns Array of comments
*/
threadsInDocument(documentId: string): Comment[] {
return this.filter(
threadsInDocument(
documentId: string,
options: CommentSortOption = { type: CommentSortType.MostRecent }
) {
const comments = this.filter(
(comment: Comment) =>
comment.documentId === documentId &&
!comment.parentCommentId &&
(!comment.isNew ||
comment.createdById === this.rootStore.auth.currentUserId)
);
if (options.type === CommentSortType.MostRecent) {
return comments;
}
const commentsById = keyBy(comments, "id");
const referencedComments = compact(
options.referencedCommentIds.map((id) => commentsById[id])
);
const directComments = differenceBy(comments, referencedComments, "id");
return [...referencedComments, ...directComments];
}
/**
@@ -45,8 +64,11 @@ export default class CommentsStore extends Store<Comment> {
* @param documentId ID of the document to get comments for
* @returns Array of comments
*/
resolvedThreadsInDocument(documentId: string): Comment[] {
return this.threadsInDocument(documentId).filter(
resolvedThreadsInDocument(
documentId: string,
options: CommentSortOption = { type: CommentSortType.MostRecent }
): Comment[] {
return this.threadsInDocument(documentId, options).filter(
(comment: Comment) => comment.isResolved === true
);
}
@@ -58,8 +80,11 @@ export default class CommentsStore extends Store<Comment> {
* @param documentId ID of the document to get comments for
* @returns Array of comments
*/
unresolvedThreadsInDocument(documentId: string): Comment[] {
return this.threadsInDocument(documentId).filter(
unresolvedThreadsInDocument(
documentId: string,
options: CommentSortOption = { type: CommentSortType.MostRecent }
): Comment[] {
return this.threadsInDocument(documentId, options).filter(
(comment: Comment) => comment.isResolved !== true
);
}
+9
View File
@@ -215,3 +215,12 @@ export type Properties<C> = {
? Property
: never]?: C[Property];
};
export enum CommentSortType {
MostRecent = "most_recent",
OrderInDocument = "order_in_document",
}
export type CommentSortOption =
| { type: CommentSortType.MostRecent }
| { type: CommentSortType.OrderInDocument; referencedCommentIds: string[] };
+1
View File
@@ -31,4 +31,5 @@ export const UserPreferenceDefaults: UserPreferences = {
[UserPreference.RememberLastPath]: true,
[UserPreference.UseCursorPointer]: true,
[UserPreference.CodeBlockLineNumers]: true,
[UserPreference.SortCommentsByOrderInDocument]: false,
};
+4 -3
View File
@@ -580,11 +580,12 @@
"Post": "Post",
"Cancel": "Cancel",
"Upload image": "Upload image",
"Resolved comments": "Resolved comments",
"View comments": "View comments",
"View resolved comments": "View resolved comments",
"No resolved comments": "No resolved comments",
"No comments yet": "No comments yet",
"Sort comments": "Sort comments",
"Most recent": "Most recent",
"Order in doc": "Order in doc",
"Resolved": "Resolved",
"Error updating comment": "Error updating comment",
"Document restored": "Document restored",
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
+2
View File
@@ -168,6 +168,8 @@ export enum UserPreference {
SeamlessEdit = "seamlessEdit",
/** Whether documents should start in full-width mode. */
FullWidthDocuments = "fullWidthDocuments",
/** Whether to sort the comments by their order in the document. */
SortCommentsByOrderInDocument = "sortCommentsByOrderInDocument",
}
export type UserPreferences = { [key in UserPreference]?: boolean };