Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/tip-tap-text-block-default-block-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@dextinity/cms-admin": patch
"@dextinity/cms-api": patch
---

Fix saving a TipTap rich text that ends with a list

Since v10.8.0, the editor added an empty list after a list at the end of the content. The API rejects a list without items, so saving failed with "Validation failed". Removing the list didn't fix it: the empty list stayed at the end of the content, where editors couldn't see or remove it.

ProseMirror fills content with the schema's first block node, for instance after a list at the end of the content or when all content is deleted. When the `textBlock` node replaced the paragraph and heading nodes, it lost the paragraph's priority, so a list became the first block node. The `textBlock` node gets the paragraph's priority again, so the editor adds an empty text block after a list, as it did before v10.8.0.

With `maxTextBlocks`, the editor only adds this empty text block when the content stays within the limit. Otherwise, a list as the last allowed block was followed by a block over the limit, and the content couldn't be saved.
1 change: 1 addition & 0 deletions packages/admin/cms-admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@tiptap/core": "^3.22.3",
"@tiptap/extension-subscript": "^3.22.3",
"@tiptap/extension-superscript": "^3.22.3",
"@tiptap/extensions": "^3.22.3",
"@tiptap/pm": "^3.22.3",
"@tiptap/react": "^3.22.3",
"@tiptap/starter-kit": "^3.22.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,7 @@ export const ListTextBlockStyles: StoryObj<typeof ListTextBlockStylesStory> = {

