Skip to content
Open
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 packages/pluggableWidgets/rich-text-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Added

- We added new configuration to allow users to use class names instead of inline styling in generated HTML to support strict CSP.

### Fixed

- We fixed an issue where the editor pasting back the whole sentence instead of the single copied word

### Changed

- We removed codemirror from code dialog viewer due to unsupported strict CSP policy. A simple internally built code editor using highlightjs is now replacing it.

## [4.12.0] - 2026-04-22

### Added
Expand Down
32 changes: 32 additions & 0 deletions packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { expect, test } from "@playwright/test";

test.afterEach("Cleanup session", async ({ page }) => {
Expand All @@ -6,6 +6,7 @@
});

test.describe("RichText", () => {
test.describe.configure({ mode: "serial" });
test("compares with a screenshot baseline and checks if inline basic mode are rendered as expected", async ({
page
}) => {
Expand Down Expand Up @@ -133,6 +134,37 @@
});
});

test("compares with a screenshot baseline and checks if class mode editor is rendered as expected", async ({
page
}) => {
await page.goto("/p/classmode");
await page.waitForLoadState("networkidle");
await expect(page.locator(".mx-name-richText1")).toBeVisible();
await expect(page.locator(".mx-name-richText1")).toHaveScreenshot(`classModeEditor.png`, { threshold: 0.4 });
});

test("checks that class mode editor output uses CSS classes instead of inline styles", async ({ page }) => {
await page.goto("/p/classmode");
await page.waitForLoadState("networkidle");
const html = await page.locator(".mx-name-richText1 .ql-editor").innerHTML();
expect(html).toMatch(/class="ql-color-/);
expect(html).toMatch(/class="ql-bg-/);
expect(html).toMatch(/class="ql-indent-/);
expect(html).toMatch(/data-style-format="class"/);
expect(html).not.toMatch(/style="color:/);
expect(html).not.toMatch(/style="background-color:/);
expect(html).not.toMatch(/style="padding-left:/);
});

test("compares with a screenshot baseline of the View/Edit Code dialog in class mode", async ({ page }) => {
await page.goto("/p/classmode");
await page.waitForLoadState("networkidle");
await page.click(".mx-name-richText1 .ql-toolbar button.ql-view-code");
await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot(
`classModeViewCodeDialog.png`
);
});

test("compares with a screenshot for rich text inside modal popup layout", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 6 additions & 8 deletions packages/pluggableWidgets/rich-text-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@
},
"testProject": {
"githubUrl": "https://github.com/mendix/testProjects",
"branchName": "rich-text-v4-web"
"branchName": "rich-text-v4-web-v2"
},
"scripts": {
"build": "cross-env MPKOUTPUT=RichText.mpk pluggable-widgets-tools build:web",
"create-gh-release": "rui-create-gh-release",
"create-translation": "rui-create-translation",
"dev": "cross-env MPKOUTPUT=RichText.mpk pluggable-widgets-tools start:web",
"e2e": "run-e2e ci",
"e2edev": "run-e2e dev --with-preps",
"e2e": "MENDIX_VERSION=11.9.1 run-e2e ci",
"e2edev": "MENDIX_VERSION=11.9.1 run-e2e dev --with-preps",
"format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .",
"lint": "eslint src/ package.json",
"publish-marketplace": "rui-publish-marketplace",
Expand All @@ -43,21 +43,19 @@
"verify": "rui-verify-package-format"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.9",
"@codemirror/state": "^6.5.2",
"@floating-ui/dom": "^1.7.4",
"@floating-ui/react": "^0.26.27",
"@melloware/coloris": "^0.25.0",
"@uiw/codemirror-theme-github": "^4.23.13",
"@uiw/react-codemirror": "^4.23.13",
"classnames": "^2.5.1",
"highlight.js": "^11.11.1",
"js-beautify": "^1.15.4",
"katex": "^0.16.22",
"linkifyjs": "^4.3.2",
"lodash.merge": "^4.6.2",
"parchment": "^3.0.0",
"quill": "^2.0.3",
"quill-resize-module": "^2.0.4"
"quill-resize-module": "^2.0.4",
"react-scroll-sync": "^1.0.2"
},
"devDependencies": {
"@mendix/automation-utils": "workspace:*",
Expand Down
8 changes: 8 additions & 0 deletions packages/pluggableWidgets/rich-text-web/src/RichText.xml
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@
<enumerationValue key="characterCountHtml">Character count (including HTML)</enumerationValue>
</enumerationValues>
</property>
<property key="styleDataFormat" type="enumeration" defaultValue="inline">
<caption>Style data format</caption>
<description>Choose how to render styling attribute in HTML</description>
<enumerationValues>
<enumerationValue key="inline">inline</enumerationValue>
<enumerationValue key="class">class</enumerationValue>
</enumerationValues>
</property>
</propertyGroup>
</propertyGroup>
<propertyGroup caption="Custom toolbar">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ describe("Rich Text", () => {
customFonts: [],
enableDefaultUpload: true,
formOrientation: "vertical",
linkValidation: true
linkValidation: true,
styleDataFormat: "inline"
};
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { CustomListItem, CustomListItemClass, STANDARD_LIST_TYPES } from "../utils/formats/customList";

// CustomListItem and CustomListItemClass extend Quill's ListItem blot.
// We test only the static helpers and the constructor-level DOM mutation,
// which do not require a live Quill / Scroll instance.

function makeListNode(listType = "ordered"): HTMLElement {
const li = document.createElement("li");
li.dataset.list = listType;
return li;
}

describe("STANDARD_LIST_TYPES", () => {
it("contains exactly the four standard types", () => {
expect(STANDARD_LIST_TYPES).toEqual(["ordered", "checked", "unchecked", "bullet"]);
});
});

describe("CustomListItem.formats", () => {
it("returns data-list value for standard list types", () => {
const node = makeListNode("ordered");
expect(CustomListItem.formats(node)).toBe("ordered");
});

it("prefers data-custom-list over data-list when both are present", () => {
const node = makeListNode("ordered");
node.dataset.customList = "lower-alpha";
expect(CustomListItem.formats(node)).toBe("lower-alpha");
});

it("returns undefined when neither attribute is present", () => {
const node = document.createElement("li");
expect(CustomListItem.formats(node)).toBeUndefined();
});
});

describe("CustomListItemClass — styleFormat marker contract", () => {
// CustomListItemClass constructor assigns domNode.dataset.styleFormat = "class".
// Instantiating it requires a live Quill Scroll instance (a Quill integration concern),
// so here we verify the contract at the class-definition level and the DOM-mutation logic
// in isolation.

it("is a subclass of CustomListItem", () => {
expect(Object.getPrototypeOf(CustomListItemClass)).toBe(CustomListItem);
});

it("the styleFormat marker 'class' round-trips correctly on a DOM node (logic under test)", () => {
// This mirrors exactly what the constructor body does:
// domNode.dataset.styleFormat = "class";
const node = makeListNode("ordered");
node.dataset.styleFormat = "class";
expect(node.dataset.styleFormat).toBe("class");
});

it("inline-mode list nodes do NOT have a styleFormat marker by default", () => {
const node = makeListNode("ordered");
expect(node.dataset.styleFormat).toBeUndefined();
});
});
148 changes: 148 additions & 0 deletions packages/pluggableWidgets/rich-text-web/src/__tests__/fonts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { FONT_LIST, FontClassAttributor, FontStyleAttributor, formatCustomFonts } from "../utils/formats/fonts";

// parchment ClassAttributor and StyleAttributor operate directly on HTMLElement nodes —
// no Quill instance is needed for unit-level attribute tests.

function makeSpan(): HTMLElement {
return document.createElement("span");
}

// FontStyleAttributor --------------------------------------------------------

describe("FontStyleAttributor", () => {
let attr: FontStyleAttributor;

beforeEach(() => {
attr = new FontStyleAttributor([]);
});

it("adds font-family style for a known font value", () => {
const node = makeSpan();
const result = attr.add(node, "arial");
expect(result).toBe(true);
expect(node.style.fontFamily).toMatch(/arial/i);
expect(node.dataset.value).toBe("arial");
});

it("returns false for an unknown font value", () => {
const node = makeSpan();
const result = attr.add(node, "not-a-real-font");
expect(result).toBe(false);
expect(node.style.fontFamily).toBe("");
});

it("reads back the value via dataset.value", () => {
const node = makeSpan();
attr.add(node, "courier-new");
expect(attr.value(node)).toBe("courier-new");
});

it("returns empty string for a node with no dataset.value", () => {
const node = makeSpan();
expect(attr.value(node)).toBe("");
});

it("applies custom fonts passed to the constructor", () => {
const custom = new FontStyleAttributor([
{ value: "my-font", description: "My Font", style: "MyFont, sans-serif" }
]);
const node = makeSpan();
expect(custom.add(node, "my-font")).toBe(true);
expect(node.style.fontFamily).toMatch(/MyFont/i);
});

it("FONT_LIST contains all 13 fonts including serif", () => {
const values = FONT_LIST.map(f => f.value);
expect(values).toContain("serif");
expect(values).toHaveLength(13);
});
});

// FontClassAttributor --------------------------------------------------------

describe("FontClassAttributor", () => {
let attr: FontClassAttributor;

beforeEach(() => {
attr = new FontClassAttributor([]);
});

it("adds font-family-<value> class for a known font value", () => {
const node = makeSpan();
const result = attr.add(node, "arial");
expect(result).toBe(true);
expect(node.classList.contains("font-family-arial")).toBe(true);
expect(node.dataset.value).toBe("arial");
});

it("returns false for an unknown font value and adds no class", () => {
const node = makeSpan();
const result = attr.add(node, "not-a-real-font");
expect(result).toBe(false);
const hasClass = Array.from(node.classList).some(c => c.startsWith("font-family-"));
expect(hasClass).toBe(false);
});

it("reads back the value via dataset.value", () => {
const node = makeSpan();
attr.add(node, "impact");
expect(attr.value(node)).toBe("impact");
});

it("returns empty string for a node with no dataset.value", () => {
const node = makeSpan();
expect(attr.value(node)).toBe("");
});

it("adds font-family-serif class for the serif font (Critical #3 regression guard)", () => {
const node = makeSpan();
const result = attr.add(node, "serif");
expect(result).toBe(true);
expect(node.classList.contains("font-family-serif")).toBe(true);
});

it("applies custom fonts passed to the constructor", () => {
const custom = new FontClassAttributor([
{ value: "my-font", description: "My Font", style: "MyFont, sans-serif" }
]);
const node = makeSpan();
expect(custom.add(node, "my-font")).toBe(true);
expect(node.classList.contains("font-family-my-font")).toBe(true);
});

it("emits class-based name, not inline style", () => {
const node = makeSpan();
attr.add(node, "helvetica");
expect(node.style.fontFamily).toBe("");
expect(node.classList.contains("font-family-helvetica")).toBe(true);
});
});

// formatCustomFonts ----------------------------------------------------------

describe("formatCustomFonts", () => {
it("maps custom font objects to FONT_LIST shape", () => {
const result = formatCustomFonts([{ fontName: "My Brand Font", fontStyle: "MyBrandFont, sans-serif" }]);
expect(result).toEqual([
{ value: "my-brand-font", description: "My Brand Font", style: "MyBrandFont, sans-serif" }
]);
});

it("lowercases and hyphenates multi-word font names", () => {
const result = formatCustomFonts([{ fontName: "Open Sans", fontStyle: "Open Sans, sans-serif" }]);
expect(result[0].value).toBe("open-sans");
});

it("returns an empty array when called with no arguments", () => {
expect(formatCustomFonts()).toEqual([]);
});

it("returns an empty array for an empty input", () => {
expect(formatCustomFonts([])).toEqual([]);
});

it("handles undefined fontName gracefully", () => {
const result = formatCustomFonts([{ fontName: undefined as any, fontStyle: "serif" }]);
expect(result[0].value).toBe("");
});
});
Loading
Loading