mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
0d198294eb
* fix: Improve merging of links in table cells through patch * PR feedback
1564 lines
45 KiB
TypeScript
1564 lines
45 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import * as Y from "yjs";
|
|
import { TextEditMode } from "@shared/types";
|
|
import { APIUpdateExtension } from "@server/collaboration/APIUpdateExtension";
|
|
import { Event } from "@server/models";
|
|
import { parser } from "@server/editor";
|
|
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
|
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
|
import { buildDocument, buildUser } from "@server/test/factories";
|
|
import { withAPIContext } from "@server/test/support";
|
|
import documentUpdater from "./documentUpdater";
|
|
|
|
describe("documentUpdater", () => {
|
|
it("should change lastModifiedById", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
text: "Changed",
|
|
document,
|
|
})
|
|
);
|
|
|
|
const event = await Event.findLatest({
|
|
teamId: user.teamId,
|
|
});
|
|
expect(document.lastModifiedById).toEqual(user.id);
|
|
expect(event!.name).toEqual("documents.update");
|
|
expect(event!.documentId).toEqual(document.id);
|
|
});
|
|
|
|
it("should change lastModifiedById when republishing an already published document", async () => {
|
|
const user = await buildUser();
|
|
const creator = await buildUser({ teamId: user.teamId });
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
userId: creator.id,
|
|
});
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
text: "Changed",
|
|
publish: true,
|
|
document,
|
|
})
|
|
);
|
|
|
|
expect(document.createdById).toEqual(creator.id);
|
|
expect(document.lastModifiedById).toEqual(user.id);
|
|
});
|
|
|
|
it("should not change lastModifiedById or generate event if nothing changed", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
title: document.title,
|
|
document,
|
|
})
|
|
);
|
|
|
|
expect(document.lastModifiedById).not.toEqual(user.id);
|
|
});
|
|
|
|
it("should notify collaboration server when text changes", async () => {
|
|
const notifyUpdateSpy = vi
|
|
.spyOn(APIUpdateExtension, "notifyUpdate")
|
|
.mockResolvedValue(undefined);
|
|
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "Initial text",
|
|
});
|
|
|
|
// Create initial collaborative state (simulating an active collaboration session)
|
|
const ydoc = ProsemirrorHelper.toYDoc("Initial text");
|
|
document.state = Buffer.from(Y.encodeStateAsUpdate(ydoc));
|
|
await document.save();
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
text: "Changed content",
|
|
document,
|
|
})
|
|
);
|
|
|
|
expect(notifyUpdateSpy).toHaveBeenCalledWith(document.id, user.id);
|
|
notifyUpdateSpy.mockRestore();
|
|
});
|
|
|
|
it("should not notify collaboration server when only title changes", async () => {
|
|
const notifyUpdateSpy = vi
|
|
.spyOn(APIUpdateExtension, "notifyUpdate")
|
|
.mockResolvedValue(undefined);
|
|
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
title: "New Title",
|
|
document,
|
|
})
|
|
);
|
|
|
|
expect(notifyUpdateSpy).not.toHaveBeenCalled();
|
|
notifyUpdateSpy.mockRestore();
|
|
});
|
|
|
|
describe("replace", () => {
|
|
it("should update document content when changing text", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
text: "Changed",
|
|
document,
|
|
})
|
|
);
|
|
|
|
expect(document.text).toEqual("Changed");
|
|
expect(document.content).toEqual({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Changed",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("append", () => {
|
|
it("should append document content when requested", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "Initial",
|
|
});
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
text: "Appended",
|
|
document,
|
|
editMode: TextEditMode.Append,
|
|
})
|
|
);
|
|
|
|
expect(document.text).toEqual("InitialAppended");
|
|
expect(document.content).toMatchObject({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "InitialAppended" }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("should preserve rich content when appending", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "**Bold**",
|
|
});
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
text: "Appended",
|
|
document,
|
|
editMode: TextEditMode.Append,
|
|
})
|
|
);
|
|
|
|
expect(document.content).toMatchObject({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
marks: [{ type: "strong" }],
|
|
text: "Bold",
|
|
},
|
|
{
|
|
type: "text",
|
|
text: "Appended",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("should preserve rich content from JSON when appending", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
const id = randomUUID();
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
marks: [{ type: "comment", attrs: { id, userId: id } }],
|
|
text: "Italic",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
text: "Appended",
|
|
document,
|
|
editMode: TextEditMode.Append,
|
|
})
|
|
);
|
|
|
|
expect(document.content).toMatchObject({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
marks: [{ type: "comment", attrs: { id, userId: id } }],
|
|
text: "Italic",
|
|
},
|
|
{
|
|
type: "text",
|
|
text: "Appended",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("should create new paragraph when appending with newline", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "Initial",
|
|
});
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
text: "\n\nAppended",
|
|
document,
|
|
editMode: TextEditMode.Append,
|
|
})
|
|
);
|
|
|
|
expect(document.text).toEqual("Initial\n\nAppended");
|
|
expect(document.content).toMatchObject({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Initial" }],
|
|
},
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Appended" }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("prepend", () => {
|
|
it("should prepend document content when requested", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "Existing",
|
|
});
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
text: "Prepended",
|
|
document,
|
|
editMode: TextEditMode.Prepend,
|
|
})
|
|
);
|
|
|
|
expect(document.text).toEqual("PrependedExisting");
|
|
expect(document.content).toMatchObject({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "PrependedExisting" }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("should preserve rich content when prepending", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "**Bold**",
|
|
});
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
text: "Prepended",
|
|
document,
|
|
editMode: TextEditMode.Prepend,
|
|
})
|
|
);
|
|
|
|
expect(document.content).toMatchObject({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Prepended",
|
|
},
|
|
{
|
|
type: "text",
|
|
marks: [{ type: "strong" }],
|
|
text: "Bold",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("should create new paragraph when prepending with newline", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "Existing",
|
|
});
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
text: "Prepended\n\n",
|
|
document,
|
|
editMode: TextEditMode.Prepend,
|
|
})
|
|
);
|
|
|
|
expect(document.text).toEqual("Prepended\n\nExisting");
|
|
expect(document.content).toMatchObject({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Prepended" }],
|
|
},
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Existing" }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("patch", () => {
|
|
it("should patch specific text in document content", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "Hello world\n\nThis is a test",
|
|
});
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"Hello earth",
|
|
TextEditMode.Patch,
|
|
"Hello world"
|
|
);
|
|
const content = result.content!.content!;
|
|
|
|
expect(content).toHaveLength(2);
|
|
expect(content[0]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Hello earth" }],
|
|
});
|
|
expect(content[1]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "This is a test" }],
|
|
});
|
|
});
|
|
|
|
it("should throw when findText is not found in document", async () => {
|
|
const user = await buildUser();
|
|
const document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "Hello world",
|
|
});
|
|
|
|
await expect(
|
|
withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
text: "replacement",
|
|
findText: "nonexistent text",
|
|
document,
|
|
editMode: TextEditMode.Patch,
|
|
})
|
|
)
|
|
).rejects.toThrow("The specified text was not found in the document");
|
|
});
|
|
|
|
it("should handle dollar signs in replacement text literally", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "The price is TBD",
|
|
});
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"$100 & $200",
|
|
TextEditMode.Patch,
|
|
"TBD"
|
|
);
|
|
const content = result.content!.content!;
|
|
|
|
expect(content).toHaveLength(1);
|
|
expect(content[0]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "The price is $100 & $200" }],
|
|
});
|
|
});
|
|
|
|
it("should delete matched text when replacement is empty string", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "Hello beautiful world",
|
|
});
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"",
|
|
TextEditMode.Patch,
|
|
"beautiful "
|
|
);
|
|
const content = result.content!.content!;
|
|
|
|
expect(content).toHaveLength(1);
|
|
expect(content[0]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Hello world" }],
|
|
});
|
|
});
|
|
|
|
it("should handle multi-block replacement in a single paragraph", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Replace this text here" }],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
// Replace with content that parses to multiple blocks
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"First paragraph\n\nSecond paragraph",
|
|
TextEditMode.Patch,
|
|
"Replace this text here"
|
|
);
|
|
const content = result.content!.content!;
|
|
|
|
// Should produce two paragraphs, not silently drop the second
|
|
expect(content.length).toBeGreaterThanOrEqual(2);
|
|
expect(content[0]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "First paragraph" }],
|
|
});
|
|
expect(content[1]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Second paragraph" }],
|
|
});
|
|
});
|
|
|
|
it("should patch multi-block content", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "# Heading\n\nOld content\n\nKeep this",
|
|
});
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"# New Heading\n\nNew content",
|
|
TextEditMode.Patch,
|
|
"# Heading\n\nOld content"
|
|
);
|
|
const content = result.content!.content!;
|
|
|
|
expect(content).toHaveLength(3);
|
|
expect(content[0]).toMatchObject({
|
|
type: "heading",
|
|
content: [{ type: "text", text: "New Heading" }],
|
|
});
|
|
expect(content[1]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "New content" }],
|
|
});
|
|
expect(content[2]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Keep this" }],
|
|
});
|
|
});
|
|
|
|
it("should patch the middle item in a list", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "- First item\n- Second item\n- Third item",
|
|
});
|
|
|
|
document = await withAPIContext(user, (ctx) =>
|
|
documentUpdater(ctx, {
|
|
text: "* Updated item",
|
|
findText: "* Second item",
|
|
document,
|
|
editMode: TextEditMode.Patch,
|
|
})
|
|
);
|
|
|
|
const listItem = (text: string) => ({
|
|
type: "list_item",
|
|
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
});
|
|
|
|
expect(document.content).toMatchObject({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "bullet_list",
|
|
content: [
|
|
listItem("First item"),
|
|
listItem("Updated item"),
|
|
listItem("Third item"),
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("should preserve comment marks on untouched blocks when patching", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
const commentId = randomUUID();
|
|
|
|
// Build content with a comment mark (not representable in markdown)
|
|
// on the LAST paragraph, then patch the FIRST paragraph.
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "First paragraph" }],
|
|
},
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
marks: [
|
|
{
|
|
type: "comment",
|
|
attrs: { id: commentId, userId: commentId },
|
|
},
|
|
],
|
|
text: "Commented text",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Third paragraph" }],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
// Capture the ProseMirror JSON of untouched blocks BEFORE patching
|
|
// (with schema defaults already applied via toProsemirror → toJSON)
|
|
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
|
const secondParaBefore = beforeDoc.content[1];
|
|
const thirdParaBefore = beforeDoc.content[2];
|
|
|
|
// Call DocumentHelper directly to inspect the ProseMirror result
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"Updated first",
|
|
TextEditMode.Patch,
|
|
"First paragraph"
|
|
);
|
|
const content = result.content!.content!;
|
|
|
|
// Verify untouched blocks are byte-for-byte identical
|
|
expect(content[1]).toEqual(secondParaBefore);
|
|
expect(content[2]).toEqual(thirdParaBefore);
|
|
|
|
// Verify the patched block was actually updated
|
|
expect(content[0]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Updated first" }],
|
|
});
|
|
|
|
// Verify we still have exactly 3 blocks
|
|
expect(content).toHaveLength(3);
|
|
});
|
|
|
|
it("should preserve comment marks when patching a later block", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
const commentId = randomUUID();
|
|
|
|
// Comment mark on the FIRST paragraph, patch the THIRD.
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
marks: [
|
|
{
|
|
type: "comment",
|
|
attrs: { id: commentId, userId: commentId },
|
|
},
|
|
],
|
|
text: "Commented text",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Middle paragraph" }],
|
|
},
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Last paragraph" }],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
|
const firstParaBefore = beforeDoc.content[0];
|
|
const secondParaBefore = beforeDoc.content[1];
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"Updated last",
|
|
TextEditMode.Patch,
|
|
"Last paragraph"
|
|
);
|
|
const content = result.content!.content!;
|
|
|
|
// Both untouched blocks must be identical
|
|
expect(content[0]).toEqual(firstParaBefore);
|
|
expect(content[1]).toEqual(secondParaBefore);
|
|
|
|
// Patched block updated
|
|
expect(content[2]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Updated last" }],
|
|
});
|
|
expect(content).toHaveLength(3);
|
|
});
|
|
|
|
it("should preserve comment marks in complex document when patching middle content", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
const commentId = randomUUID();
|
|
|
|
// Complex document: heading, paragraph with comment, bullet list, paragraph to patch
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "heading",
|
|
attrs: { level: 2 },
|
|
content: [{ type: "text", text: "Section Title" }],
|
|
},
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
marks: [
|
|
{
|
|
type: "comment",
|
|
attrs: { id: commentId, userId: commentId },
|
|
},
|
|
],
|
|
text: "This has a comment",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "bullet_list",
|
|
content: [
|
|
{
|
|
type: "list_item",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Item one" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "list_item",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Item two" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Final paragraph to edit" }],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"Edited final paragraph",
|
|
TextEditMode.Patch,
|
|
"Final paragraph to edit"
|
|
);
|
|
const content = result.content!.content!;
|
|
|
|
// All three untouched blocks must be byte-for-byte identical
|
|
expect(content[0]).toEqual(beforeDoc.content[0]);
|
|
expect(content[1]).toEqual(beforeDoc.content[1]);
|
|
expect(content[2]).toEqual(beforeDoc.content[2]);
|
|
|
|
// Patched block updated
|
|
expect(content[3]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Edited final paragraph" }],
|
|
});
|
|
expect(content).toHaveLength(4);
|
|
});
|
|
|
|
it("should preserve comment mark when patching adjacent text in the same paragraph", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
const commentId = randomUUID();
|
|
|
|
// Paragraph: "Hello world " + "commented"(with comment mark) + " end"
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "Hello world " },
|
|
{
|
|
type: "text",
|
|
marks: [
|
|
{
|
|
type: "comment",
|
|
attrs: { id: commentId, userId: commentId },
|
|
},
|
|
],
|
|
text: "commented",
|
|
},
|
|
{ type: "text", text: " end" },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
|
const commentNode = beforeDoc.content[0].content[1];
|
|
const endNode = beforeDoc.content[0].content[2];
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"Goodbye world ",
|
|
TextEditMode.Patch,
|
|
"Hello world "
|
|
);
|
|
const content = result.content!.content!;
|
|
|
|
expect(content).toHaveLength(1);
|
|
// Comment mark and trailing text must be preserved exactly
|
|
expect(content[0]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "Goodbye world " },
|
|
commentNode,
|
|
endNode,
|
|
],
|
|
});
|
|
});
|
|
|
|
it("should preserve rich content in other checklist items when patching one item", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
const commentId = randomUUID();
|
|
|
|
// Checklist with 3 items; second item has a comment mark
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "checkbox_list",
|
|
content: [
|
|
{
|
|
type: "checkbox_item",
|
|
attrs: { checked: false },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "First task" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "checkbox_item",
|
|
attrs: { checked: true },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
marks: [
|
|
{
|
|
type: "comment",
|
|
attrs: { id: commentId, userId: commentId },
|
|
},
|
|
],
|
|
text: "Commented task",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "checkbox_item",
|
|
attrs: { checked: false },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Third task" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
|
const secondItem = beforeDoc.content[0].content[1];
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"Updated task",
|
|
TextEditMode.Patch,
|
|
"First task"
|
|
);
|
|
const list = result.content!.content![0];
|
|
|
|
// The second checklist item with its comment mark must be preserved
|
|
expect(list.content![1]).toEqual(secondItem);
|
|
|
|
// The first item should be updated
|
|
expect(list.content![0].content![0].content![0].text).toEqual(
|
|
"Updated task"
|
|
);
|
|
|
|
// All three items should still exist
|
|
expect(list.content).toHaveLength(3);
|
|
});
|
|
|
|
it("should patch checklist item checked state while preserving rich content in siblings", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
const commentId = randomUUID();
|
|
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "checkbox_list",
|
|
content: [
|
|
{
|
|
type: "checkbox_item",
|
|
attrs: { checked: false },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Buy groceries" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "checkbox_item",
|
|
attrs: { checked: false },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
marks: [
|
|
{
|
|
type: "comment",
|
|
attrs: { id: commentId, userId: commentId },
|
|
},
|
|
],
|
|
text: "Review PR",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
|
const secondItem = beforeDoc.content[0].content[1];
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"- [x] Buy groceries",
|
|
TextEditMode.Patch,
|
|
"- [ ] Buy groceries"
|
|
);
|
|
const list = result.content!.content![0];
|
|
|
|
// Checked state should be updated
|
|
expect(list.content![0].attrs!.checked).toBe(true);
|
|
|
|
// Text should remain the same
|
|
expect(list.content![0].content![0].content![0].text).toEqual(
|
|
"Buy groceries"
|
|
);
|
|
|
|
// Second item with comment mark must be preserved exactly
|
|
expect(list.content![1]).toEqual(secondItem);
|
|
});
|
|
|
|
it("should uncheck a checkbox item while preserving rich content in siblings", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
const commentId = randomUUID();
|
|
|
|
// First item is checked, second has a comment mark
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "checkbox_list",
|
|
content: [
|
|
{
|
|
type: "checkbox_item",
|
|
attrs: { checked: true },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Buy groceries" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "checkbox_item",
|
|
attrs: { checked: false },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
marks: [
|
|
{
|
|
type: "comment",
|
|
attrs: { id: commentId, userId: commentId },
|
|
},
|
|
],
|
|
text: "Review PR",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
|
const secondItem = beforeDoc.content[0].content[1];
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"- [ ] Buy groceries",
|
|
TextEditMode.Patch,
|
|
"- [x] Buy groceries"
|
|
);
|
|
const list = result.content!.content![0];
|
|
|
|
// Checked state should be updated to false
|
|
expect(list.content![0].attrs!.checked).toBe(false);
|
|
|
|
// Text should remain the same
|
|
expect(list.content![0].content![0].content![0].text).toEqual(
|
|
"Buy groceries"
|
|
);
|
|
|
|
// Second item with comment mark must be preserved exactly
|
|
expect(list.content![1]).toEqual(secondItem);
|
|
});
|
|
|
|
it("should preserve mention node when patching adjacent text in the same paragraph", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
const mentionId = randomUUID();
|
|
|
|
// Paragraph: "Hello " + @mention + " please review this"
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "Hello " },
|
|
{
|
|
type: "mention",
|
|
attrs: {
|
|
type: "user",
|
|
label: "Tom",
|
|
modelId: mentionId,
|
|
actorId: null,
|
|
id: mentionId,
|
|
},
|
|
},
|
|
{ type: "text", text: " please review this" },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
|
const mentionNode = beforeDoc.content[0].content[1];
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"please approve this",
|
|
TextEditMode.Patch,
|
|
"please review this"
|
|
);
|
|
const content = result.content!.content!;
|
|
|
|
expect(content).toHaveLength(1);
|
|
expect(content[0].type).toEqual("paragraph");
|
|
// The mention node must be preserved
|
|
expect(content[0].content![1]).toEqual(mentionNode);
|
|
});
|
|
|
|
it("should preserve table colwidth when patching a different cell", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
|
|
// Table with colwidth set on cells (not representable in markdown)
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "table",
|
|
content: [
|
|
{
|
|
type: "tr",
|
|
content: [
|
|
{
|
|
type: "th",
|
|
attrs: {
|
|
colspan: 1,
|
|
rowspan: 1,
|
|
colwidth: [150],
|
|
alignment: null,
|
|
},
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Header A" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "th",
|
|
attrs: {
|
|
colspan: 1,
|
|
rowspan: 1,
|
|
colwidth: [250],
|
|
alignment: null,
|
|
},
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Header B" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "tr",
|
|
content: [
|
|
{
|
|
type: "td",
|
|
attrs: {
|
|
colspan: 1,
|
|
rowspan: 1,
|
|
colwidth: [150],
|
|
alignment: null,
|
|
},
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Cell to edit" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "td",
|
|
attrs: {
|
|
colspan: 1,
|
|
rowspan: 1,
|
|
colwidth: [250],
|
|
alignment: null,
|
|
},
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Keep this" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
|
// Second cell in second row should retain colwidth
|
|
const unchangedCell = beforeDoc.content[0].content[1].content[1];
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"Updated cell",
|
|
TextEditMode.Patch,
|
|
"Cell to edit"
|
|
);
|
|
const table = result.content!.content![0];
|
|
const secondRow = table.content![1];
|
|
|
|
// The unchanged cell must preserve its colwidth attr
|
|
expect(secondRow.content![1]).toEqual(unchangedCell);
|
|
expect(secondRow.content![1].attrs!.colwidth).toEqual([250]);
|
|
});
|
|
|
|
it("should preserve trailing whitespace in checklist items when patching", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
|
|
// Checklist where an item ends with a hard break (trailing spaces matter)
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "checkbox_list",
|
|
content: [
|
|
{
|
|
type: "checkbox_item",
|
|
attrs: { checked: false },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Edit me" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "checkbox_item",
|
|
attrs: { checked: false },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "Line one" },
|
|
{ type: "br" },
|
|
{ type: "text", text: "Line two" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
|
const secondItem = beforeDoc.content[0].content[1];
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"Edited",
|
|
TextEditMode.Patch,
|
|
"Edit me"
|
|
);
|
|
const list = result.content!.content![0];
|
|
|
|
// The second item with its hard break must be preserved exactly
|
|
expect(list.content![1]).toEqual(secondItem);
|
|
});
|
|
|
|
it("should preserve rich content in blockquote when patching", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
const commentId = randomUUID();
|
|
|
|
// Blockquote with two paragraphs; second has a comment mark
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "blockquote",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Edit this line" }],
|
|
},
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
marks: [
|
|
{
|
|
type: "comment",
|
|
attrs: { id: commentId, userId: commentId },
|
|
},
|
|
],
|
|
text: "Keep this comment",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
|
const secondPara = beforeDoc.content[0].content[1];
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"Edited line",
|
|
TextEditMode.Patch,
|
|
"Edit this line"
|
|
);
|
|
const blockquote = result.content!.content![0];
|
|
|
|
// Patched paragraph updated
|
|
expect(blockquote.content![0].content![0].text).toEqual("Edited line");
|
|
|
|
// Second paragraph with comment mark preserved exactly
|
|
expect(blockquote.content![1]).toEqual(secondPara);
|
|
});
|
|
|
|
it("should always patch the first occurrence when findText appears multiple times", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "TODO item\n\nSome details\n\nTODO item",
|
|
});
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"DONE item",
|
|
TextEditMode.Patch,
|
|
"TODO item"
|
|
);
|
|
const content = result.content!.content!;
|
|
|
|
expect(content).toHaveLength(3);
|
|
// First occurrence replaced
|
|
expect(content[0]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "DONE item" }],
|
|
});
|
|
// Middle paragraph unchanged
|
|
expect(content[1]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Some details" }],
|
|
});
|
|
// Second occurrence untouched
|
|
expect(content[2]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "TODO item" }],
|
|
});
|
|
});
|
|
|
|
it("should patch text containing inline formatting", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
text: "This is **bold** text",
|
|
});
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"This is **strong** text",
|
|
TextEditMode.Patch,
|
|
"This is **bold** text"
|
|
);
|
|
const content = result.content!.content!;
|
|
|
|
expect(content).toHaveLength(1);
|
|
expect(content[0]).toMatchObject({
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "This is " },
|
|
{
|
|
type: "text",
|
|
marks: [{ type: "strong" }],
|
|
text: "strong",
|
|
},
|
|
{ type: "text", text: " text" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("should preserve ordered list container attrs when patching an item", async () => {
|
|
const user = await buildUser();
|
|
let document = await buildDocument({
|
|
teamId: user.teamId,
|
|
});
|
|
|
|
// Ordered list starting at 3 with lower-alpha style
|
|
document.content = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "ordered_list",
|
|
attrs: { order: 3, listStyle: "lower-alpha" },
|
|
content: [
|
|
{
|
|
type: "list_item",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "First item" }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "list_item",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "Second item" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
await document.save();
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"Updated item",
|
|
TextEditMode.Patch,
|
|
"First item"
|
|
);
|
|
const list = result.content!.content![0];
|
|
|
|
// Container attrs must be preserved
|
|
expect(list.attrs!.order).toEqual(3);
|
|
expect(list.attrs!.listStyle).toEqual("lower-alpha");
|
|
|
|
// Patched item updated
|
|
expect(list.content![0].content![0].content![0].text).toEqual(
|
|
"Updated item"
|
|
);
|
|
|
|
// Unchanged item preserved
|
|
expect(list.content![1].content![0].content![0].text).toEqual(
|
|
"Second item"
|
|
);
|
|
});
|
|
|
|
it("should apply a link when wrapping existing table cell text", async () => {
|
|
const user = await buildUser();
|
|
const document = await buildDocument({ teamId: user.teamId });
|
|
|
|
document.content = parser
|
|
.parse("| Name | Notes |\n|------|-------|\n| Alpha | see |\n")
|
|
.toJSON();
|
|
await document.save();
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"[see](https://example.com/docs)",
|
|
TextEditMode.Patch,
|
|
"see"
|
|
);
|
|
|
|
// The cell text is unchanged but should now carry a link mark — it must
|
|
// not be silently dropped during the merge.
|
|
const cellText =
|
|
result.content!.content![0].content![1].content![1].content![0]
|
|
.content![0];
|
|
expect(cellText.text).toEqual("see");
|
|
expect(cellText.marks).toEqual([
|
|
expect.objectContaining({
|
|
type: "link",
|
|
attrs: expect.objectContaining({ href: "https://example.com/docs" }),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("should preserve other table cells when adding a link to one cell", async () => {
|
|
const user = await buildUser();
|
|
const document = await buildDocument({ teamId: user.teamId });
|
|
|
|
document.content = parser
|
|
.parse(
|
|
"| Name | Notes |\n|------|-------|\n| Alpha | see |\n| Beta | other |\n"
|
|
)
|
|
.toJSON();
|
|
await document.save();
|
|
|
|
// Capture the untouched (Beta) row BEFORE patching so we can assert its
|
|
// structure and attrs are preserved exactly, not just its text.
|
|
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
|
const untouchedRow = beforeDoc.content[0].content[2];
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"see [docs](https://example.com/d)",
|
|
TextEditMode.Patch,
|
|
"see"
|
|
);
|
|
|
|
// The patched cell gained the link
|
|
expect(result.text).toContain("[docs](https://example.com/d)");
|
|
// The untouched row node must remain identical
|
|
expect(result.content!.content![0].content![2]).toEqual(untouchedRow);
|
|
});
|
|
|
|
it("should apply a mark when wrapping existing list item text", async () => {
|
|
const user = await buildUser();
|
|
const document = await buildDocument({ teamId: user.teamId });
|
|
|
|
document.content = parser.parse("- clickme\n- other\n").toJSON();
|
|
await document.save();
|
|
|
|
const result = DocumentHelper.applyMarkdownToDocument(
|
|
document,
|
|
"[clickme](https://example.com)",
|
|
TextEditMode.Patch,
|
|
"clickme"
|
|
);
|
|
|
|
expect(result.text).toContain("[clickme](https://example.com)");
|
|
expect(result.text).toContain("other");
|
|
});
|
|
});
|
|
});
|