mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
feat: Option to return anchor text for comments (#8196)
* feat: Option to return anchor text for comments * cleanup anchorText presentation * consolidated anchor text * cleanup unused method
This commit is contained in:
@@ -8,6 +8,7 @@ import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
@@ -74,10 +75,10 @@ function CommentThread({
|
||||
|
||||
const canReply = can.comment && !thread.isResolved;
|
||||
|
||||
const highlightedCommentMarks = editor
|
||||
?.getComments()
|
||||
.filter((comment) => comment.id === thread.id);
|
||||
const highlightedText = highlightedCommentMarks?.map((c) => c.text).join("");
|
||||
const highlightedText = ProsemirrorHelper.getAnchorTextForComment(
|
||||
editor?.getComments() ?? [],
|
||||
thread.id
|
||||
);
|
||||
|
||||
const commentsInThread = comments
|
||||
.inThread(thread.id)
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { Comment } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import presentUser from "./user";
|
||||
|
||||
export default function present(comment: Comment) {
|
||||
type Options = {
|
||||
/** Whether to include anchor text, if it exists */
|
||||
includeAnchorText?: boolean;
|
||||
};
|
||||
|
||||
export default function present(
|
||||
comment: Comment,
|
||||
{ includeAnchorText }: Options = {}
|
||||
) {
|
||||
let anchorText: string | undefined;
|
||||
|
||||
if (includeAnchorText && comment.document) {
|
||||
const commentMarks = ProsemirrorHelper.getComments(
|
||||
DocumentHelper.toProsemirror(comment.document)
|
||||
);
|
||||
anchorText = ProsemirrorHelper.getAnchorTextForComment(
|
||||
commentMarks,
|
||||
comment.id
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: comment.id,
|
||||
data: comment.data,
|
||||
@@ -15,5 +37,6 @@ export default function present(comment: Comment) {
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
reactions: comment.reactions ?? [],
|
||||
anchorText,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { CommentStatusFilter, ReactionSummary } from "@shared/types";
|
||||
import {
|
||||
CommentStatusFilter,
|
||||
ProsemirrorData,
|
||||
ReactionSummary,
|
||||
} from "@shared/types";
|
||||
import { Comment, Reaction } from "@server/models";
|
||||
import {
|
||||
buildAdmin,
|
||||
buildCollection,
|
||||
buildComment,
|
||||
buildCommentMark,
|
||||
buildDocument,
|
||||
buildResolvedComment,
|
||||
buildTeam,
|
||||
@@ -78,6 +83,92 @@ describe("#comments.info", () => {
|
||||
expect(body.policies[0].abilities.update).toBeTruthy();
|
||||
expect(body.policies[0].abilities.delete).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return anchor text for an anchored comment", async () => {
|
||||
const anchorText = "anchor text";
|
||||
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 content = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: anchorText,
|
||||
marks: [buildCommentMark({ id: comment.id, userId: user.id })],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as ProsemirrorData;
|
||||
await document.update({ content });
|
||||
|
||||
const res = await server.post("/api/comments.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: comment.id,
|
||||
includeAnchorText: true,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(comment.id);
|
||||
expect(body.data.anchorText).toEqual(anchorText);
|
||||
});
|
||||
|
||||
it("should not return anchor text for a non-anchored comment", async () => {
|
||||
const anchorText = "anchor text";
|
||||
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 content = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: anchorText,
|
||||
marks: [buildCommentMark({ userId: user.id })],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as ProsemirrorData;
|
||||
await document.update({ content });
|
||||
|
||||
const res = await server.post("/api/comments.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: comment.id,
|
||||
includeAnchorText: true,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(comment.id);
|
||||
expect(body.data.anchorText).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#comments.list", () => {
|
||||
@@ -120,6 +211,58 @@ describe("#comments.list", () => {
|
||||
expect(body.pagination.total).toEqual(2);
|
||||
});
|
||||
|
||||
it("should return anchor texts for comments in a document", async () => {
|
||||
const anchorText = "anchor text";
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const commentOne = await buildComment({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
const commentTwo = await buildResolvedComment(user, {
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
const content = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: anchorText,
|
||||
marks: [buildCommentMark({ id: commentOne.id, userId: user.id })],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as ProsemirrorData;
|
||||
await document.update({ content });
|
||||
|
||||
const res = await server.post("/api/comments.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
includeAnchorText: true,
|
||||
sort: "createdAt",
|
||||
direction: "ASC",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(2);
|
||||
expect(body.data[0].id).toEqual(commentOne.id);
|
||||
expect(body.data[1].id).toEqual(commentTwo.id);
|
||||
expect(body.data[0].anchorText).toEqual(anchorText);
|
||||
expect(body.data[1].anchorText).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return unresolved comments for a collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
|
||||
@@ -60,7 +60,7 @@ router.post(
|
||||
feature(TeamPreference.Commenting),
|
||||
validate(T.CommentsInfoSchema),
|
||||
async (ctx: APIContext<T.CommentsInfoReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { id, includeAnchorText } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const comment = await Comment.findByPk(id, {
|
||||
@@ -72,8 +72,10 @@ router.post(
|
||||
authorize(user, "read", comment);
|
||||
authorize(user, "read", document);
|
||||
|
||||
comment.document = document;
|
||||
|
||||
ctx.body = {
|
||||
data: presentComment(comment),
|
||||
data: presentComment(comment, { includeAnchorText }),
|
||||
policies: presentPolicies(user, [comment]),
|
||||
};
|
||||
}
|
||||
@@ -93,6 +95,7 @@ router.post(
|
||||
parentCommentId,
|
||||
statusFilter,
|
||||
collectionId,
|
||||
includeAnchorText,
|
||||
} = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const statusQuery = [];
|
||||
@@ -135,6 +138,7 @@ router.post(
|
||||
Comment.findAll(params),
|
||||
Comment.count({ where }),
|
||||
]);
|
||||
comments.forEach((comment) => (comment.document = document));
|
||||
} else if (collectionId) {
|
||||
const collection = await Collection.findByPk(collectionId);
|
||||
authorize(user, "read", collection);
|
||||
@@ -184,7 +188,9 @@ router.post(
|
||||
|
||||
ctx.body = {
|
||||
pagination: { ...ctx.state.pagination, total },
|
||||
data: comments.map(presentComment),
|
||||
data: comments.map((comment) =>
|
||||
presentComment(comment, { includeAnchorText })
|
||||
),
|
||||
policies: presentPolicies(user, comments),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,13 +65,18 @@ export const CommentsListSchema = BaseSchema.extend({
|
||||
parentCommentId: z.string().uuid().optional(),
|
||||
/** Comment statuses to include in results */
|
||||
statusFilter: z.nativeEnum(CommentStatusFilter).array().optional(),
|
||||
/** Whether to include anchor text, if it exists */
|
||||
includeAnchorText: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type CommentsListReq = z.infer<typeof CommentsListSchema>;
|
||||
|
||||
export const CommentsInfoSchema = z.object({
|
||||
body: BaseIdSchema,
|
||||
body: BaseIdSchema.extend({
|
||||
/** Whether to include anchor text, if it exists */
|
||||
includeAnchorText: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type CommentsInfoReq = z.infer<typeof CommentsInfoSchema>;
|
||||
|
||||
@@ -654,3 +654,23 @@ export function buildProseMirrorDoc(content: DeepPartial<ProsemirrorData>[]) {
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCommentMark(overrides: {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
draft?: boolean;
|
||||
resolved?: boolean;
|
||||
}) {
|
||||
if (!overrides.id) {
|
||||
overrides.id = randomstring.generate(10);
|
||||
}
|
||||
|
||||
if (!overrides.userId) {
|
||||
overrides.userId = randomstring.generate(10);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "comment",
|
||||
attrs: overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { CommentMark, ProsemirrorHelper } from "./ProsemirrorHelper";
|
||||
|
||||
describe("ProsemirrorHelper", () => {
|
||||
describe("getAnchorTextForComment", () => {
|
||||
it("should return the anchor text for the comment", async () => {
|
||||
const commentId = "test-comment-id";
|
||||
const anchorText = "anchor text";
|
||||
const commentMarks: CommentMark[] = [
|
||||
{
|
||||
id: commentId,
|
||||
userId: "test-user-id",
|
||||
text: anchorText,
|
||||
},
|
||||
{
|
||||
id: "random-comment-id",
|
||||
userId: "test-user-id",
|
||||
text: "some random text",
|
||||
},
|
||||
];
|
||||
|
||||
const returnedAnchorText = ProsemirrorHelper.getAnchorTextForComment(
|
||||
commentMarks,
|
||||
commentId
|
||||
);
|
||||
|
||||
expect(returnedAnchorText).toEqual(anchorText);
|
||||
});
|
||||
|
||||
it("should return the consolidated anchor text when multiple marks are present for the comment", async () => {
|
||||
const commentId = "test-comment-id";
|
||||
const anchorTextOne = "anchor text 1";
|
||||
const anchorTextTwo = "anchor text 2";
|
||||
const commentMarks: CommentMark[] = [
|
||||
{
|
||||
id: commentId,
|
||||
userId: "test-user-id",
|
||||
text: anchorTextOne,
|
||||
},
|
||||
{
|
||||
id: commentId,
|
||||
userId: "test-user-id",
|
||||
text: anchorTextTwo,
|
||||
},
|
||||
{
|
||||
id: "random-comment-id",
|
||||
userId: "test-user-id",
|
||||
text: "some random text",
|
||||
},
|
||||
];
|
||||
|
||||
const returnedAnchorText = ProsemirrorHelper.getAnchorTextForComment(
|
||||
commentMarks,
|
||||
commentId
|
||||
);
|
||||
|
||||
expect(returnedAnchorText).toEqual(`${anchorTextOne}${anchorTextTwo}`);
|
||||
});
|
||||
|
||||
it("should return undefined when no comment mark matches the provided comment", async () => {
|
||||
const commentId = "test-comment-id";
|
||||
const commentMarks: CommentMark[] = [
|
||||
{
|
||||
id: "random-comment-id-1",
|
||||
userId: "test-user-id",
|
||||
text: "some random text",
|
||||
},
|
||||
{
|
||||
id: "random-comment-id-2",
|
||||
userId: "test-user-id",
|
||||
text: "some random text",
|
||||
},
|
||||
];
|
||||
|
||||
const returnedAnchorText = ProsemirrorHelper.getAnchorTextForComment(
|
||||
commentMarks,
|
||||
commentId
|
||||
);
|
||||
|
||||
expect(returnedAnchorText).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined when comment marks are empty", async () => {
|
||||
const returnedAnchorText = ProsemirrorHelper.getAnchorTextForComment(
|
||||
[],
|
||||
"test-comment-id"
|
||||
);
|
||||
expect(returnedAnchorText).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -204,6 +204,24 @@ export class ProsemirrorHelper {
|
||||
return comments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the consolidated anchor text for the given comment-id.
|
||||
*
|
||||
* @param marks all available comment marks in a document.
|
||||
* @param commentId the comment-id to build the anchor text.
|
||||
* @returns consolidated anchor text.
|
||||
*/
|
||||
static getAnchorTextForComment(
|
||||
marks: CommentMark[],
|
||||
commentId: string
|
||||
): string | undefined {
|
||||
const anchorTexts = marks
|
||||
.filter((mark) => mark.id === commentId)
|
||||
.map((mark) => mark.text);
|
||||
|
||||
return anchorTexts.length ? anchorTexts.join("") : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through the document to find all of the images.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user