diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md index 2ab0b1c870..c10f34f2a6 100644 --- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md +++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md @@ -10,6 +10,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - We fixed a security vulnerability (CVE-2026-13149). +### Added + +- We added new configuration to allow users to use class names instead of inline styling in generated HTML to support strict CSP. + +- We added a help button to the toolbar that opens a dialog listing the editor's keyboard shortcuts. The button is shown when the full toolbar is enabled and can be turned off with the new "Keyboard shortcuts" setting. + +- We added translations for the toolbar, dialogs, and help texts. The editor UI now follows the page language automatically, with bundled translations for English, Dutch, German, French, and Spanish (falling back to English). + +- We added a clear button to every color picker (font color, font background, and the table and cell background and border color pickers) so a color can be reset to none. + +### Fixed + +- We fixed the image dialog to only show the image source tabs that are available: the Media Library tab now appears only when an image data source is configured, and the Upload tab is hidden when default upload is disabled. + +- We fixed Tiptap duplicate extension warnings for `link`, `textStyle`, `underline`, and `textDirection` by properly configuring StarterKit, removing redundant extension registrations in TextColorClass, memoizing the extensions array to prevent re-registration on component re-renders, and disabling the core TextDirection extension that conflicts with our custom implementation. + +- We fixed an issue where empty editor content was saving as `

` instead of an empty string, which incorrectly passed required field validation. Empty content now correctly saves as `""`, ensuring proper validation behavior. Note: This is a breaking change for forms that were relying on the incorrect behavior - required RichText fields will now correctly reject empty content. + +- We fixed an issue where the editor pasting back the whole sentence instead of the single copied word + +- We fixed an issue where table header cells did not honor background color and border (color, style, width) configuration. Header cells can now be styled the same way as regular cells. + +### Changed + +- We aligned the image dialog's "Upload" tab dropzone with the File Uploader widget's dropzone so both share the same look, states, and messages. + +- 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 diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js index c05860d869..9edf3d3ca6 100644 --- a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js +++ b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js @@ -2,6 +2,7 @@ import { expect, test } from "@mendix/run-e2e/fixtures"; import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers"; 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 }) => { @@ -32,8 +33,8 @@ test.describe("RichText", () => { await expect(page.locator(".mx-name-richText1")).toBeVisible(); await expect(page.locator(".mx-name-richText1")).toHaveScreenshot(`bottomToolbarAdvancedMode.png`); - await page.click(".mx-name-richText1 .ql-toolbar button.ql-image"); - await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot( + await page.click('.mx-name-richText1 .tiptap-toolbar button[title="Insert Image"]'); + await expect(page.locator(".mx-name-richText1 .toolbar-dialog.image-dialog").first()).toHaveScreenshot( `insertImageDialog.png` ); }); @@ -47,8 +48,8 @@ test.describe("RichText", () => { await expect(page.locator(".mx-name-richText4")).toBeVisible(); await expect(page.locator(".mx-name-richText4")).toHaveScreenshot(`toolbarAdvancedMode.png`); - 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( + await page.click('.mx-name-richText4 .tiptap-toolbar button[title="View/Edit Code"]'); + await expect(page.locator(".mx-name-richText4 .highlighted-code-editor").first()).toHaveScreenshot( `viewCodeDialog.png` ); }); @@ -115,23 +116,107 @@ test.describe("RichText", () => { await expect(page.locator(".mx-name-richText6")).toHaveScreenshot(`readOnlyModeReadPanel.png`); }); + test("compares with a screenshot baseline and checks if class mode editor is rendered as expected", async ({ + page + }) => { + await page.goto("/p/classmode"); + await waitForMendixApp(page); + 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 waitForMendixApp(page); + + const editor = page.locator(".mx-name-richText1 .tiptap"); + await expect(editor).toBeVisible(); + + // Apply text color, highlight and indent to the first block so the + // class-based output can be asserted. Re-select before each command + // because clicking a toolbar control collapses the DOM selection. + const firstBlock = editor.locator("h1, h2, h3, p").first(); + + await firstBlock.click({ clickCount: 3 }); + await page.click('.mx-name-richText1 button[title="Text Color"]'); + await page.locator(".color-picker-dropdown div[title]").nth(10).click(); + + await firstBlock.click({ clickCount: 3 }); + await page.click('.mx-name-richText1 button[title="Background Color"]'); + await page.locator(".color-picker-dropdown div[title]").nth(10).click(); + + await firstBlock.click({ clickCount: 3 }); + await page.click('.mx-name-richText1 button[title="Increase Indent"]'); + + const html = await editor.innerHTML(); + + // Class mode emits class + data-* attributes, not inline styles. + expect(html).toMatch(/class="[^"]*has-text-color/); + expect(html).toMatch(/data-text-color="/); + expect(html).toMatch(/class="[^"]*has-text-highlight/); + expect(html).toMatch(/data-text-highlight="/); + expect(html).toMatch(/class="[^"]*indent-\d/); + expect(html).toMatch(/data-indent="/); + 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 waitForMendixApp(page); + await page.click('.mx-name-richText1 .tiptap-toolbar button[title="View/Edit Code"]'); + await expect(page.locator(".mx-name-richText1 .highlighted-code-editor").first()).toHaveScreenshot( + `classModeViewCodeDialog.png` + ); + }); + test("compares with a screenshot for rich text inside modal popup layout", async ({ page }) => { await page.goto("/"); await waitForMendixApp(page); await page.click(".mx-navbar-item [title='Demo']"); - await page.click(".mx-name-customWidget1 .ql-toolbar button.ql-video"); - await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot( + await page.click('.mx-name-customWidget1 .tiptap-toolbar button[title="Insert YouTube Video"]'); + await expect(page.locator(".toolbar-dialog.video-dialog").first()).toHaveScreenshot( `richTextDialogInsidePopup.png` ); - await page.click(".widget-rich-text .widget-rich-text-modal-body #rich-text-video-src-input"); - await page - .locator(".widget-rich-text .widget-rich-text-modal-body #rich-text-video-src-input") - .fill("https://www.mendix.com"); - await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot( + await page.locator(".toolbar-dialog.video-dialog #video-url").fill("https://www.mendix.com"); + await expect(page.locator(".toolbar-dialog.video-dialog").first()).toHaveScreenshot( `richTextDialogInsidePopupEdit.png` ); }); + + test("clearing all content leaves the editor empty, not a stray

", async ({ page }) => { + await page.goto("/"); + await waitForMendixApp(page); + await page.click("text=Generate Data"); + await page.goto("/p/basic"); + await waitForMendixApp(page); + + // Find the first editable rich text editor + const editor = page.locator(".mx-name-richText1 .tiptap"); + await editor.scrollIntoViewIfNeeded(); + await expect(editor).toBeVisible(); + + // Click into the editor and clear all content. selectText() reliably + // selects the editor contents across platforms, unlike Control+A which + // depends on OS focus behaviour. + await editor.click(); + await editor.selectText(); + await page.keyboard.press("Backspace"); + + // Blur the editor to trigger the save/normalize path. + await page.keyboard.press("Tab"); + await page.waitForTimeout(500); + + // The editor should now be empty. Tiptap keeps a placeholder paragraph + // in the DOM, but the widget normalizes that empty paragraph to an + // empty string on save (see normalizeEmpty in EditorWrapper). + expect((await editor.textContent())?.trim() || "").toBe(""); + // No text nodes remain — only an empty placeholder paragraph/break. + const strippedText = (await editor.innerHTML()).replace(/<[^>]*>/g, "").trim(); + expect(strippedText).toBe(""); + }); }); diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/inlineBasicMode-chromium-linux.png b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/inlineBasicMode-chromium-linux.png index 9fe9cee055..2581241d11 100644 Binary files a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/inlineBasicMode-chromium-linux.png and b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/inlineBasicMode-chromium-linux.png differ diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/toolbarBasicMode-chromium-linux.png b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/toolbarBasicMode-chromium-linux.png index 1f2effedfc..508576582d 100644 Binary files a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/toolbarBasicMode-chromium-linux.png and b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/toolbarBasicMode-chromium-linux.png differ diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/.openspec.yaml b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/.openspec.yaml new file mode 100644 index 0000000000..c53ef21aaa --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-05 diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/design.md new file mode 100644 index 0000000000..b1909f37d3 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/design.md @@ -0,0 +1,186 @@ +## Context + +The rich text editor uses TipTap, which already supports column widths via the `colwidth` attribute on table cells. TipTap's base `TableCell` extension includes this attribute, and the `TableBackgroundColor` extension reads it to generate `` elements for column sizing. + +Currently: + +- `colwidth` is an array of pixel values (e.g., `[150]` for single column, `[100, 150, 200]` for colspan) +- TipTap has a `resizable: true` option that should enable drag-to-resize, but the custom `TableBackgroundColorNodeView` doesn't implement resize handles +- Users can click column borders, which sets a width, but cannot drag to adjust +- No UI control exists to set exact pixel widths + +The existing cell configuration dropdown already has sections for background color, border color/style/width using a `ConfigurationSection` pattern. + +## Goals / Non-Goals + +**Goals:** + +- Add a number input control to the cell configuration dropdown for setting column width +- Support exact pixel values (25-1000px range) +- Provide clear button to reset to auto width +- Handle colspan cells by setting only the first column width +- Follow existing configuration dropdown patterns and styling +- Validate input and clamp to acceptable ranges + +**Non-Goals:** + +- Implementing drag-to-resize handles (would require significant NodeView work) +- Supporting percentage or other CSS units (only pixels for now) +- Setting widths for all columns in a colspan cell (only first column) +- Syncing widths across all rows (only first row widths apply per TipTap's design) +- Adding column width presets dropdown (keep it simple with number input only) + +## Decisions + +### Decision 1: Use number input type (not dropdown with presets) + +**Rationale:** User requested "exact pixel values" in requirements. A number input provides precision without limiting users to preset sizes. + +**Alternatives considered:** + +- Dropdown with presets (Small/Medium/Large): Too restrictive, doesn't meet "exact values" requirement +- Hybrid (input + preset buttons): Over-engineered for initial implementation +- Text input: Number input provides better UX with built-in increment/decrement and validation + +**Implementation:** Add new `"numberInput"` type to `ConfigurationSection` interface alongside existing `"colorPicker"` and `"dropdown"` types. + +--- + +### Decision 2: Set only first column width for colspan cells + +**Rationale:** + +- User explicitly requested "set only first column" in requirements +- Simplifies implementation and UX (no need for multi-column width editor) +- Matches TipTap's array-based `colwidth` structure where `colwidth[0]` is the first column + +**Alternatives considered:** + +- Set all spanned columns to same width: Could surprise users if they had different widths set +- Show per-column width array editor: Complex UI for edge case (most cells don't have colspan) +- Disable control for colspan cells: Too restrictive + +**Implementation:** `onChange` handler creates `colwidth` array with single value: `[width]` + +--- + +### Decision 3: Use setCellAttribute command (not custom command) + +**Rationale:** + +- `setCellAttribute("colwidth", value)` should work because `colwidth` is in TipTap's base TableCell +- Other cell properties (backgroundColor, borderColor, etc.) use `setCellAttribute` successfully +- No need to define custom commands in extensions + +**Alternatives considered:** + +- Custom `setCellWidth` command in `TableCellBackgroundColor`: Unnecessary abstraction +- Modify `TableCellBackgroundColor.addAttributes()` to re-declare colwidth: Already inherits via `...this.parent?.()` + +**Implementation:** Direct call to `editor.chain().focus().setCellAttribute("colwidth", [width]).run()` + +--- + +### Decision 4: Render clear button conditionally (when value exists) + +**Rationale:** + +- Provides explicit way to reset to auto width (null) +- Visual indicator that a custom width is set +- Follows common UI pattern (seen in search inputs, etc.) + +**Alternatives considered:** + +- Always show clear button: Clutters UI when empty +- Use empty string to mean auto: Less explicit, could be confusing +- Add separate "Auto" button: Redundant with clear functionality + +**Implementation:** Render `×` button only when `currentValue !== null` + +--- + +### Decision 5: Store null for auto width (not empty array or zero) + +**Rationale:** + +- TipTap treats `colwidth: null` as auto-sizing behavior +- Consistent with how TipTap's base extensions work +- `colwidth: []` or `colwidth: [0]` could cause layout issues + +**Implementation:** When user clears input, call `setCellAttribute("colwidth", null)` + +## Risks / Trade-offs + +### Risk: Parent attribute inheritance might not work + +If `TableCellBackgroundColor` doesn't properly inherit `colwidth` from base `TableCell`, `setCellAttribute` calls will fail silently. + +**Mitigation:** The extension already uses `...this.parent?.()` in `addAttributes()`, which should preserve `colwidth`. If issues arise, explicitly re-declare `colwidth` in the extension's attributes. + +--- + +### Risk: First-row-only behavior might confuse users + +TipTap only uses `colwidth` from the first row of the table to generate ``. Setting widths on other rows has no effect. + +**Mitigation:** Could add tooltip or helper text: "Note: Only first row column widths apply." For initial implementation, leave as-is since it matches TipTap's inherent behavior. + +--- + +### Risk: Custom NodeView might interfere with colwidth updates + +The `TableBackgroundColorNodeView` manually generates `` in `updateColgroup()`. If it's not reactive to `colwidth` changes, widths might not update visually. + +**Mitigation:** The NodeView's `update()` method already calls `updateColgroup()` on node changes, so it should react to `colwidth` updates. Test thoroughly during implementation. + +--- + +### Trade-off: No drag-to-resize (only manual input) + +Users lose the convenience of dragging column borders to resize. + +**Mitigation:** The number input provides precision that drag handles lack. If drag-to-resize is needed later, it would require integrating ProseMirror's `columnResizing` plugin into the custom NodeView (~200 lines of code). For now, manual input meets the stated requirements. + +--- + +### Trade-off: Pixel-only units (no percentages) + +Users cannot set responsive column widths using percentages. + +**Mitigation:** Percentage support would require custom rendering logic (TipTap's `colwidth` is pixel-only). Tables in rich text editors typically use fixed layouts. Can be added later if users request it. + +## Migration Plan + +No migration needed. This is a purely additive feature: + +- Existing tables without `colwidth` continue to work (auto width) +- Existing tables with `colwidth` set (via TipTap's base functionality) display correctly +- No data model changes or breaking API changes + +Deployment: + +1. Merge code changes +2. Run build +3. Deploy widget to Mendix project +4. Feature is immediately available in cell configuration dropdown + +Rollback: + +- If issues arise, remove the new configuration section from `createCellConfigurationSections()` +- No data corruption risk (colwidth attribute is standard TipTap) + +## Open Questions + +1. **Should we show the actual rendered column width vs. the set value?** + - The set `colwidth` might differ from actual rendered width due to table layout constraints + - For initial implementation: Show the set value only (simpler) + - Can add "measured width" tooltip later if needed + +2. **Should we prevent setting widths on non-first-row cells?** + - Pro: Avoids confusion about why widths don't apply + - Con: Restricts user control, might not match mental model + - Decision: Allow setting on any cell (simpler), rely on TipTap's first-row behavior + +3. **Should we add keyboard shortcuts for common widths?** + - Out of scope for initial implementation + - Can be added later if users request it diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/proposal.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/proposal.md new file mode 100644 index 0000000000..282fd1302a --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/proposal.md @@ -0,0 +1,41 @@ +## Why + +Users need precise control over table column widths in the rich text editor. Currently, while TipTap supports column widths via the `colwidth` attribute, there's no UI control to set them. Users can only click column borders which sets widths without drag-to-resize capability, making it difficult to achieve desired layouts. + +## What Changes + +- Add a "Column Width" number input control to the cell configuration dropdown +- Support setting exact pixel widths (25-1000px range) +- Provide "Clear" button to reset to auto width +- Handle colspan cells by setting only the first column width +- Integrate with existing `setCellAttribute` command for `colwidth` attribute + +## Capabilities + +### New Capabilities + +- `table-column-width-control`: UI control in cell configuration dropdown allowing users to set precise column widths in pixels, with validation, auto width support, and colspan handling + +### Modified Capabilities + + + +## Impact + +**Affected Files:** + +- `src/components/toolbars/components/ConfigurationDropdown.tsx` - Add number input type support +- `src/components/toolbars/components/ConfigurationDropdown.scss` - Add number input styling +- `src/components/toolbars/ToolbarConfig.ts` - Update ConfigurationSection type definition +- `src/components/toolbars/helpers/configurationHelpers.ts` - Add column width section + +**User Impact:** + +- Positive: Users gain precise control over table column widths via familiar number input UI +- No breaking changes: Feature is additive, existing tables continue to work + +**Technical Impact:** + +- Uses existing TipTap `colwidth` attribute mechanism (no changes to data model) +- No impact on existing table resize behavior (custom NodeView remains unchanged) +- Follows established configuration dropdown patterns diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/specs/table-column-width-control/spec.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/specs/table-column-width-control/spec.md new file mode 100644 index 0000000000..1bafe2051b --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/specs/table-column-width-control/spec.md @@ -0,0 +1,102 @@ +## ADDED Requirements + +### Requirement: Column width input control + +The cell configuration dropdown SHALL include a number input control labeled "Column Width" that allows users to set the column width in pixels. + +#### Scenario: Opening cell configuration shows current width + +- **WHEN** user selects a table cell and opens the cell configuration dropdown +- **THEN** the "Column Width" input displays the current column width value if set, or shows empty with "Auto" placeholder if width is auto + +#### Scenario: Setting a valid column width + +- **WHEN** user enters a numeric value between 25 and 1000 in the "Column Width" input +- **THEN** the column width is set to that exact pixel value and the column resizes accordingly + +#### Scenario: Clearing column width for auto sizing + +- **WHEN** user clicks the clear button or deletes the value from the "Column Width" input +- **THEN** the column width is reset to auto (null) and the column resizes to fit content + +### Requirement: Column width validation + +The system SHALL validate column width input to ensure values are within acceptable ranges. + +#### Scenario: Entering value below minimum + +- **WHEN** user enters a value less than 25 pixels +- **THEN** the system clamps the value to 25 pixels + +#### Scenario: Entering value above maximum + +- **WHEN** user enters a value greater than 1000 pixels +- **THEN** the system clamps the value to 1000 pixels + +#### Scenario: Entering invalid input + +- **WHEN** user enters non-numeric text +- **THEN** the system ignores the input and retains the previous value + +### Requirement: Colspan cell width handling + +The system SHALL set only the first column width when the selected cell has a colspan greater than 1. + +#### Scenario: Setting width on colspan cell + +- **WHEN** user sets column width on a cell with colspan=3 that currently has colwidth=[100, 150, 200] +- **THEN** the system sets colwidth=[new_value] affecting only the first spanned column + +#### Scenario: Reading width from colspan cell + +- **WHEN** user opens cell configuration for a cell with colspan=3 and colwidth=[100, 150, 200] +- **THEN** the "Column Width" input displays 100 (the first column's width) + +### Requirement: Number input UI component + +The configuration dropdown SHALL support a "numberInput" type with number-specific controls. + +#### Scenario: Number input with unit label + +- **WHEN** a configuration section has type "numberInput" with unit "px" +- **THEN** the UI renders a number input field with "px" unit label beside it + +#### Scenario: Number input with clear button + +- **WHEN** a configuration section has type "numberInput" and the field has a value +- **THEN** the UI displays a clear button (×) that resets the field to empty when clicked + +#### Scenario: Number input with placeholder + +- **WHEN** a configuration section has type "numberInput" with placeholder "Auto" +- **THEN** the empty input field shows "Auto" as placeholder text + +### Requirement: Column width persistence + +The system SHALL persist column width values in the document's colwidth attribute. + +#### Scenario: Width persists after save and reload + +- **WHEN** user sets a column width to 150 pixels, saves the document, and reloads the page +- **THEN** the column width remains 150 pixels and is displayed correctly in the cell configuration + +#### Scenario: Width persists across editing sessions + +- **WHEN** user sets column widths on multiple columns and closes the editor +- **THEN** reopening the document displays all columns at their configured widths + +### Requirement: Visual feedback + +The system SHALL provide clear visual feedback when column width changes. + +#### Scenario: Immediate column resize on input + +- **WHEN** user changes the column width value in the input field +- **THEN** the table column resizes immediately without requiring additional confirmation + +#### Scenario: Clear button visibility + +- **WHEN** the column width input has a value +- **THEN** the clear button is visible +- **WHEN** the column width input is empty +- **THEN** the clear button is hidden diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/tasks.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/tasks.md new file mode 100644 index 0000000000..dbd8a58ee0 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/add-cell-column-width/tasks.md @@ -0,0 +1,48 @@ +## 1. Type Definition Updates + +- [x] 1.1 Update `ConfigurationSection` interface in `src/components/toolbars/ToolbarConfig.ts` to add `"numberInput"` to type union +- [x] 1.2 Add optional fields to `ConfigurationSection` interface: `min?: number`, `max?: number`, `step?: number`, `placeholder?: string`, `unit?: string` +- [x] 1.3 Update `ConfigurationSection` interface in `src/components/toolbars/components/ConfigurationDropdown.tsx` to match the updated type definition +- [x] 1.4 Update `getCurrentValue` return type to allow `string | number | null` instead of just `string | null` + +## 2. UI Component Implementation + +- [x] 2.1 Add number input rendering case in `ConfigurationDropdown.tsx` after the dropdown case (around line 73) +- [x] 2.2 Create wrapper div with className `configuration-number-input` containing the input, unit label, and clear button +- [x] 2.3 Implement number input with type="number", min/max/step/placeholder props from section config +- [x] 2.4 Implement conditional unit label display using `configuration-unit` className when `section.unit` exists +- [x] 2.5 Implement conditional clear button (×) with `configuration-clear-button` className, shown only when `currentValue !== null` +- [x] 2.6 Wire up onChange handler to call `section.onChange(e.target.value)` on input change +- [x] 2.7 Wire up clear button onClick handler to call `section.onChange("")` to reset to auto width + +## 3. Styling + +- [x] 3.1 Add `.configuration-number-input` styles in `ConfigurationDropdown.scss` with flex layout and 4px gap +- [x] 3.2 Add `.configuration-input` styles matching `.configuration-select` with padding, borders, transitions, and focus states +- [x] 3.3 Add number input specific styles to hide spinner arrows (`::-webkit-inner-spin-button`, `::-webkit-outer-spin-button`, `-moz-appearance: textfield`) +- [x] 3.4 Add `.configuration-input::placeholder` styles with gray color and italic font +- [x] 3.5 Add `.configuration-unit` styles with 12px font size, gray color, and nowrap +- [x] 3.6 Add `.configuration-clear-button` styles with padding, borders, hover/active states matching the design + +## 4. Cell Configuration Logic + +- [x] 4.1 Add new configuration section object in `createCellConfigurationSections()` function in `src/components/toolbars/helpers/configurationHelpers.ts` after `cellBorderWidth` +- [x] 4.2 Set section id to `"cellWidth"`, label to `"Column Width"`, type to `"numberInput"` +- [x] 4.3 Set min to 25, max to 1000, step to 1, placeholder to "Auto", unit to "px" +- [x] 4.4 Implement `getCurrentValue` function that calls `getCellAttributes(editor)`, extracts `colwidth` array, and returns `colwidth[0]` or null +- [x] 4.5 Implement `onChange` function that parses input value, validates it, clamps to min/max range, and calls `setCellAttribute("colwidth", [value])` or `setCellAttribute("colwidth", null)` for empty input +- [x] 4.6 Handle edge case where input is empty string by calling `setCellAttribute("colwidth", null)` to reset to auto width +- [x] 4.7 Handle colspan cells by creating colwidth array with single value `[clampedValue]` regardless of colspan + +## 5. Testing & Verification + +- [x] 5.1 Test setting column width to valid value (e.g., 150px) and verify column resizes +- [x] 5.2 Test clearing column width via clear button and verify column auto-sizes +- [x] 5.3 Test entering value below minimum (e.g., 10) and verify it clamps to 25 +- [x] 5.4 Test entering value above maximum (e.g., 2000) and verify it clamps to 1000 +- [x] 5.5 Test entering invalid input (e.g., "abc") and verify it's ignored +- [x] 5.6 Test with colspan cell (colspan=3) and verify only first column width is set +- [x] 5.7 Test that width persists after save/reload of document +- [x] 5.8 Test that clear button visibility toggles correctly (hidden when empty, shown when value exists) +- [x] 5.9 Test that placeholder "Auto" displays when input is empty +- [x] 5.10 Test keyboard navigation and accessibility of number input control diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/README.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/README.md new file mode 100644 index 0000000000..4bcf59afdc --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/README.md @@ -0,0 +1,84 @@ +# Normalize Empty Content + +**Status:** Proposed +**Type:** Bug Fix +**Impact:** Breaking (Validation behavior) +**Effort:** ~3.5 hours + +## Quick Summary + +Fix the rich text editor to save empty content as `""` instead of `

`, ensuring correct validation and consistent data representation. + +## Problem + +When users delete all content, the editor currently saves `

` to the database. This breaks: + +- Required field validation (incorrectly passes) +- Empty checks (`if (value)` returns true) +- Data consistency (semantically empty ≠ empty string) + +## Solution + +Use Tiptap's `editor.isEmpty` property to return `""` when content is empty, and normalize comparison logic to treat `""` and `

` as equivalent. + +## Files + +- [`proposal.md`](./proposal.md) - Problem statement, scope, and impact +- [`design.md`](./design.md) - Technical design, decisions, and architecture +- [`tasks.md`](./tasks.md) - Implementation checklist and effort estimate + +## Key Decisions + +1. **Trust Tiptap's isEmpty** - Use built-in check, no custom logic +2. **Normalize at persistence** - Convert to `""` when saving, not loading +3. **Lazy migration** - Existing `

` records normalize on next edit +4. **No version bump** - This Tiptap version is unreleased + +## Changes Required + +### Editor.tsx (line 237-240) + +```typescript +// Before: +const html = editor.getHTML(); + +// After: +const html = editor.isEmpty ? "" : editor.getHTML(); +``` + +### EditorWrapper.tsx (line 48-60) + +```typescript +// Add normalization in comparison: +const normalizeEmpty = (val?: string) => (!val || val === "

" ? "" : val); + +const current = normalizeEmpty(stringAttribute.value); +const incoming = normalizeEmpty(html); +if (current !== incoming) { + stringAttribute.setValue(incoming); +} +``` + +## Testing + +- ✓ Unit tests for empty content behavior +- ✓ E2E test for persistence +- ✓ Manual validation testing + +## Breaking Change + +**Required field validation will now correctly reject empty content.** + +Previously: `

` passed validation ❌ +After fix: `""` fails validation ✓ + +This is desired behavior (bug fix), but may affect existing forms. + +## Next Steps + +1. Review proposal and design +2. Approve or request changes +3. Implement following tasks.md +4. Test thoroughly +5. Update CHANGELOG +6. Merge to main branch diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/design.md new file mode 100644 index 0000000000..fd696c3c70 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/design.md @@ -0,0 +1,374 @@ +## Design Overview + +The fix normalizes empty editor content at the persistence boundary by checking Tiptap's `isEmpty` property and returning an empty string instead of `

`. A secondary normalization in the comparison layer prevents unnecessary updates when values are semantically equivalent. + +## Architecture + +``` +User Deletes Content + ↓ +Tiptap Editor (internal state:

) + ↓ +onUpdate Event Fires + ↓ +┌─────────────────────────────────────────┐ +│ Editor.tsx (PRIMARY FIX) │ +│ │ +│ onUpdate: ({ editor }) => { │ +│ const html = editor.isEmpty │ +│ ? "" │ ← Use isEmpty check +│ : editor.getHTML(); │ +│ onUpdate?.(html); │ +│ } │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ EditorWrapper.tsx (OPTIMIZATION) │ +│ │ +│ const current = stringAttribute.value || ""; │ +│ const incoming = html || ""; │ ← Normalize comparison +│ if (current !== incoming) { │ +│ stringAttribute.setValue(incoming); │ +│ } │ +└─────────────────────────────────────────┘ + ↓ +Mendix EditableValue.setValue("") + ↓ +Database: RichTextField = "" ✓ +``` + +## Key Design Decisions + +### 1. Use Tiptap's `isEmpty` Property + +**Decision:** Trust Tiptap's built-in `editor.isEmpty` check rather than implementing custom logic. + +**Rationale:** + +- Tiptap's `isEmpty` is battle-tested and handles edge cases correctly +- Considers semantic emptiness (ignores empty marks, formatting-only nodes) +- No need to reinvent the wheel with regex or string parsing +- Future-proof as Tiptap evolves + +**Edge Cases Handled by isEmpty:** + +- `

