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

## [Unreleased]

### Fixed

- 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.0] - 2026-09-29

### Added
Expand Down
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 @@ -76,6 +76,10 @@ export interface EditorProps extends Pick<
className?: string;
toolbarGroups?: ToolbarGroupsConfig;
style?: CSSProperties;
/** Font family shown in the toolbar dropdown when the selection has none. Not applied to the content. */
defaultFontFamily?: string;
/** Font size shown in the toolbar dropdown when the selection has none. Not applied to the content. */
defaultFontSize?: string;
}

export interface EditorHandle {
Expand All @@ -95,6 +99,8 @@ interface EditorInnerProps extends Pick<
className?: string;
toolbarGroups?: ToolbarGroupsConfig;
style?: CSSProperties;
defaultFontFamily?: string;
defaultFontSize?: string;
}

function EditorInner({
Expand All @@ -107,7 +113,9 @@ function EditorInner({
advancedConfig,
customFonts,
helpButton,
style
style,
defaultFontFamily,
defaultFontSize
}: EditorInnerProps): ReactElement {
const { editor, codeViewState, codeViewDispatch } = useCurrentEditor();
const t = useT();
Expand Down Expand Up @@ -165,6 +173,8 @@ function EditorInner({
toolbarGroups={toolbarGroups}
advancedConfig={advancedConfig}
customFonts={customFonts}
defaultFontFamily={defaultFontFamily}
defaultFontSize={defaultFontSize}
helpButton={helpButton}
></Toolbar>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ function EditorWrapper(props: EditorWrapperProps): ReactElement {
enableStatusBar,
statusBarContent,
customFonts,
defaultFontFamily,
defaultFontSize,
toolbarLocation,
readOnlyStyle,
onFocus,
Expand Down Expand Up @@ -143,6 +145,10 @@ function EditorWrapper(props: EditorWrapperProps): ReactElement {
helpButton={helpButton}
advancedConfig={advancedConfig}
customFonts={customFonts}
defaultFontFamily={
defaultFontFamily?.status === "available" ? defaultFontFamily.value : undefined
}
defaultFontSize={defaultFontSize?.status === "available" ? defaultFontSize.value : undefined}
toolbarLocation={
stringAttribute.readOnly && readOnlyStyle !== "text" ? "hide" : toolbarLocation
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ interface ToolbarProps extends PropsWithChildren {
toolbarGroups?: ToolbarGroupsConfig;
advancedConfig?: AdvancedConfigType[];
customFonts?: CustomFontsType[];
defaultFontFamily?: string;
defaultFontSize?: string;
helpButton?: boolean;
}

Expand Down Expand Up @@ -162,16 +164,29 @@ function ToolbarRowCode(): ReactElement {
}

export default function Toolbar(props: ToolbarProps): ReactElement | null {
const { preset = "basic", toolbarConfig, toolbarGroups, advancedConfig, customFonts, helpButton } = props;
const {
preset = "basic",
toolbarConfig,
toolbarGroups,
advancedConfig,
customFonts,
defaultFontFamily,
defaultFontSize,
helpButton
} = props;
const { editor, codeViewState } = useCurrentEditor();
const [activeDropdown, setActiveDropdown] = useState<DropdownCommand | null>(null);
const [pendingImageDialogFiles, setPendingImageDialogFiles] = useState<File[]>([]);
const [imageDialogRequestId, setImageDialogRequestId] = useState(0);

// Filter toolbar groups based on preset and custom configuration
const filteredGroups = useMemo(
() => getFilteredToolbarGroups(preset, toolbarConfig, toolbarGroups, advancedConfig, customFonts),
[preset, toolbarConfig, toolbarGroups, advancedConfig, customFonts]
() =>
getFilteredToolbarGroups(preset, toolbarConfig, toolbarGroups, advancedConfig, customFonts, {
fontFamily: defaultFontFamily,
fontSize: defaultFontSize
}),
[preset, toolbarConfig, toolbarGroups, advancedConfig, customFonts, defaultFontFamily, defaultFontSize]
);

// Help button is only shown when the full set of toolbar groups is present
Expand Down
Loading
Loading