await step("Switch to ordered list via keyboard shortcut", async () => {
const editor = canvas.getByRole("textbox");
await userEvent.click(editor);
await userEvent.click(within(editor).getByText("List item text"));
const mod = /Mac/i.test(navigator.platform) ? "Meta" : "Control";
await userEvent.keyboard(`{${mod}>}{Shift>}7{/Shift}{/${mod}}`);

Expand Down Expand Up @@ -777,7 +777,7 @@ export const ListTextBlockStyles: StoryObj<typeof ListTextBlockStylesStory> = {

await step("Nest a bullet list inside the ordered list", async () => {
const editor = canvas.getByRole("textbox");
await userEvent.click(editor);
await userEvent.click(within(editor).getByText("List item text"));
await userEvent.keyboard("{Enter}Nested item{Tab}");

const mod = /Mac/i.test(navigator.platform) ? "Meta" : "Control";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { Editor, JSONContent } from "@tiptap/core";
import { Editor, type JSONContent } from "@tiptap/core";
import { describe, expect, it, vi } from "vitest";

import { render } from "../../testing/test-utils";
import { createBlockSkeleton } from "../helpers/createBlockSkeleton";
import { BlockCategory, type BlockInterface, type LinkBlockInterface } from "../types";
import {
buildTipTapExtensions,
createTipTapRichTextBlock,
resolveTipTapOptions,
type TipTapRichTextBlockFactoryOptions,
type TipTapRichTextBlockState,
type TipTapTextBlock,
type TipTapTextBlockElementProps,
Expand Down Expand Up @@ -77,6 +80,99 @@ describe("createTipTapRichTextBlock", () => {
});
});

describe("editor", () => {
function createEditor(options: TipTapRichTextBlockFactoryOptions = {}) {
return new Editor({
extensions: buildTipTapExtensions({
resolvedOptions: resolveTipTapOptions(options),
inlineStyles: [],
placeholders: [],
childBlocks: {},
maxTextBlocks: options.maxTextBlocks,
}),
});
}

const listItem = (text: string): JSONContent => ({
type: "listItem",
content: [{ type: "textBlock", attrs: { textBlock: "paragraph" }, content: [{ type: "text", text }] }],
});

it("should use the text block as the schema's default block type, not a list", () => {
const editor = createEditor();
expect(editor.schema.topNodeType.contentMatch.defaultType?.name).toBe("textBlock");
});

it.each(["orderedList", "bulletList"])("should end content ending with an %s with an empty default text block", (listType) => {
const editor = createEditor();

editor.commands.setContent({ type: "doc", content: [{ type: listType, content: [listItem("item")] }] });

expect(editor.state.doc.lastChild?.toJSON()).toEqual({ type: "textBlock", attrs: { textBlock: "paragraph" } });
});

it("should leave a single empty default text block after deleting all content", () => {
const editor = createEditor();
editor.commands.setContent({ type: "doc", content: [{ type: "orderedList", content: [listItem("item")] }] });

editor.commands.selectAll();
editor.commands.deleteSelection();

expect(editor.getJSON()).toEqual({ type: "doc", content: [{ type: "textBlock", attrs: { textBlock: "paragraph" } }] });
});

it("should fill an emptied heading-only block with the default text block", () => {
const editor = createEditor({
textBlocks: [
{ name: "heading-1", label: "Heading 1", tag: "h1" },
{ name: "heading-2", label: "Heading 2", tag: "h2" },
],
defaultTextBlock: "heading-2",
});
editor.commands.setContent({
type: "doc",
content: [{ type: "textBlock", attrs: { textBlock: "heading-1" }, content: [{ type: "text", text: "Title" }] }],
});

editor.commands.selectAll();
editor.commands.deleteSelection();

expect(editor.getJSON()).toEqual({ type: "doc", content: [{ type: "textBlock", attrs: { textBlock: "heading-2" } }] });
});

const toggleList = (editor: Editor, listType: "orderedList" | "bulletList") =>
listType === "orderedList" ? editor.commands.toggleOrderedList() : editor.commands.toggleBulletList();

const paragraph = (text: string): JSONContent => ({
type: "textBlock",
attrs: { textBlock: "paragraph" },
content: [{ type: "text", text }],
});

it.each(["orderedList", "bulletList"] as const)(
"should not add an empty text block after the %s when it would exceed maxTextBlocks",
(listType) => {
const editor = createEditor({ maxTextBlocks: 2 });
editor.commands.setContent({ type: "doc", content: [paragraph("first"), paragraph("second")] });
editor.commands.setTextSelection(editor.state.doc.content.size - 1);

toggleList(editor, listType);

expect(editor.getJSON().content?.map((node) => node.type)).toEqual(["textBlock", listType]);
},
);

it.each(["orderedList", "bulletList"] as const)("should add an empty text block after the %s below maxTextBlocks", (listType) => {
const editor = createEditor({ maxTextBlocks: 2 });
editor.commands.setContent({ type: "doc", content: [paragraph("first")] });
editor.commands.setTextSelection(editor.state.doc.content.size - 1);

toggleList(editor, listType);

expect(editor.getJSON().content?.map((node) => node.type)).toEqual([listType, "textBlock"]);
});
});

describe("maxTextBlocks", () => {
const paragraph = (text: string): JSONContent => ({
type: "textBlock",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { styled } from "@mui/material/styles";
import { Extension, type Extensions } from "@tiptap/core";
import Subscript from "@tiptap/extension-subscript";
import Superscript from "@tiptap/extension-superscript";
import { skipTrailingNodeMeta } from "@tiptap/extensions";
import { EditorContent, type JSONContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import isEqual from "lodash.isequal";
Expand Down Expand Up @@ -100,7 +101,7 @@ const defaultTextBlocks: TipTapTextBlock[] = [
})),
];

function resolveTipTapOptions({
export function resolveTipTapOptions({
undoRedoButtons = true,
bold = true,
italic = true,
Expand Down Expand Up @@ -185,7 +186,7 @@ export interface TipTapChildBlock {
display: "block" | "inline";
}

interface TipTapRichTextBlockFactoryOptions {
export interface TipTapRichTextBlockFactoryOptions {
/**
* Shows the undo/redo buttons in the toolbar. The keyboard shortcuts work regardless. Defaults to `true`.
*/
Expand Down Expand Up @@ -309,6 +310,12 @@ const isCmsBlockNode = (content: JSONContent): boolean => content.type === "cmsB
const createMaxTextBlocksExtension = (maxTextBlocks: number) =>
Extension.create({
name: "maxTextBlocks",
dispatchTransaction({ transaction, next }) {
if (transaction.doc.childCount >= maxTextBlocks) {
transaction.setMeta(skipTrailingNodeMeta, true);
}
next(transaction);
},
addKeyboardShortcuts() {
return {
Enter: ({ editor }) => {
Expand Down Expand Up @@ -448,7 +455,7 @@ function collectLinkMarksData(content: JSONContent): unknown[] {
return results;
}

function buildTipTapExtensions({
export function buildTipTapExtensions({
resolvedOptions,
inlineStyles,
placeholders,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function createTextBlock({

return Node.create({
name: "textBlock",
priority: 1000,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
group: "block",
content: "inline*",
defining: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { getSchema } from "@tiptap/core";
import { validate } from "class-validator";
import { describe, expect, it } from "vitest";

import { ExternalLinkBlock } from "../externalLink/external-link.block";
import { createLinkBlock } from "../factories/createLinkBlock";
import {
buildExtensions,
createTipTapRichTextBlock,
type CreateTipTapRichTextBlockOptions,
resolveTipTapOptions,
Expand Down Expand Up @@ -44,6 +46,25 @@ const onlyFeatures = (options: CreateTipTapRichTextBlockOptions = {}): CreateTip
...options,
});

describe("createTipTapRichTextBlock schema", () => {
it.each([
["default text blocks", {}],
["heading-only text blocks", { textBlocks: headingOnly234 }],
])("should use the text block as the default block type with %s, like the Admin's schema", (_name, options) => {
const schema = getSchema(
buildExtensions({
resolvedOptions: resolveTipTapOptions(options),
inlineStyles: [],
placeholders: [],
hasBlockChildBlocks: true,
hasInlineChildBlocks: false,
}),
);

expect(schema.topNodeType.contentMatch.defaultType?.name).toBe("textBlock");
});
});

describe("createTipTapRichTextBlock validation", () => {
describe("default schema (default features)", () => {
const block = createTipTapRichTextBlock({}, "TestDefault");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export function resolveTipTapOptions({
};
}

function buildExtensions({
export function buildExtensions({
resolvedOptions,
inlineStyles,
placeholders,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function createTextBlock({
}) {
return Node.create({
name: "textBlock",
priority: 1000,
group: "block",
content: "inline*",
defining: true,
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading