Skip to content
Open
16 changes: 16 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,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Fixed

- We fixed an issue where text color, font family and font size were lost after saving and reopening content when the style data format was set to "Class".

- We fixed an issue where the On change action ran when leaving the editor without changing its content.

- We fixed an issue where pressing Escape in a text or number field of a configuration dropdown, such as the table configuration, saved the typed value instead of discarding it.

- We fixed an issue where the character count in the status bar included trailing empty lines and spaces.

- We fixed an issue where embedding a Google Maps link as a video produced an invalid embed address.

- We fixed an issue where the "Enable spell checking" setting had no effect.

- We fixed an issue where the "Default font family" and "Default font size" settings did not set the initial value of the font family and font size toolbar options.

## [5.1.1] - 2026-10-15

### Fixed
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -406,4 +406,15 @@ describe("ConfigurationDropdown Enter/Escape", () => {

expect(document.activeElement).not.toBe(input);
});

it("Escape discards the draft without committing it", () => {
const input = document.querySelector(".configuration-input") as HTMLInputElement;
input.focus();
fireEvent.change(input, { target: { value: "42" } });

fireEvent.keyDown(input, { key: "Escape" });

expect(onChange).not.toHaveBeenCalled();
expect(input.value).toBe("10");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import "@testing-library/jest-dom";
import { act, render } from "@testing-library/react";
import { Editor } from "@tiptap/core";
import { DynamicValue } from "mendix";
import { dynamic, EditableValueBuilder } from "@mendix/widget-plugin-test-utils";
import { RichTextContainerProps, StatusBarContentEnum } from "../../typings/RichTextProps";
import RichText from "../RichText";

const plainValue = `<p>Plain text</p>`;

describe("Rich Text default font family and size", () => {
let defaultProps: RichTextContainerProps;

beforeEach(() => {
defaultProps = {
name: "RichText",
id: "RichText1",
stringAttribute: new EditableValueBuilder<string>().withValue(plainValue).build(),
// The font family / size dropdowns live in the "fontColor" group (preset "standard" and up).
preset: "standard",
toolbarLocation: "top",
widthUnit: "percentage",
width: 100,
heightUnit: "percentageOfWidth",
height: 75,
toolbarConfig: "basic",
history: true,
fontStyle: true,
fontScript: true,
fontColor: true,
code: true,
indent: true,
embed: true,
align: true,
list: true,
remove: true,
header: true,
view: true,
tableBetter: false,
helpButton: true,
advancedConfig: [],
readOnlyStyle: "text",
tabIndex: 0,
onChangeType: "onLeave",
enableStatusBar: false,
dialogStyle: "inline",
statusBarContent: "wordCount" as StatusBarContentEnum,
spellCheck: true,
minHeightUnit: "none",
maxHeightUnit: "none",
maxHeight: 0,
minHeight: 75,
OverflowY: "auto",
customFonts: [],
enableDefaultUpload: true,
linkValidation: true,
styleDataFormat: "class"
};
});

function renderWidget(props: Partial<RichTextContainerProps> = {}): ReturnType<typeof render> {
return render(<RichText {...defaultProps} {...props} />);
}

function fontFamilyLabel(container: HTMLElement): string | null | undefined {
return container.querySelector(".toolbar-dropdown-button.fontFamily .dropdown-label")?.textContent;
}

function fontSizeLabel(container: HTMLElement): string | null | undefined {
return container.querySelector(".toolbar-dropdown-button.fontSize .dropdown-label")?.textContent;
}

function getEditor(container: HTMLElement): Editor {
return (container.querySelector(".ProseMirror") as HTMLElement & { editor: Editor }).editor;
}

describe("font family dropdown", () => {
it("shows the placeholder when no default is configured", () => {
const { container } = renderWidget();
expect(fontFamilyLabel(container)).toBe("Font Family");
});

it("shows the configured default when the selection has no font family", () => {
const { container } = renderWidget({ defaultFontFamily: dynamic.available("arial") });
expect(fontFamilyLabel(container)).toBe("Arial");
});

it("matches the configured default by font name", () => {
const { container } = renderWidget({ defaultFontFamily: dynamic.available("Times New Roman") });
expect(fontFamilyLabel(container)).toBe("Times New Roman");
});

it("matches a custom font configured as default", () => {
const { container } = renderWidget({
customFonts: [{ fontName: "Roboto", fontStyle: "roboto, sans-serif" }],
defaultFontFamily: dynamic.available("Roboto")
});
expect(fontFamilyLabel(container)).toBe("Roboto");
});

it("shows the font family of the selection when it has one", () => {
const { container } = renderWidget({
stringAttribute: new EditableValueBuilder<string>()
.withValue(`<p><span style="font-family: impact, sans-serif">Styled</span></p>`)
.build(),
styleDataFormat: "inline",
defaultFontFamily: dynamic.available("arial")
});
expect(fontFamilyLabel(container)).toBe("Impact");
});

it.each<[string, DynamicValue<string>]>([
["loading", dynamic.loading("arial")],
["unavailable", dynamic.unavailable()],
["empty", dynamic.available("")],
["unknown", dynamic.available("no-such-font")],
["unsafe", dynamic.available("arial; color: red")]
])("shows the placeholder when the default is %s", (_, value) => {
const { container } = renderWidget({ defaultFontFamily: value });
expect(fontFamilyLabel(container)).toBe("Font Family");
});

it("follows changes of the configured default", () => {
const { container, rerender } = renderWidget({ defaultFontFamily: dynamic.available("arial") });
rerender(<RichText {...defaultProps} defaultFontFamily={dynamic.available("impact")} />);
expect(fontFamilyLabel(container)).toBe("Impact");
});
});

describe("font size dropdown", () => {
it("keeps the current fallback when no default is configured", () => {
const { container } = renderWidget();
expect(fontSizeLabel(container)).toBe("14px");
});

it("shows the configured default when the selection has no font size", () => {
const { container } = renderWidget({ defaultFontSize: dynamic.available("20px") });
expect(fontSizeLabel(container)).toBe("20px");
});

it("normalizes a bare number to px", () => {
const { container } = renderWidget({ defaultFontSize: dynamic.available("24") });
expect(fontSizeLabel(container)).toBe("24px");
});

it("shows the font size of the selection when it has one", () => {
const { container } = renderWidget({
stringAttribute: new EditableValueBuilder<string>()
.withValue(`<p><span style="font-size: 32px">Styled</span></p>`)
.build(),
styleDataFormat: "inline",
defaultFontSize: dynamic.available("20px")
});
expect(fontSizeLabel(container)).toBe("32px");
});

it.each<[string, DynamicValue<string>]>([
["loading", dynamic.loading("20px")],
["unavailable", dynamic.unavailable()],
["empty", dynamic.available("")],
["unsafe", dynamic.available("20px; color: red")]
])("keeps the current fallback when the default is %s", (_, value) => {
const { container } = renderWidget({ defaultFontSize: value });
expect(fontSizeLabel(container)).toBe("14px");
});
});

describe("editor content", () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

function renderAndFlush(props: Partial<RichTextContainerProps>): {
container: HTMLElement;
stringAttribute: RichTextContainerProps["stringAttribute"];
} {
const stringAttribute = new EditableValueBuilder<string>().withValue(plainValue).build();
const { container } = renderWidget({ stringAttribute, ...props });
act(() => {
jest.advanceTimersByTime(500);
});
return { container, stringAttribute };
}

it("does not style the editor or change the saved value", () => {
const baseline = renderAndFlush({});
const baselineHtml = getEditor(baseline.container).getHTML();
baseline.container.remove();

const setAttribute = jest.spyOn(Element.prototype, "setAttribute");
const { container, stringAttribute } = renderAndFlush({
defaultFontFamily: dynamic.available("arial"),
defaultFontSize: dynamic.available("20px")
});

const dom = container.querySelector(".ProseMirror") as HTMLElement;
expect(dom.style.fontFamily).toBe("");
expect(dom.style.fontSize).toBe("");
expect(setAttribute.mock.calls.some(([name, value]) => name === "style" && /font/i.test(value))).toBe(
false
);
expect(getEditor(container).getHTML()).toBe(baselineHtml);
expect(stringAttribute.setValue).not.toHaveBeenCalled();

setAttribute.mockRestore();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,35 @@ describe("Rich Text runtime behaviour", () => {
expect(onChange.execute).toHaveBeenCalledTimes(1);
});

it("does not execute onChange on blur without an edit when onChangeType is onLeave", () => {
const onChange = actionValue();
const onBlur = actionValue();
const { container } = render(
<RichText {...defaultProps} onChangeType="onLeave" onChange={onChange} onBlur={onBlur} />
);
flush();

fireEvent.focus(getEditorDom(container));
fireEvent.blur(getEditorDom(container));

expect(onChange.execute).not.toHaveBeenCalled();
expect(onBlur.execute).toHaveBeenCalledTimes(1);
});

it("executes onChange on leave once per edit when onChangeType is onLeave", () => {
const onChange = actionValue();
const { container } = render(<RichText {...defaultProps} onChangeType="onLeave" onChange={onChange} />);

fireEvent.focus(getEditorDom(container));
edit(container, "<p>edited</p>");
fireEvent.blur(getEditorDom(container));
expect(onChange.execute).toHaveBeenCalledTimes(1);

fireEvent.focus(getEditorDom(container));
fireEvent.blur(getEditorDom(container));
expect(onChange.execute).toHaveBeenCalledTimes(1);
});

it("executes onChange on edit and not on blur when onChangeType is onDataChange", () => {
const onChange = actionValue();
const { container } = render(
Expand Down Expand Up @@ -176,6 +205,7 @@ describe("Rich Text runtime behaviour", () => {

rerender(<RichText {...defaultProps} onChangeType="onLeave" {...second} />);
fireEvent.focus(getEditorDom(container));
edit(container, "<p>edited</p>");
fireEvent.blur(getEditorDom(container));

expect(second.onFocus.execute).toHaveBeenCalledTimes(1);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import "@testing-library/jest-dom";
import { render } from "@testing-library/react";
import { EditableValueBuilder } from "@mendix/widget-plugin-test-utils";
import { RichTextContainerProps, StatusBarContentEnum } from "../../typings/RichTextProps";
import RichText from "../RichText";

describe("Rich Text spell checking", () => {
let defaultProps: RichTextContainerProps;
beforeEach(() => {
defaultProps = {
name: "RichText",
id: "RichText1",
stringAttribute: new EditableValueBuilder<string>().withValue("<p>Rich text</p>").build(),
preset: "basic",
toolbarLocation: "bottom",
widthUnit: "percentage",
width: 100,
heightUnit: "percentageOfWidth",
height: 75,
toolbarConfig: "basic",
history: true,
fontStyle: true,
fontScript: true,
fontColor: true,
code: true,
indent: true,
embed: true,
align: true,
list: true,
remove: true,
header: true,
view: true,
tableBetter: false,
helpButton: true,
advancedConfig: [],
readOnlyStyle: "text",
tabIndex: 0,
onChangeType: "onLeave",
enableStatusBar: false,
dialogStyle: "inline",
statusBarContent: "wordCount" as StatusBarContentEnum,
spellCheck: false,
minHeightUnit: "none",
maxHeightUnit: "none",
maxHeight: 0,
minHeight: 75,
OverflowY: "auto",
customFonts: [],
enableDefaultUpload: true,
linkValidation: true,
styleDataFormat: "inline"
};
});

const getEditable = (container: HTMLElement): HTMLElement => {
const editable = container.querySelector<HTMLElement>(".ProseMirror");
expect(editable).not.toBeNull();
return editable!;
};

it("enables spell checking on the editable area when spellCheck is true", () => {
const { container } = render(<RichText {...defaultProps} spellCheck />);
expect(getEditable(container)).toHaveAttribute("spellcheck", "true");
});

it("disables spell checking on the editable area when spellCheck is false", () => {
const { container } = render(<RichText {...defaultProps} spellCheck={false} />);
expect(getEditable(container)).toHaveAttribute("spellcheck", "false");
});

it("follows spellCheck changes after the editor is created", () => {
const { container, rerender } = render(<RichText {...defaultProps} spellCheck={false} />);
expect(getEditable(container)).toHaveAttribute("spellcheck", "false");

rerender(<RichText {...defaultProps} spellCheck />);
expect(getEditable(container)).toHaveAttribute("spellcheck", "true");
});
});
Loading
Loading