` → true +- `

` → true (empty marks) +- `

` → Implementation depends on Tiptap's definition +- `


` → false (has content - soft break) +- `` → false (has structure) + +### 2. Normalize at Persistence Boundary + +**Decision:** Convert `

` to `""` in the `onUpdate` handler, not on load. + +**Rationale:** + +- Centralizes normalization at the point where data leaves the editor +- Tiptap can maintain whatever internal state it needs +- Rendering is unaffected (Tiptap will render `

` internally regardless) +- Single source of truth for what gets persisted + +**Rejected Alternative:** Normalize on load (`defaultValue`) + +- Would require changing initialization logic +- Adds complexity without user-visible benefit +- Lazy migration is sufficient + +### 3. Comparison Normalization + +**Decision:** Treat `undefined`, `""`, and `

` as equivalent in comparison logic. + +**Why:** + +```javascript +// Without normalization: +stringAttribute.value = ""; +html = "

"; +"" !== "

" // → triggers setValue() unnecessarily + +// With normalization: +current = "" || "" = ""; +incoming = "

" || "" = ""; // Wait, this doesn't work! + +// Correct normalization: +const normalize = (val?: string) => val || ""; +current = normalize(stringAttribute.value); // "" or "

" → "" +incoming = normalize(html); // "" or "

