diff --git a/server/routes/api/comments/comments.test.ts b/server/routes/api/comments/comments.test.ts index b214a6202..394d1d95c 100644 --- a/server/routes/api/comments/comments.test.ts +++ b/server/routes/api/comments/comments.test.ts @@ -176,7 +176,116 @@ describe("#comments.create", () => { }, }); + const anotherRes = await server.post("/api/comments.create", { + body: { + token: user.getJwtToken(), + documentId: document.id, + data: { + type: "doc", + content: [{ type: "paragraph" }], + }, + }, + }); + expect(res.status).toEqual(400); + expect(anotherRes.status).toEqual(400); + }); + + it("should not allow comments containing only whitespaces", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + + const res = await server.post("/api/comments.create", { + body: { + token: user.getJwtToken(), + documentId: document.id, + data: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: " \n\r\n" }], + }, + ], + }, + }, + }); + + expect(res.status).toEqual(400); + }); + + it("should allow adding images to comments", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + + const res = await server.post("/api/comments.create", { + body: { + token: user.getJwtToken(), + documentId: document.id, + data: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "image", + attrs: { + src: "https://example.com/image.png", + alt: "Example image", + }, + }, + ], + }, + ], + }, + }, + }); + + expect(res.status).toEqual(200); + }); + + it("should allow adding images from internal sources", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + + const res = await server.post("/api/comments.create", { + body: { + token: user.getJwtToken(), + documentId: document.id, + data: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "image", + attrs: { + src: "/api/attachments.redirect?id=1401323b-c4e2-40de-b172-e1668ec89111", + alt: null, + }, + }, + ], + }, + ], + }, + }, + }); + + expect(res.status).toEqual(200); }); it("should not allow invalid comment data", async () => { diff --git a/server/routes/api/schema.ts b/server/routes/api/schema.ts index a22393216..d44772f01 100644 --- a/server/routes/api/schema.ts +++ b/server/routes/api/schema.ts @@ -2,6 +2,7 @@ import formidable from "formidable"; import { Node } from "prosemirror-model"; import { z } from "zod"; import { ProsemirrorData as TProsemirrorData } from "@shared/types"; +import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; import { schema } from "@server/editor"; export const BaseSchema = z.object({ @@ -14,7 +15,7 @@ export const ProsemirrorSchema = z.custom((val) => { try { const node = Node.fromJSON(schema, val); node.check(); - return true; + return !ProsemirrorHelper.isEmpty(node, schema); } catch (_e) { return false; } diff --git a/shared/editor/nodes/Image.tsx b/shared/editor/nodes/Image.tsx index d67efc302..ba4b32860 100644 --- a/shared/editor/nodes/Image.tsx +++ b/shared/editor/nodes/Image.tsx @@ -166,6 +166,8 @@ export default class Image extends SimpleImage { ["p", { class: "caption" }, 0], ]; }, + toPlainText: (node) => + node.attrs.alt ? `(image: ${node.attrs.alt})` : "(image)", }; } diff --git a/shared/utils/ProsemirrorHelper.ts b/shared/utils/ProsemirrorHelper.ts index b2aa0f470..bc18cdfef 100644 --- a/shared/utils/ProsemirrorHelper.ts +++ b/shared/utils/ProsemirrorHelper.ts @@ -142,8 +142,35 @@ export class ProsemirrorHelper { * * @returns True if the editor is empty */ - static isEmpty(doc: Node) { - return !doc || doc.textContent.trim() === ""; + static isEmpty(doc: Node, schema?: Schema) { + if (!schema) { + return !doc || doc.textContent.trim() === ""; + } + + const textSerializers = Object.fromEntries( + Object.entries(schema.nodes) + .filter(([, node]) => node.spec.toPlainText) + .map(([name, node]) => [name, node.spec.toPlainText]) + ); + + let empty = true; + doc.descendants((child: Node) => { + // If we've already found non-empty data, we can stop descending further + if (!empty) { + return false; + } + + const toPlainText = textSerializers[child.type.name]; + if (toPlainText) { + empty = !toPlainText(child).trim(); + } else if (child.isText) { + empty = !child.text?.trim(); + } + + return empty; + }); + + return empty; } /**