mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
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:
@@ -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();
|
||||
|
||||
@@ -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")};
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
@@ -31,4 +31,5 @@ export const UserPreferenceDefaults: UserPreferences = {
|
||||
[UserPreference.RememberLastPath]: true,
|
||||
[UserPreference.UseCursorPointer]: true,
|
||||
[UserPreference.CodeBlockLineNumers]: true,
|
||||
[UserPreference.SortCommentsByOrderInDocument]: false,
|
||||
};
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user