From 41a9190b83a5cbc79a1c5e08e96a024abec253a3 Mon Sep 17 00:00:00 2001 From: Arukuen Date: Tue, 8 Sep 2026 10:45:34 +0800 Subject: [PATCH 1/3] fix: escape generated dynamic content --- src/components/dynamic-content-control/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/dynamic-content-control/index.js b/src/components/dynamic-content-control/index.js index affa7fc2ef..6b698eaa44 100644 --- a/src/components/dynamic-content-control/index.js +++ b/src/components/dynamic-content-control/index.js @@ -15,6 +15,7 @@ import { QueryLoopContext } from '~stackable/higher-order/with-query-loop-contex */ import { __ } from '@wordpress/i18n' import { useBlockEditContext } from '@wordpress/block-editor' +import { escapeAttribute } from '@wordpress/escape-html' import { Button, TextControl, @@ -122,8 +123,10 @@ export const useDynamicContentControlProps = props => { const onChange = ( newValue, editorQueryString, frontendQueryString ) => { // If `isFormatType` is true, the onChange function will generate a `stackable/dynamic-content` format type. + // Custom date formats add query parameters with "&"" character. Escape it so + // HTML validation in Typography preserves this as markup, not text. const willChangeValue = props.isFormatType - ? `${ newValue }` + ? `${ newValue }` : `!#stk_dynamic/${ frontendQueryString }!#` props.onChange( willChangeValue ) From c2d8e69ef5d3d888097bafdb2864efdc645c6eae Mon Sep 17 00:00:00 2001 From: Arukuen Date: Tue, 8 Sep 2026 11:24:20 +0800 Subject: [PATCH 2/3] fix: focus the fix in how typography handle dynamic content --- src/block-components/typography/edit.js | 5 +++-- src/components/dynamic-content-control/index.js | 5 +---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/block-components/typography/edit.js b/src/block-components/typography/edit.js index 4905f3b1c6..15b46f4f03 100644 --- a/src/block-components/typography/edit.js +++ b/src/block-components/typography/edit.js @@ -116,11 +116,12 @@ export const Controls = props => { value={ unescape( text ) } onChange={ onChangeContent } /** - * Pass the unescaped Dynamic Content `onChange` function. + * Dynamic Content generates this markup itself. Preserve it because + * HTML validation normalizes string ampersands in its attribute. * * @param {string} text Text with dynamic content. */ - changeDynamicContent={ onChangeContent } + changeDynamicContent={ text => updateAttribute( 'text', text ) } isDynamic={ true } /> ) } diff --git a/src/components/dynamic-content-control/index.js b/src/components/dynamic-content-control/index.js index 6b698eaa44..affa7fc2ef 100644 --- a/src/components/dynamic-content-control/index.js +++ b/src/components/dynamic-content-control/index.js @@ -15,7 +15,6 @@ import { QueryLoopContext } from '~stackable/higher-order/with-query-loop-contex */ import { __ } from '@wordpress/i18n' import { useBlockEditContext } from '@wordpress/block-editor' -import { escapeAttribute } from '@wordpress/escape-html' import { Button, TextControl, @@ -123,10 +122,8 @@ export const useDynamicContentControlProps = props => { const onChange = ( newValue, editorQueryString, frontendQueryString ) => { // If `isFormatType` is true, the onChange function will generate a `stackable/dynamic-content` format type. - // Custom date formats add query parameters with "&"" character. Escape it so - // HTML validation in Typography preserves this as markup, not text. const willChangeValue = props.isFormatType - ? `${ newValue }` + ? `${ newValue }` : `!#stk_dynamic/${ frontendQueryString }!#` props.onChange( willChangeValue ) From 192e1e94851ba292f91db1bf945aebc5e06f6dbc Mon Sep 17 00:00:00 2001 From: bfintal Date: Tue, 8 Sep 2026 17:54:02 +0800 Subject: [PATCH 3/3] test: cover custom formatted dates in dynamic content Keep the #3748 apply path covered so a custom date format stays a date, not escaped HTML. --- e2e/readme.md | 2 +- e2e/tests/dynamic-content-custom-date.spec.ts | 97 +++++++++++++++++++ playwright.config.js | 2 + playwright.premium.config.js | 6 +- 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 e2e/tests/dynamic-content-custom-date.spec.ts diff --git a/e2e/readme.md b/e2e/readme.md index 178bb3bef2..17829f2137 100644 --- a/e2e/readme.md +++ b/e2e/readme.md @@ -137,7 +137,7 @@ Fail if a panel still shows `ProControl` / "Get Premium" while premium is mocked |---------|------| | Design System | Sidebar opens; Preview; Color Schemes / Font pairs / Size presets / Icon Library are live controls | | Global Block Styles | Save a named style from one Text block, apply it to a second; canvas uses the style, inspector stays at defaults and can override; rename and delete from Design System; updating a style applies the change to other blocks that use it | -| Dynamic Content | Post title / meta / featured image resolve on the frontend | +| Dynamic Content | Post title / meta / featured image resolve on the frontend; custom date format apply path (`e2e/tests/dynamic-content-custom-date.spec.ts`) | | Conditional display | Logged-in condition visible on frontend; logged-out condition hidden while logged in | | Motion / Transform / Custom CSS | Entrance class, hover transform CSS, applied custom CSS on frontend | | Columns / Posts / Image / Icon / Separator | Arrangement (2+ columns), Offset after layout pick, circle shape, gradient, extra separator layer | diff --git a/e2e/tests/dynamic-content-custom-date.spec.ts b/e2e/tests/dynamic-content-custom-date.spec.ts new file mode 100644 index 0000000000..ad64a135bb --- /dev/null +++ b/e2e/tests/dynamic-content-custom-date.spec.ts @@ -0,0 +1,97 @@ +import { + test, + expect, + openInspectorPanel, + publishAndVisitFrontend, + waitForBlockEditor, +} from 'e2e/test-utils' + +// Issue #3748 / PR #3754: applying a custom date format writes `&` into +// `data-stk-dynamic`. Typography must keep that generated span, or the +// editor and frontend show escaped HTML instead of the formatted date. +// +// Premium suite only (see playwright.premium.config.js). Dynamic Fields +// apply lives in premium; the escape bug is in free typography. +test.describe( 'Dynamic Content custom date format', () => { + const createdPostIds: Array = [] + + test.afterEach( async ( { requestUtils } ) => { + for ( const id of createdPostIds.splice( 0 ) ) { + await requestUtils.deletePost( id ).catch( () => undefined ) + } + } ) + + test( 'custom formatted post date renders as a date, not escaped HTML', async ( { + page, + admin, + editor, + requestUtils, + stackable, + } ) => { + const post = await requestUtils.createPost( { + title: 'DC Custom Date Format', + status: 'draft', + date: '2026-03-15T12:00:00', + date_gmt: '2026-03-15T12:00:00', + } ) + createdPostIds.push( post.id ) + + await admin.editPost( String( post.id ) ) + await stackable.dismissToursAndNotices() + await waitForBlockEditor( editor ) + + await editor.insertBlock( { name: 'stackable/text' } ) + await stackable.pickDefaultLayout( editor ) + await stackable.selectBlockByName( editor, 'stackable/text' ) + + await stackable.openInspectorTab( 'Style' ) + await openInspectorPanel( page, 'Typography' ) + + const inspector = page.getByRole( 'region', { name: 'Editor settings' } ) + const contentControl = inspector.locator( '.stk-control' ).filter( { + has: page.locator( '.stk-control-label', { hasText: /^Content$/ } ), + } ) + await contentControl.locator( '.stk-dynamic-content-control__button' ).click() + + const popover = page.locator( '.stackable-dynamic-content__popover' ) + await expect( popover ).toBeVisible() + await expect( popover.getByRole( 'button', { name: 'Apply' } ) ).toBeVisible() + + const fieldControl = popover.locator( '.ugb-advanced-autosuggest-control' ) + .filter( { hasText: /^Field$/ } ) + await fieldControl.locator( 'input' ).click() + await page.locator( '.ugb-autosuggest-option[data-value="post-date"]' ).click() + + await expect( popover.getByLabel( 'Date Format' ) ).toBeVisible() + await popover.getByLabel( 'Date Format' ).selectOption( 'custom' ) + await popover.getByLabel( 'Custom Format' ).fill( 'F' ) + await expect( popover.getByRole( 'button', { name: 'Apply' } ) ).toBeEnabled() + await popover.getByRole( 'button', { name: 'Apply' } ).click() + await expect( popover ).toBeHidden() + + const canvasBlock = editor.canvas.locator( '[data-type="stackable/text"]' ).first() + await expect( canvasBlock ).toHaveText( 'March' ) + await expect( canvasBlock ).not.toContainText( 'data-stk-dynamic' ) + await expect( canvasBlock ).not.toContainText( ' { + const attributes = await editor.getBlockAttributes( clientId ) + uniqueId = attributes.uniqueId + expect( attributes.text ).toContain( '