" → "" +``` + +**Actually, we need:** + +```javascript +const normalize = (val?: string) => { + if (!val || val === "

") return ""; + return val; +}; +``` + +**Rationale:** + +- Prevents duplicate setValue() calls during transition period +- Reduces unnecessary database writes +- Handles both `""` and `

` gracefully + +### 4. Lazy Migration + +**Decision:** Don't proactively migrate existing `

` records. + +**Rationale:** + +- Records with `

` load and render fine +- They normalize to `""` on next edit +- No risk of data corruption +- No need for migration script +- Gradual, safe transition + +## Implementation Details + +### File: `src/components/Editor.tsx` + +**Current Code (Line 237-240):** + +```typescript +onUpdate: ({ editor }) => { + const html = editor.getHTML(); + onUpdate?.(html); +}; +``` + +**New Code:** + +```typescript +onUpdate: ({ editor }) => { + const html = editor.isEmpty ? "" : editor.getHTML(); + onUpdate?.(html); +}; +``` + +**Impact:** + +- Every content change triggers this check +- Performance: Negligible (boolean property access) +- Testing: Unit test can mock editor.isEmpty + +### File: `src/components/EditorWrapper.tsx` + +**Current Code (Line 48-60):** + +```typescript +const [setAttributeValueDebounce] = useDebounceWithStatus( + (html?: string) => { + if (stringAttribute.value !== html) { + stringAttribute.setValue(html); + if (onChangeType === "onDataChange") { + executeAction(onChange); + } + } + }, + 200, + false +); +``` + +**New Code:** + +```typescript +const normalizeEmpty = (val?: string): string => { + if (!val || val === "

") return ""; + return val; +}; + +const [setAttributeValueDebounce] = useDebounceWithStatus( + (html?: string) => { + const current = normalizeEmpty(stringAttribute.value); + const incoming = normalizeEmpty(html); + + if (current !== incoming) { + stringAttribute.setValue(incoming); + if (onChangeType === "onDataChange") { + executeAction(onChange); + } + } + }, + 200, + false +); +``` + +**Impact:** + +- Prevents spurious updates when switching representations +- Helper function can be tested independently +- Encapsulates normalization logic + +## Testing Strategy + +### Unit Tests + +**File:** `src/__tests__/RichText.spec.tsx` + +New test cases: + +```typescript +describe("Empty content handling", () => { + it("returns empty string when editor is empty", () => { + const onUpdate = jest.fn(); + const editor = createMockEditor({ isEmpty: true, getHTML: () => "

" }); + + // Trigger onUpdate + editor.onUpdate({ editor }); + + expect(onUpdate).toHaveBeenCalledWith(""); + }); + + it("returns HTML when editor has content", () => { + const onUpdate = jest.fn(); + const editor = createMockEditor({ + isEmpty: false, + getHTML: () => "

Hello

" + }); + + editor.onUpdate({ editor }); + + expect(onUpdate).toHaveBeenCalledWith("

Hello

"); + }); + + it("normalizes

in comparison", () => { + const setValue = jest.fn(); + const stringAttribute = { value: "

", setValue }; + + // Trigger update with empty string + handleUpdate(""); + + // Should NOT call setValue (semantically equivalent) + expect(setValue).not.toHaveBeenCalled(); + }); +}); +``` + +### E2E Tests + +**File:** `e2e/RichText.spec.js` + +New test: + +```javascript +test("empty content persists as empty string", async ({ page }) => { + await page.goto("/p/rich-text-test"); + await waitForMendixApp(page); + + // Type content + const editor = page.locator(".tiptap-editor"); + await editor.click(); + await page.keyboard.type("Hello World"); + + // Delete all content + await page.keyboard.press("Control+A"); + await page.keyboard.press("Backspace"); + + // Blur to trigger save + await page.keyboard.press("Tab"); + + // Verify the attribute value is empty string + const isEmpty = await page.evaluate(() => { + // Access Mendix test helper + const context = window.mx.data.get(/* richTextEntity */); + const value = context.get("RichTextField"); + return value === "" || value === null; + }); + + expect(isEmpty).toBe(true); +}); +``` + +## Risks and Mitigations + +### Risk 1: Validation Breaking Change + +**Risk:** Forms with required RichText that accepted `

` will now fail validation. + +**Mitigation:** + +- This is the desired behavior (bug fix) +- Document as breaking change in CHANGELOG +- Note: Since this is unreleased Tiptap version, no version bump needed + +**Verdict:** Acceptable - improves correctness + +### Risk 2: Tiptap's isEmpty Edge Cases + +**Risk:** Tiptap's `isEmpty` might have edge cases we don't anticipate. + +**Mitigation:** + +- Trust Tiptap's well-tested implementation +- Add unit tests for known edge cases +- Monitor issue reports after release + +**Verdict:** Low risk - Tiptap is mature + +### Risk 3: Performance + +**Risk:** Extra checks on every keystroke. + +**Analysis:** + +- `editor.isEmpty` is a boolean property (O(1)) +- Normalization is string comparison (O(n), but n is small) +- Already debounced (200ms) + +**Verdict:** Negligible impact + +## Alternative Approaches Considered + +### Alternative 1: Normalize on Load + +```typescript +const editor = useEditor({ + content: normalizeEmpty(defaultValue), + ... +}); +``` + +**Rejected because:** + +- Doesn't prevent `

` from being saved in the first place +- Adds complexity to initialization +- No user-visible benefit + +### Alternative 2: Custom isEmpty Logic + +```typescript +const isEmpty = (html: string) => { + return !html || html === "

" || html.trim() === ""; +}; +``` + +**Rejected because:** + +- Fragile (what about `

`, `


`, etc?) +- Reinvents Tiptap's tested logic +- Harder to maintain + +### Alternative 3: Database Migration + +Run a script to convert all `

` → `""` in database. + +**Rejected because:** + +- Unnecessary risk +- Lazy migration is safer +- No user-visible benefit + +## Conclusion + +This design provides a minimal, safe fix for the empty content bug by leveraging Tiptap's built-in `isEmpty` property and normalizing at the persistence boundary. The secondary comparison normalization prevents unnecessary updates during the transition period. + +**Key Benefits:** + +- ✓ Simple implementation (two small changes) +- ✓ Low risk (uses proven Tiptap API) +- ✓ Testable (unit + E2E coverage) +- ✓ Backward compatible (lazy migration) +- ✓ Fixes validation correctness diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/proposal.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/proposal.md new file mode 100644 index 0000000000..5b8eb44b30 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/proposal.md @@ -0,0 +1,58 @@ +## Why + +When users delete all content from the rich text editor, it currently saves as `

` instead of an empty string. This creates several problems: + +1. **Validation issues**: Required fields incorrectly pass validation with `

` as the value +2. **Comparison inconsistencies**: Checking `if (value === "")` or `if (!value)` fails because `

` is truthy +3. **Data semantics**: Semantically empty content should be represented as an empty string, not HTML markup +4. **Developer confusion**: Backend logic must special-case `

` when checking for empty content + +This is a bug in the current implementation where Tiptap's `getHTML()` returns `

` for an empty editor, but we should normalize this to an empty string before persisting to the database. + +## What Changes + +- Modify `onUpdate` handler in `Editor.tsx` to return empty string (`""`) when editor is empty using Tiptap's built-in `isEmpty` property +- Normalize value comparison in `EditorWrapper.tsx` to treat `undefined`, `""`, and `

` as equivalent, preventing unnecessary database updates +- Add unit tests for empty content handling +- Add E2E test to verify empty content persists as empty string + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `rich-text-persistence`: Empty editor content now correctly saves as empty string (`""`) instead of `

`, ensuring proper validation and consistent data representation + +## Impact + +**Affected Files:** + +- `src/components/Editor.tsx` - Modify `onUpdate` handler to check `editor.isEmpty` and return `""` when true +- `src/components/EditorWrapper.tsx` - Normalize value comparison to prevent spurious updates +- `src/__tests__/RichText.spec.tsx` - Add tests for empty content handling +- `e2e/RichText.spec.js` - Add E2E test for empty content persistence + +**User Impact:** + +- **Validation improvement** (Breaking): Required RichText fields will now correctly reject empty content. Previously, `

` incorrectly passed validation. +- **Data consistency**: Empty content is consistently represented as `""` across all scenarios +- **Backward compatibility**: Existing records with `

` will load normally and get normalized to `""` on next edit (lazy migration) + +**Technical Impact:** + +- Leverages Tiptap's built-in `isEmpty` property (battle-tested, no custom logic needed) +- Minimal performance impact (one boolean property check per update) +- No changes to Tiptap configuration or extensions +- No version bump needed (Tiptap version is unreleased) + +## Migration + +**Lazy Migration Strategy:** + +- Existing records with `

` in database remain unchanged +- Values normalize to `""` when user next edits the content +- No database migration script required +- Safe, gradual transition diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/tasks.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/tasks.md new file mode 100644 index 0000000000..b3ab006932 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-06-normalize-empty-content/tasks.md @@ -0,0 +1,107 @@ +## Implementation Tasks + +### Core Implementation + +- [ ] **Modify Editor.tsx onUpdate handler** + - File: `src/components/Editor.tsx` + - Line: 237-240 + - Change: Replace `const html = editor.getHTML();` with `const html = editor.isEmpty ? "" : editor.getHTML();` + - Validation: Verify editor.isEmpty property is accessible via TypeScript types + +- [ ] **Add normalizeEmpty helper in EditorWrapper.tsx** + - File: `src/components/EditorWrapper.tsx` + - Add helper function above component: + ```typescript + function normalizeEmpty(value?: string): string { + if (!value || value === "

") return ""; + return value; + } + ``` + - Update comparison logic in setAttributeValueDebounce (line 48-60) + - Use `normalizeEmpty()` for both current and incoming values + +### Testing + +- [ ] **Add unit tests for empty content handling** + - File: `src/__tests__/RichText.spec.tsx` + - Test cases: + - Empty editor returns empty string + - Editor with content returns HTML + - Normalization treats "" and "

" as equivalent + - Edge case: whitespace-only content behavior + +- [ ] **Add E2E test for empty content persistence** + - File: `e2e/RichText.spec.js` + - Test scenario: + - Type content in editor + - Delete all content + - Blur to trigger save + - Verify attribute value is empty string (not "

") + +- [ ] **Manual testing checklist** + - [ ] Create new record with required RichText field + - [ ] Try to save with empty content → validation should fail + - [ ] Add content and save → should succeed + - [ ] Delete all content and save → validation should fail + - [ ] Verify database stores "" not "

" + - [ ] Load existing record with "

" → should render correctly + - [ ] Edit and save → should normalize to "" + - [ ] Test undo/redo with empty content + - [ ] Test code view → code view toggle with empty content + +### Documentation + +- [ ] **Update CHANGELOG.md** + - Add entry under "Fixed" section: + - "Empty editor content now correctly saves as empty string instead of `

`, fixing validation and comparison issues" + - Note breaking change: Required field validation now correctly rejects empty content + +- [ ] **Update README.md (if needed)** + - Check if empty content behavior is documented + - Add note about empty value representation if relevant + +### Code Review Checklist + +- [ ] Verify isEmpty property is correctly used +- [ ] Confirm no TypeScript errors +- [ ] Check that normalization logic handles all cases (undefined, "", "

") +- [ ] Verify debounce behavior is maintained +- [ ] Ensure onChange events still fire correctly +- [ ] Confirm no regression in existing functionality +- [ ] Review test coverage for edge cases + +### Pre-Merge Verification + +- [ ] All unit tests pass: `pnpm test` +- [ ] All E2E tests pass: `pnpm e2e` +- [ ] Linting passes: `pnpm lint` +- [ ] Format check passes: `pnpm format` +- [ ] Build succeeds: `pnpm build` +- [ ] Manual testing completed +- [ ] CHANGELOG updated +- [ ] No console errors in browser during testing + +## Estimated Effort + +- Core implementation: **30 minutes** +- Unit tests: **1 hour** +- E2E tests: **1 hour** +- Manual testing: **30 minutes** +- Documentation: **15 minutes** +- Code review and fixes: **30 minutes** + +**Total: ~3.5 hours** + +## Dependencies + +None - this is a self-contained fix within the RichText widget. + +## Success Criteria + +✓ Empty editor persists as `""` instead of `

` +✓ Required field validation correctly rejects empty content +✓ Existing `

` records load and normalize on next edit +✓ No regression in content editing functionality +✓ All tests pass (unit + E2E) +✓ No TypeScript errors +✓ Code review approved diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/.openspec.yaml b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/.openspec.yaml new file mode 100644 index 0000000000..8803b473ec --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-12 diff --git a/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/design.md b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/design.md new file mode 100644 index 0000000000..1076cbebc3 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/openspec/changes/archive/2026-07-12-auto-cycle-list-styles/design.md @@ -0,0 +1,176 @@ +## Context + +Rich text widget renders ordered (`
    `) and unordered (`