-
Notifications
You must be signed in to change notification settings - Fork 190
fix(plan-engine): date.setValue and checkbox.setState update the visible content control, not just its stored value #3802
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7c4853e
bad6d4e
abcaa11
77fce10
bac3af5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,8 +64,84 @@ export function resolveHyperlinkAttributes(instruction, docx) { | |
| } | ||
|
|
||
| /** | ||
| * Processes a HYPERLINK instruction and creates a `w:hyperlink` node. | ||
| * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. | ||
| * Slips a `<w:hyperlink>` *inside* one paragraph, wrapping just its visible runs. | ||
| * | ||
| * @param {import('../../v2/types/index.js').OpenXmlNode} paragraph | ||
| * @param {Record<string, string | boolean>} linkAttributes | ||
| * @returns {import('../../v2/types/index.js').OpenXmlNode} A copy of the paragraph with its runs wrapped. | ||
| */ | ||
| function wrapParagraphRunsInHyperlink(paragraph, linkAttributes) { | ||
| const children = Array.isArray(paragraph.elements) ? paragraph.elements : []; | ||
| const wrappedChildren = []; | ||
| let pendingRuns = null; | ||
|
|
||
| const flushPendingRuns = () => { | ||
| if (!pendingRuns || pendingRuns.length === 0) { | ||
| pendingRuns = null; | ||
| return; | ||
| } | ||
| wrappedChildren.push({ | ||
| name: 'w:hyperlink', | ||
| type: 'element', | ||
| attributes: { ...linkAttributes }, | ||
| elements: pendingRuns, | ||
| }); | ||
| pendingRuns = null; | ||
| }; | ||
|
|
||
| for (const child of children) { | ||
| if (child?.name === 'w:r') { | ||
| if (!pendingRuns) pendingRuns = []; | ||
| pendingRuns.push(child); | ||
| continue; | ||
| } | ||
| // Paragraph properties (w:pPr) and any other structural child must stay a | ||
| // direct child of the paragraph, outside the inline hyperlink. | ||
| flushPendingRuns(); | ||
| wrappedChildren.push(child); | ||
|
Comment on lines
+98
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a cross-paragraph field's visible text is inside an inline wrapper such as Useful? React with 👍 / 👎. |
||
| } | ||
| flushPendingRuns(); | ||
|
|
||
| return { ...paragraph, elements: wrappedChildren }; | ||
| } | ||
|
|
||
| /** | ||
| * Turns a HYPERLINK field code into real `<w:hyperlink>` tag(s). | ||
| * | ||
| * Old Word docs don't store a link as one tidy tag. They store "plumbing": a | ||
| * `begin` marker, the URL instruction, a `separate` marker, the visible text, | ||
| * then an `end` marker. By the time we get here that plumbing has been stripped | ||
| * and `nodesToCombine` holds just the visible content the link should cover. Our | ||
| * job is to wrap that content in a `<w:hyperlink>`. | ||
| * | ||
| * Common case — the whole link lives on one line, so `nodesToCombine` is just | ||
| * inline runs. One `<w:hyperlink>` around all of them is correct: | ||
| * | ||
| * <w:hyperlink><w:r>CSP - 1</w:r></w:hyperlink> | ||
| * | ||
| * Cross-paragraph case — the plumbing started on one line and finished on the | ||
| * next (very common when a link fills a table cell), so `nodesToCombine` holds | ||
| * whole `<w:p>` blocks, not loose runs. A `<w:hyperlink>` is *inline* (like | ||
| * `<b>`): it lives inside a line of text and cannot contain a `<w:p>`. Wrapping | ||
| * the paragraphs in one hyperlink... | ||
| * | ||
| * <w:hyperlink> <-- paragraphs can't live inside an inline tag | ||
| * <w:p>CSP - 1</w:p> | ||
| * <w:p>Data in transit</w:p> | ||
| * </w:hyperlink> | ||
| * | ||
| * ...makes the importer throw the paragraphs away (it only reads runs out of a | ||
| * hyperlink), which empties the table cell. An empty cell breaks the tableCell | ||
| * `block+` schema and aborts the whole document load. | ||
| * | ||
| * So for the cross-paragraph case we keep each paragraph and drop a *separate* | ||
| * `<w:hyperlink>` inside each one, all pointing at the same target — exactly how | ||
| * Word itself writes a link that spans more than one line: | ||
| * | ||
| * <w:p><w:hyperlink r:id="rId5"><w:r>CSP - 1</w:r></w:hyperlink></w:p> | ||
| * <w:p><w:hyperlink r:id="rId5"><w:r>Data in transit</w:r></w:hyperlink></w:p> | ||
| * | ||
| * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The visible content the link should cover. | ||
| * @param {string} instruction The instruction text. | ||
| * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx }} [options] | ||
| * @returns {import('../../v2/types/index.js').OpenXmlNode[]} | ||
|
|
@@ -75,12 +151,30 @@ export function preProcessHyperlinkInstruction(nodesToCombine, instruction, opti | |
| const docx = options.docx; | ||
| const linkAttributes = resolveHyperlinkAttributes(instruction, docx) ?? {}; | ||
|
|
||
| return [ | ||
| { | ||
| // A `<w:p>` in the gathered content means the link spans a paragraph break, so | ||
| // we can't use the simple "one hyperlink around everything" shape. | ||
| const spansParagraphBoundary = nodesToCombine.some((node) => node?.name === 'w:p'); | ||
| if (!spansParagraphBoundary) { | ||
| return [ | ||
| { | ||
| name: 'w:hyperlink', | ||
| type: 'element', | ||
| attributes: linkAttributes, | ||
| elements: nodesToCombine, | ||
| }, | ||
| ]; | ||
| } | ||
|
|
||
| return nodesToCombine.map((node) => { | ||
| if (node?.name === 'w:p') { | ||
| return wrapParagraphRunsInHyperlink(node, linkAttributes); | ||
| } | ||
| // A loose run sitting between paragraphs still gets its own inline link. | ||
| return { | ||
| name: 'w:hyperlink', | ||
| type: 'element', | ||
| attributes: linkAttributes, | ||
| elements: nodesToCombine, | ||
| }, | ||
| ]; | ||
| attributes: { ...linkAttributes }, | ||
| elements: [node], | ||
| }; | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| /* @vitest-environment jsdom */ | ||
|
|
||
| /** | ||
| * End-to-end coverage for `checkbox.setState`: it must update a block-scope | ||
| * checkbox's VISIBLE glyph, not just the stored `w14:checked` value. | ||
| * | ||
| * A block-scope checkbox is an SDT whose `sdtContent` wraps a `<w:p>` carrying | ||
| * the glyph (e.g. stacked Yes/No checkboxes in a table cell). The inline path | ||
| * swaps the glyph via `updateStructuredContentById`, but that builds inline text | ||
| * JSON the block schema rejects — so for block SDTs the glyph must be rewritten | ||
| * through the content range directly. The unit test in | ||
| * `content-controls-wrappers.test.ts` pins the wrapper-level behavior with a | ||
| * mock; this test drives the real import → mutate → read pipeline against the | ||
| * `block_checkbox_control.docx` fixture to confirm the glyph swaps end-to-end | ||
| * and to guard against regressions. | ||
| */ | ||
|
|
||
| import { describe, expect, it } from 'vitest'; | ||
| import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; | ||
|
|
||
| const UNCHECKED = '☐'; // U+2610 | ||
| const CHECKED = '☒'; // U+2612 | ||
|
|
||
| describe('checkbox.setState updates the visible glyph (block scope)', () => { | ||
| it('swaps the rendered glyph ☐ -> ☒ for a block-scope checkbox', async () => { | ||
| const docData = await loadTestDataForEditorTests('block_checkbox_control.docx'); | ||
| const { editor } = initTestEditor({ | ||
| content: docData.docx, | ||
| media: docData.media, | ||
| mediaFiles: docData.mediaFiles, | ||
| fonts: docData.fonts, | ||
| isHeadless: true, | ||
| user: { name: 'Test', email: 'test@example.com' }, | ||
| }); | ||
|
|
||
| // The fixture starts unchecked. | ||
| const before = await Promise.resolve(editor.doc.contentControls.list()); | ||
| const checkboxBefore = before.items.find((item) => item.controlType === 'checkbox'); | ||
| expect(checkboxBefore).toBeDefined(); | ||
| expect(checkboxBefore?.text).toContain(UNCHECKED); | ||
|
|
||
| const result = await Promise.resolve( | ||
| editor.doc.contentControls.checkbox.setState( | ||
| { target: checkboxBefore!.target, checked: true }, | ||
| { changeMode: 'direct' }, | ||
| ), | ||
| ); | ||
| expect(result.success).toBe(true); | ||
|
|
||
| // Re-read: the rendered glyph should now be checked, not the empty box. | ||
| const after = await Promise.resolve(editor.doc.contentControls.list()); | ||
| const checkboxAfter = after.items.find((item) => item.controlType === 'checkbox'); | ||
| expect(checkboxAfter?.text).toContain(CHECKED); | ||
| expect(checkboxAfter?.text).not.toContain(UNCHECKED); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a HYPERLINK field begins after ordinary text in its first paragraph or ends before ordinary text in its last paragraph,
preProcessNodesForFldCharpasses the whole boundary paragraphs here, including those outside-field runs. This loop wraps every directw:r, so the prefix and suffix unexpectedly become clickable; preserve the field boundaries when splitting the hyperlink across paragraphs rather than treating every run in each collected paragraph as field content.Useful? React with 👍 / 👎.