mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
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:
+28
-1
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -82,6 +82,8 @@ export default class SimpleImage extends Node {
|
||||
},
|
||||
],
|
||||
],
|
||||
leafText: (node) =>
|
||||
node.attrs.alt ? `(image: ${node.attrs.alt})` : "(image)",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user