fix: Sync schema between frontend editor and API (#11101)

* fix: Sync schema between frontend editor and API
Allow lists in basic schema

* test

* snap
This commit is contained in:
Tom Moor
2026-01-07 22:10:41 -05:00
committed by GitHub
parent 2116d9972f
commit ca21b8a17d
9 changed files with 157 additions and 71 deletions
+28 -1
View File
@@ -1,6 +1,10 @@
import { Schema } from "prosemirror-model";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { richExtensions, withComments } from "@shared/editor/nodes";
import {
basicExtensions,
richExtensions,
withComments,
} from "@shared/editor/nodes";
const extensions = withComments(richExtensions);
export const extensionManager = new ExtensionManager(extensions);
@@ -29,3 +33,26 @@ export const parser = extensionManager.parser({
export const serializer = extensionManager.serializer();
export const plugins = extensionManager.plugins;
export const basicExtensionManager = new ExtensionManager(basicExtensions);
export const basicSchema = new Schema({
nodes: basicExtensionManager.nodes,
marks: basicExtensionManager.marks,
});
for (const extension of basicExtensionManager.extensions) {
extension.bindEditor({
schema: basicSchema,
props: {
theme: {
isDark: false,
},
},
} as any);
}
export const basicParser = basicExtensionManager.parser({
schema: basicSchema,
plugins: basicExtensionManager.rulePlugins,
});
+2 -2
View File
@@ -13,7 +13,7 @@ import {
import type { ProsemirrorData, ReactionSummary } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CommentValidation } from "@shared/validations";
import { schema } from "@server/editor";
import { basicSchema } from "@server/editor";
import { ValidationError } from "@server/errors";
import Document from "./Document";
import User from "./User";
@@ -137,7 +137,7 @@ class Comment extends ParanoidModel<
* @returns The plain text representation of the comment data
*/
public toPlainText() {
const node = Node.fromJSON(schema, this.data);
const node = Node.fromJSON(basicSchema, this.data);
return ProsemirrorHelper.toPlainText(node);
}
@@ -9,59 +9,6 @@ exports[`#comments.add_reaction should require authentication 1`] = `
}
`;
exports[`#comments.create should create a comment from markdown text 1`] = `
{
"content": [
{
"attrs": {
"level": 2,
},
"content": [
{
"text": "heading",
"type": "text",
},
],
"type": "heading",
},
{
"content": [
{
"content": [
{
"content": [
{
"text": "list item 1",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "list_item",
},
{
"content": [
{
"content": [
{
"text": "list item 2",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "list_item",
},
],
"type": "bullet_list",
},
],
"type": "doc",
}
`;
exports[`#comments.create should require authentication 1`] = `
{
"error": "authentication_required",
+101 -2
View File
@@ -487,7 +487,7 @@ describe("#comments.create", () => {
teamId: user.teamId,
});
const text = "## heading\n\n- list item 1\n- list item 2";
const text = "test\n\n- list item 1\n- list item 2";
const res = await server.post("/api/comments.create", {
body: {
@@ -500,7 +500,9 @@ describe("#comments.create", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.data).toMatchSnapshot();
expect(body.data.data.content[0].type).toEqual("paragraph");
expect(body.data.data.content[0].content[0].text).toEqual("test");
expect(body.data.data.content[1].type).toEqual("bullet_list");
});
it("should not allow empty comment data", async () => {
@@ -651,6 +653,71 @@ describe("#comments.create", () => {
expect(res.status).toEqual(400);
});
it("should not allow rich formatting nodes", 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: "heading",
attrs: { level: 1 },
content: [{ type: "text", text: "Heading" }],
},
],
},
},
});
expect(res.status).toEqual(400);
});
it("should allow list nodes", 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: "bullet_list",
content: [
{
type: "list_item",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Item 1" }],
},
],
},
],
},
],
},
},
});
expect(res.status).toEqual(200);
});
});
describe("#comments.update", () => {
@@ -690,6 +757,38 @@ describe("#comments.update", () => {
expect(body.policies[0].abilities.update).toBeTruthy();
expect(body.policies[0].abilities.delete).toBeTruthy();
});
it("should not allow rich formatting nodes in update", async () => {
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 res = await server.post("/api/comments.update", {
body: {
token: user.getJwtToken(),
id: comment.id,
data: {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
content: [{ type: "text", text: "Heading" }],
},
],
},
},
});
expect(res.status).toEqual(400);
});
});
describe("#comments.resolve", () => {
+2 -2
View File
@@ -9,7 +9,7 @@ import {
IconType,
} from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import { parser } from "@server/editor";
import { basicParser } from "@server/editor";
import auth from "@server/middlewares/authentication";
import { feature } from "@server/middlewares/feature";
import { rateLimiter } from "@server/middlewares/rateLimiter";
@@ -52,7 +52,7 @@ router.post(
user
)
: undefined;
const data = text ? parser.parse(text).toJSON() : ctx.input.body.data;
const data = text ? basicParser.parse(text).toJSON() : ctx.input.body.data;
const comment = await Comment.createWithCtx(ctx, {
id,
+3 -2
View File
@@ -1,6 +1,7 @@
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { CommentStatusFilter } from "@shared/types";
import { basicSchema } from "@server/editor";
import { BaseSchema, ProsemirrorSchema } from "@server/routes/api/schema";
import { zodEmojiType } from "@server/utils/zod";
@@ -36,7 +37,7 @@ export const CommentsCreateSchema = BaseSchema.extend({
parentCommentId: z.string().uuid().optional(),
/** Create comment with this data */
data: ProsemirrorSchema().optional(),
data: ProsemirrorSchema({ schema: basicSchema }).optional(),
/** Create comment with this text */
text: z.string().optional(),
@@ -51,7 +52,7 @@ export type CommentsCreateReq = z.infer<typeof CommentsCreateSchema>;
export const CommentsUpdateSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
/** Update comment with this data */
data: ProsemirrorSchema(),
data: ProsemirrorSchema({ schema: basicSchema }),
}),
});
+10 -6
View File
@@ -1,4 +1,5 @@
import type formidable from "formidable";
import type { Schema } from "prosemirror-model";
import { Node } from "prosemirror-model";
import { z } from "zod";
import type { ProsemirrorData as TProsemirrorData } from "@shared/types";
@@ -16,15 +17,18 @@ export const BaseSchema = z.object({
*
* @param allowEmpty - Whether to allow an empty document.
*/
export const ProsemirrorSchema = (options?: { allowEmpty: boolean }) =>
z.custom<TProsemirrorData>((val) => {
export const ProsemirrorSchema = (options?: {
allowEmpty?: boolean;
schema?: Schema;
}) => {
const s = options?.schema ?? schema;
return z.custom<TProsemirrorData>((val) => {
try {
const node = Node.fromJSON(schema, val);
const node = Node.fromJSON(s, val);
node.check();
return options?.allowEmpty
? true
: !ProsemirrorHelper.isEmpty(node, schema);
return options?.allowEmpty ? true : !ProsemirrorHelper.isEmpty(node, s);
} catch (_e) {
return false;
}
}, "Invalid data");
};
+2
View File
@@ -82,6 +82,8 @@ export default class SimpleImage extends Node {
},
],
],
leafText: (node) =>
node.attrs.alt ? `(image: ${node.attrs.alt})` : "(image)",
};
}
+9 -3
View File
@@ -47,10 +47,10 @@ import Video from "./Video";
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
/**
* The basic set of nodes that are used in the editor. This is used for simple
* A set of inline nodes that are used in the editor. This is used for simple
* editors that need basic formatting.
*/
export const basicExtensions: Nodes = [
export const inlineExtensions: Nodes = [
Doc,
Paragraph,
Emoji,
@@ -87,12 +87,18 @@ export const tableExtensions: Nodes = [
Table,
];
/**
* The basic set of nodes that are used in the editor. This is used for simple
* editors that need basic formatting and lists.
*/
export const basicExtensions: Nodes = [...inlineExtensions, ...listExtensions];
/**
* The full set of nodes that are used in the editor. This is used for rich
* editors that need advanced formatting.
*/
export const richExtensions: Nodes = [
...basicExtensions.filter((n) => n !== SimpleImage),
...inlineExtensions.filter((n) => n !== SimpleImage),
Image,
CodeBlock,
CodeFence,