From df43227e903c8fb141e68046614298ec67c22bee Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 11:01:51 +0100 Subject: [PATCH 1/5] chore(deps): bump @draggable/formeo-languages to 3.7.0 for pages.page 3.7.0 translates pages.page, so the locale test checks every pages.* key again and the docs no longer say it shows in English. Refs #122 --- docs/editor/pages.md | 5 ++--- package-lock.json | 8 ++++---- package.json | 2 +- src/lib/js/components/stages/page-text.mjs | 2 +- src/lib/js/components/stages/stages-pages.test.js | 5 ----- 5 files changed, 8 insertions(+), 14 deletions(-) diff --git a/docs/editor/pages.md b/docs/editor/pages.md index c2a13a89..695d700c 100644 --- a/docs/editor/pages.md +++ b/docs/editor/pages.md @@ -200,9 +200,8 @@ right-to-left `dir` work without any extra CSS. | `pages.page` | Page | These strings come from `@draggable/formeo-languages` (3.6.0 and later) and follow the editor's `i18n` option. For a -locale that doesn't have one of them, the English fallback above is shown. `pages.page` (the type shown next to a -page in the condition target list) is new, and shows in English until a release of `@draggable/formeo-languages` -ships it. +locale that doesn't have one of them, the English fallback above is shown. `@draggable/formeo-languages` 3.7.0 and +later translate `pages.page` (the type shown next to a page in the condition target list). ## "Clear All" diff --git a/package-lock.json b/package-lock.json index 6f404d00..236a17e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "5.9.2", "license": "MIT", "dependencies": { - "@draggable/formeo-languages": "^3.6.0", + "@draggable/formeo-languages": "3.7.0", "@draggable/i18n": "^1.0.7", "@draggable/tooltip": "^1.2.2", "lodash": "^4.17.21", @@ -911,9 +911,9 @@ } }, "node_modules/@draggable/formeo-languages": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@draggable/formeo-languages/-/formeo-languages-3.6.0.tgz", - "integrity": "sha512-2wjRmD39vcp+S3t2A9XBeC38F+pP5JoYk6elM/V9HzREKPIkMhhGP1u6p82vRjyfzM77S2mioDS9KaXBCQLu6g==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@draggable/formeo-languages/-/formeo-languages-3.7.0.tgz", + "integrity": "sha512-p1jwfFM3Lo+RAnSAbYz8SgXlqMXHRORFgKXc9nyD4zysj9gp7FkRMpUYwoQqEekF3ce5dF73+upX3PnKa2xexQ==", "license": "MIT", "dependencies": { "@draggable/i18n": "1.1.1" diff --git a/package.json b/package.json index 851d37b2..9b9a16c1 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "zod": "^4.4.3" }, "dependencies": { - "@draggable/formeo-languages": "^3.6.0", + "@draggable/formeo-languages": "^3.7.0", "@draggable/i18n": "^1.0.7", "@draggable/tooltip": "^1.2.2", "lodash": "^4.17.21", diff --git a/src/lib/js/components/stages/page-text.mjs b/src/lib/js/components/stages/page-text.mjs index eb276aa0..ed3a0ee9 100644 --- a/src/lib/js/components/stages/page-text.mjs +++ b/src/lib/js/components/stages/page-text.mjs @@ -2,7 +2,7 @@ import i18n from '@draggable/i18n' /** * English fallbacks for the editor's page tab strings (#122), for locales that don't translate them. - * @draggable/formeo-languages ships the same en-US text from 3.6.0. + * @draggable/formeo-languages ships these keys from 3.7.0. */ export const PAGE_TEXT = Object.freeze({ 'pages.label': 'Pages', diff --git a/src/lib/js/components/stages/stages-pages.test.js b/src/lib/js/components/stages/stages-pages.test.js index c54afa31..63cd2ef1 100644 --- a/src/lib/js/components/stages/stages-pages.test.js +++ b/src/lib/js/components/stages/stages-pages.test.js @@ -63,12 +63,7 @@ describe('pageText (#122)', () => { }) it('@draggable/formeo-languages ships every pages.* key, matching the English fallback', () => { - // pages.page is new (#122) and not yet shipped by @draggable/formeo-languages; pageText()'s own - // fallback carries it until a release adds it (see docs/editor/pages.md#i18n) for (const [key, text] of Object.entries(PAGE_TEXT)) { - if (key === 'pages.page') { - continue - } assert.equal(enUS[key], text, key) } }) From 80c978e42724ab18138fccf8803c3e2e867b9565 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 11:13:48 +0100 Subject: [PATCH 2/5] fix(renderer): read a skipped page's answers as unanswered in conditions A skipped page's fields kept driving other conditions, so a page shown by an answer on a skipped page stayed in play. While a page is skipped its fields now read as empty, unchecked and not visible, and the conditions watching that page re-run whenever it is skipped or back. A condition that skips or brings back a page still reads that page's own fields as they are, so a page can skip itself by its own answer without its bring-back condition undoing the skip at once. Refs #122 --- docs/editor/pages.md | 6 +- docs/renderer/renderer.md | 10 ++-- src/lib/js/renderer/index.js | 69 ++++++++++++++++++++--- src/lib/js/renderer/pagination.test.js | 76 +++++++++++++++++++++++++- tests/renderer-pagination.spec.js | 29 ++++++++++ 5 files changed, 172 insertions(+), 18 deletions(-) diff --git a/docs/editor/pages.md b/docs/editor/pages.md index 695d700c..b2af03a9 100644 --- a/docs/editor/pages.md +++ b/docs/editor/pages.md @@ -155,9 +155,9 @@ edit button) and use its **Conditions** panel: 3. Add a second condition that brings the page back (the opposite **If**, and **is visible**), because a condition is never undone on its own. -The renderer then leaves the page out of the tabs or the wizard while it's skipped. A skipped page's answers still -count as sources for other conditions, so a page depending on an answer given on a skippable page should be skipped -along with it. See [Skipping pages](../renderer/renderer.md#skipping-pages) for the details and an example. +The renderer then leaves the page out of the tabs or the wizard while it's skipped. While a page is skipped, other +conditions read its answers as empty. See [Skipping pages](../renderer/renderer.md#skipping-pages) for the details and +an example. ## Styling diff --git a/docs/renderer/renderer.md b/docs/renderer/renderer.md index a334ced1..d2fd5d28 100644 --- a/docs/renderer/renderer.md +++ b/docs/renderer/renderer.md @@ -551,11 +551,11 @@ A skipped page: - **Keeps its index.** `renderer.page` and `pageCount` still count every stage. Setting `renderer.page` to a skipped page shows the next page in play. -A skipped page's answers stay on the page and still count as sources for other conditions, even though they're left -out of `userData` and submission while the page is skipped. So a page whose own visibility depends on an answer given -on a skippable page should also be skipped by whatever skips that page: if "Account type" skips the Company page, a -VAT page shown by "VAT registered?" (a field on the Company page) should also be skipped whenever Company is, or it -can show for an answer the user never actually gave in this pass. +While a page is skipped, conditions read its fields as unanswered: an empty value, unchecked and not visible. Its +answers stay on the page and count again if it comes back. So if "Account type" skips the Company page, a VAT page +shown only when "VAT registered?" (a field on the Company page) is "yes" drops out along with Company, and returns +with it. A condition that skips or brings back a page still reads that page's own fields as they are, so a page can +skip itself. A skipped page can hold the author's own submit field. With `submit: false` (the default), skipping the page holding it disables that button along with every other control on the page, leaving the form with no enabled submit — Enter diff --git a/src/lib/js/renderer/index.js b/src/lib/js/renderer/index.js index 3a679048..b3f57db9 100644 --- a/src/lib/js/renderer/index.js +++ b/src/lib/js/renderer/index.js @@ -22,6 +22,15 @@ const SKIP_DISABLED_ATTR = 'data-formeo-skip-disabled' const SKIPPABLE_CONTROLS = 'input, select, textarea, button' // a page condition can only skip (true) or bring back (false) a stage const STAGE_SKIP_PROPERTIES = { isNotVisible: true, isVisible: false } +// while its page is skipped, a field reads as unanswered, so answers the user can't see don't drive conditions +const SKIPPED_PAGE_READS = { + value: '', + checked: '', + isChecked: false, + isNotChecked: true, + isVisible: false, + isNotVisible: true, +} export default class FormeoRenderer { constructor(opts = {}, formDataArg) { @@ -36,6 +45,9 @@ export default class FormeoRenderer { this.dom = dom } + // every applied condition's runner and the components it watches, so a page's skip can re-run those reading it + conditionRunners = [] + /** * Index of the page on show when the `pagination` option splits the form's stages into pages * @return {Number} 0 without pagination @@ -271,6 +283,7 @@ export default class FormeoRenderer { control.disabled = false control.removeAttribute(SKIP_DISABLED_ATTR) } + this.rerunConditionsReading(stage) this.pager?.refresh() return } @@ -289,6 +302,7 @@ export default class FormeoRenderer { control.setAttribute(SKIP_DISABLED_ATTR, '') } } + this.rerunConditionsReading(stage) this.pager?.refresh({ focus: hadFocus }) if (!this.pager && hadFocus) { // no pager to refocus the next page for us: find it ourselves among the stages still in the form @@ -478,6 +492,7 @@ export default class FormeoRenderer { * whenever a component one of its if-clauses reads from changes. */ applyConditions = () => { + this.conditionRunners = [] for (const { conditions } of Object.values(this.components)) { if (!conditions) { continue @@ -496,6 +511,12 @@ export default class FormeoRenderer { applyCondition = ({ if: ifConditions = [], then: thenConditions = [] }) => { const clauseGroups = groupIfConditions(ifConditions) + // the pages this condition skips or brings back: it reads their fields as they are, or skipping a page by its own + // answer would make them read as unanswered and bring the page straight back + const ownStages = thenConditions + .filter(({ target }) => isAddress(target) && splitAddress(target)[0] === 'stages') + .map(({ target }) => this.getComponent(target)?.component) + .filter(Boolean) // a `value` action fires `input` on its target; when the condition watches that target, // the event would re-enter run and set the value again, forever let running = false @@ -505,7 +526,7 @@ export default class FormeoRenderer { } running = true try { - if (this.evaluateClauseGroups(clauseGroups)) { + if (this.evaluateClauseGroups(clauseGroups, ownStages)) { for (const thenCondition of thenConditions) { this.execResult(thenCondition, evt) } @@ -520,16 +541,32 @@ export default class FormeoRenderer { const { component, options } = this.getComponent(address) this.listenForChanges(options || component, run) } + const watched = [...watchedAddresses].map(address => this.getComponent(address)?.component).filter(Boolean) + this.conditionRunners.push({ watched, ownStages, run }) run({ target: null }) } + /** + * A page's skip state changes what its fields read as, so re-runs the conditions watching anything on it. Only + * those: re-running every condition would re-apply unrelated `value` actions over the user's later input. + * @param {HTMLElement} stage + */ + rerunConditionsReading = stage => { + for (const { watched, ownStages, run } of this.conditionRunners) { + if (!ownStages.includes(stage) && watched.some(component => stage.contains(component))) { + run({ target: null }) + } + } + } + /** * @param {Array>} clauseGroups output of groupIfConditions + * @param {HTMLElement[]} [ownStages] see evaluateCondition * @return {Boolean} true when every clause of at least one group matches */ - evaluateClauseGroups = clauseGroups => - clauseGroups.some(group => group.length && group.every(clause => this.evaluateCondition(clause))) + evaluateClauseGroups = (clauseGroups, ownStages) => + clauseGroups.some(group => group.length && group.every(clause => this.evaluateCondition(clause, ownStages))) listenForChanges = (component, handler) => { if (!component) { @@ -552,8 +589,11 @@ export default class FormeoRenderer { /** * Evaulate conditions + * @param {Object} clause one if-clause + * @param {HTMLElement[]} [ownStages] stages the condition skips or brings back; see getComponentProperty + * @return {Boolean} */ - evaluateCondition = ({ source, sourceProperty, targetProperty, comparison, target }) => { + evaluateCondition = ({ source, sourceProperty, targetProperty, comparison, target }, ownStages = []) => { // a clause without a source address (e.g. half-filled in the editor), or reading from a field // that is no longer in the form, never matches if (!isAddress(source) || !this.getComponent(source)?.component) { @@ -561,13 +601,15 @@ export default class FormeoRenderer { } // Compare as string, this allows values like "true" to be checked for properties like "checked". - const sourceValue = this.getComponentProperty(source, sourceProperty) + const sourceValue = this.getComponentProperty(source, sourceProperty, ownStages) if (typeof sourceValue === 'boolean') { return sourceValue } - const targetValue = String(isAddress(target) ? this.getComponentProperty(target, targetProperty) : target) + const targetValue = String( + isAddress(target) ? this.getComponentProperty(target, targetProperty, ownStages) : target + ) return comparisonMap[comparison]?.(sourceValue, targetValue) } @@ -591,7 +633,15 @@ export default class FormeoRenderer { targetPropertyMap[targetProperty]?.(elem, { targetProperty, assignment, value }) } - getComponentProperty = (address, propertyName) => { + /** + * Reads a property of a rendered component. While its page is skipped, a field reads as unanswered (#122), except + * to a condition that skips or brings back that same page, so a page can skip itself by its own answer. + * @param {String} address e.g. `fields.abc` + * @param {String} propertyName e.g. `value`, `isChecked` + * @param {HTMLElement[]} [ownStages] stages whose fields are read as they are even while skipped + * @return {*} + */ + getComponentProperty = (address, propertyName, ownStages = []) => { const { component, option } = this.getComponent(address) || {} const elem = option || component @@ -600,6 +650,11 @@ export default class FormeoRenderer { return undefined } + const skippedPage = elem.closest?.(`[${SKIPPED_ATTR}]`) + if (skippedPage && !ownStages.includes(skippedPage) && Object.hasOwn(SKIPPED_PAGE_READS, propertyName)) { + return SKIPPED_PAGE_READS[propertyName] + } + // a mapped property must win even when it legitimately resolves to false or an empty value return propertyMap[propertyName] ? propertyMap[propertyName](elem) : elem[propertyName] } diff --git a/src/lib/js/renderer/pagination.test.js b/src/lib/js/renderer/pagination.test.js index bb8d4aad..518e03d3 100644 --- a/src/lib/js/renderer/pagination.test.js +++ b/src/lib/js/renderer/pagination.test.js @@ -1153,11 +1153,13 @@ describe('pagination (#122)', () => { try { for (const pagination of [undefined, 'wizard']) { warn.mock.resetCalls() + // b on p-2 skips p-1, then p-2 itself: a skipped page's answers no longer drive other pages, so the + // source must stay in play for the second skip to reach the guard const data = buildPages([[field('a')], [field('b')]]) - skipWhen(data, 'skip', 'p-1') - skipWhen(data, 'skip', 'p-2') + skipWhen(data, 'skip', 'p-1', { source: 'b' }) + skipWhen(data, 'skip', 'p-2', { on: 'p-2', source: 'b' }) render(pagination, data) - typeInto(input('a'), 'skip') + typeInto(input('b'), 'skip') assert.deepEqual(skipped(), [true, false], String(pagination)) assert.equal(warn.mock.callCount(), 1, String(pagination)) } @@ -1209,6 +1211,74 @@ describe('pagination (#122)', () => { assert.equal(otherStage.hasAttribute('data-skipped'), false) }) + // p-1: a skips p-2 when 'skip'; p-2: b == 'yes' shows p-3, anything else skips it + const vatPages = () => { + const data = buildPages([[field('a')], [field('b')], [field('c')]], ['One', 'Company', 'VAT']) + skipWhen(data, 'skip', 'p-2') + const clause = comparison => [{ source: 'fields.b', sourceProperty: 'value', comparison, target: 'yes' }] + data.stages['p-2'].conditions = [ + { if: clause('=='), then: [{ target: 'stages.p-3', targetProperty: 'isVisible' }] }, + { if: clause('!='), then: [{ target: 'stages.p-3', targetProperty: 'isNotVisible' }] }, + ] + return data + } + + test("a skipped page's answers read as unanswered (#122)", () => { + const renderer = render('wizard', vatPages()) + typeInto(input('b'), 'yes') + assert.deepEqual(skipped(), [false, false, false]) + typeInto(input('a'), 'skip') + assert.deepEqual(skipped(), [false, true, true]) + assert.equal( + renderer.evaluateCondition({ source: 'fields.b', sourceProperty: 'value', comparison: '==', target: 'yes' }), + false + ) + assert.equal(renderer.getComponentProperty('fields.b', 'isVisible'), false) + }) + + test('its answers count again when the page comes back', () => { + render('wizard', vatPages()) + typeInto(input('b'), 'yes') + typeInto(input('a'), 'skip') + typeInto(input('a'), '') + assert.deepEqual(skipped(), [false, false, false]) + }) + + test('skipping a page re-runs only the conditions that read it', () => { + const data = vatPages() + // an unrelated value action on p-1: a == 'skip' fills c with 'auto' + data.stages['p-1'].conditions.push({ + if: [{ source: 'fields.a', sourceProperty: 'value', comparison: '==', target: 'skip' }], + then: [{ target: 'fields.c', targetProperty: 'value', assignment: '=', value: 'auto' }], + }) + render('wizard', data) + typeInto(input('a'), 'skip') + typeInto(input('c'), 'mine') + typeInto(input('b'), 'yes') // b is on the skipped p-2; nothing that reads a should re-run + typeInto(input('a'), '') // p-2 comes back: only conditions watching p-2 (b) re-run + assert.equal(input('c').value, 'mine') + }) + + test('a page skipped by its own answer stays skipped, while other pages read that answer as unanswered', () => { + // p-2 skips itself when b says so; p-1 shows p-3 only when b == 'skip' + const data = skipWhen(buildPages([[field('a')], [field('b')], [field('c')]]), 'skip', 'p-2', { + on: 'p-2', + source: 'b', + }) + const clause = comparison => [{ source: 'fields.b', sourceProperty: 'value', comparison, target: 'skip' }] + data.stages['p-1'].conditions = [ + { if: clause('=='), then: [{ target: 'stages.p-3', targetProperty: 'isVisible' }] }, + { if: clause('!='), then: [{ target: 'stages.p-3', targetProperty: 'isNotVisible' }] }, + ] + const renderer = render('wizard', data) + assert.deepEqual(skipped(), [false, false, true]) + typeInto(input('b'), 'skip') + // p-2's own conditions read b as it is, so its "bring back" condition doesn't bounce it straight back + assert.deepEqual(skipped(), [false, true, true]) + assert.equal(renderer.getComponentProperty('fields.b', 'value'), '') + assert.equal(renderer.getComponentProperty('fields.b', 'value', [stage('p-2')]), 'skip') + }) + describe('navigation', () => { const next = () => container.querySelector('.formeo-pages-next') const previous = () => container.querySelector('.formeo-pages-previous') diff --git a/tests/renderer-pagination.spec.js b/tests/renderer-pagination.spec.js index b743e6c6..c7a7a435 100644 --- a/tests/renderer-pagination.spec.js +++ b/tests/renderer-pagination.spec.js @@ -348,4 +348,33 @@ test.describe('multi-page forms (#122)', () => { // Company is required and empty, so Next stays put await expect(root.locator('input[name="company"]')).toBeVisible() }) + + test("wizard: a skipped page's answers stop showing the pages that depend on them", async ({ page: browserPage }) => { + // "person" skips Company; the VAT page is shown only when "vat" (on Company) is "yes" + const vatForm = buildPages([ + page(1, [input('kind')], 'About you'), + page(2, [input('vat')], 'Company'), + page(3, [input('vat-number')], 'VAT'), + ]) + const vatClause = comparison => [{ source: 'fields.vat', sourceProperty: 'value', comparison, target: 'yes' }] + vatForm.stages['p-1'].conditions = skipForm.stages['p-1'].conditions + vatForm.stages['p-2'].conditions = [ + { if: vatClause('=='), then: [{ target: 'stages.p-3', targetProperty: 'isVisible' }] }, + { if: vatClause('!='), then: [{ target: 'stages.p-3', targetProperty: 'isNotVisible' }] }, + ] + const root = await mount(browserPage, vatForm, 'wizard') + const steps = root.getByRole('list', { name: 'Progress' }).getByRole('button') + await root.getByRole('button', { name: 'Next' }).click() + await root.locator('input[name="vat"]').fill('yes') + await expect(steps).toHaveText(['About you', 'Company', 'VAT']) + + await root.getByRole('button', { name: 'Previous' }).click() + await root.locator('input[name="kind"]').fill('person') + await expect(steps).toHaveText(['About you']) + await expect(root.locator('.formeo-pages-status')).toHaveText('About you (1 of 1)') + + await root.locator('input[name="kind"]').fill('') + await expect(steps).toHaveText(['About you', 'Company', 'VAT']) + await expect(root.locator('.formeo-pages-status')).toHaveText('About you (1 of 3)') + }) }) From 97c61b8245692c5bd823d1782f1b826a8942154f Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 11:26:44 +0100 Subject: [PATCH 3/5] fix(renderer): decide each page action's own-page reads separately The own-page exemption applied to the whole condition, so a condition showing its own page and another one read its own page's answers as they are for both, and wasn't re-run when its page was skipped. Now only an action that skips or brings back a page reads that page as it is; every other action reads a skipped page as unanswered. A page's skip re-runs the conditions watching it that act on any other target, leaving that page's own actions alone. destroy() drops the runners. The re-run test now uses a value action whose source still matches when the page comes back, so it fails if every condition re-runs. Docs note that value actions reading a page can fire as it's skipped. Refs #122 --- docs/renderer/renderer.md | 4 +- src/lib/js/renderer/index.js | 53 ++++++++++------ src/lib/js/renderer/pagination.test.js | 84 ++++++++++++++++++++++++-- src/lib/js/renderer/renderer.test.js | 12 ++++ 4 files changed, 129 insertions(+), 24 deletions(-) diff --git a/docs/renderer/renderer.md b/docs/renderer/renderer.md index d2fd5d28..f02b6fbb 100644 --- a/docs/renderer/renderer.md +++ b/docs/renderer/renderer.md @@ -555,7 +555,9 @@ While a page is skipped, conditions read its fields as unanswered: an empty valu answers stay on the page and count again if it comes back. So if "Account type" skips the Company page, a VAT page shown only when "VAT registered?" (a field on the Company page) is "yes" drops out along with Company, and returns with it. A condition that skips or brings back a page still reads that page's own fields as they are, so a page can -skip itself. +skip itself. Conditions reading a page's fields run again whenever it's skipped or comes back, so a `value` action +driven by its answers can fire then too: "VAT registered?" `!=` "yes" setting another field to "none" sets it as +Company is skipped. A skipped page can hold the author's own submit field. With `submit: false` (the default), skipping the page holding it disables that button along with every other control on the page, leaving the form with no enabled submit — Enter diff --git a/src/lib/js/renderer/index.js b/src/lib/js/renderer/index.js index b3f57db9..6908c527 100644 --- a/src/lib/js/renderer/index.js +++ b/src/lib/js/renderer/index.js @@ -204,6 +204,7 @@ export default class FormeoRenderer { this.renderedForm?.remove() this.renderedForm = null this.components = Object.create(null) + this.conditionRunners = [] } getRenderedForm(formData = this.form) { @@ -511,24 +512,33 @@ export default class FormeoRenderer { applyCondition = ({ if: ifConditions = [], then: thenConditions = [] }) => { const clauseGroups = groupIfConditions(ifConditions) - // the pages this condition skips or brings back: it reads their fields as they are, or skipping a page by its own - // answer would make them read as unanswered and bring the page straight back - const ownStages = thenConditions - .filter(({ target }) => isAddress(target) && splitAddress(target)[0] === 'stages') - .map(({ target }) => this.getComponent(target)?.component) - .filter(Boolean) + // an action that skips or brings back a page reads that page's own fields as they are, or skipping a page by its + // own answer would make them read as unanswered and bring the page straight back. Every other action, even one in + // the same condition, reads a skipped page's fields as unanswered. + const actions = thenConditions.map(action => ({ action, page: this.stageTargetOf(action) })) // a `value` action fires `input` on its target; when the condition watches that target, // the event would re-enter run and set the value again, forever let running = false - const run = evt => { + /** + * @param {Event|{target: null}} evt + * @param {HTMLElement} [exceptPage] a page whose skip state just changed; its own actions are left alone + */ + const run = (evt, exceptPage) => { if (running) { return } running = true try { - if (this.evaluateClauseGroups(clauseGroups, ownStages)) { - for (const thenCondition of thenConditions) { - this.execResult(thenCondition, evt) + // every clause is read before any action runs, so one action can't change what the next one sees + const matches = new Map() + for (const { page } of actions) { + if (!matches.has(page)) { + matches.set(page, this.evaluateClauseGroups(clauseGroups, page ? [page] : [])) + } + } + for (const { action, page } of actions) { + if (page !== exceptPage && matches.get(page)) { + this.execResult(action, evt) } } } finally { @@ -542,20 +552,29 @@ export default class FormeoRenderer { this.listenForChanges(options || component, run) } const watched = [...watchedAddresses].map(address => this.getComponent(address)?.component).filter(Boolean) - this.conditionRunners.push({ watched, ownStages, run }) + this.conditionRunners.push({ watched, pages: actions.map(({ page }) => page), run }) run({ target: null }) } + /** + * @param {Object} action a then-action + * @return {HTMLElement|null} the stage it skips or brings back, null for any other action + */ + stageTargetOf = ({ target }) => + isAddress(target) && splitAddress(target)[0] === 'stages' ? (this.getComponent(target)?.component ?? null) : null + /** * A page's skip state changes what its fields read as, so re-runs the conditions watching anything on it. Only - * those: re-running every condition would re-apply unrelated `value` actions over the user's later input. + * those: re-running every condition would re-apply unrelated `value` actions over the user's later input. The + * actions that skip or bring back that same page read it as it is, so they're left alone rather than undoing the + * skip that caused the re-run. * @param {HTMLElement} stage */ rerunConditionsReading = stage => { - for (const { watched, ownStages, run } of this.conditionRunners) { - if (!ownStages.includes(stage) && watched.some(component => stage.contains(component))) { - run({ target: null }) + for (const { watched, pages, run } of this.conditionRunners) { + if (pages.some(page => page !== stage) && watched.some(component => stage.contains(component))) { + run({ target: null }, stage) } } } @@ -590,7 +609,7 @@ export default class FormeoRenderer { /** * Evaulate conditions * @param {Object} clause one if-clause - * @param {HTMLElement[]} [ownStages] stages the condition skips or brings back; see getComponentProperty + * @param {HTMLElement[]} [ownStages] stages the action being decided skips or brings back; see getComponentProperty * @return {Boolean} */ evaluateCondition = ({ source, sourceProperty, targetProperty, comparison, target }, ownStages = []) => { @@ -635,7 +654,7 @@ export default class FormeoRenderer { /** * Reads a property of a rendered component. While its page is skipped, a field reads as unanswered (#122), except - * to a condition that skips or brings back that same page, so a page can skip itself by its own answer. + * to an action that skips or brings back that same page, so a page can skip itself by its own answer. * @param {String} address e.g. `fields.abc` * @param {String} propertyName e.g. `value`, `isChecked` * @param {HTMLElement[]} [ownStages] stages whose fields are read as they are even while skipped diff --git a/src/lib/js/renderer/pagination.test.js b/src/lib/js/renderer/pagination.test.js index 518e03d3..3b1007cb 100644 --- a/src/lib/js/renderer/pagination.test.js +++ b/src/lib/js/renderer/pagination.test.js @@ -1245,20 +1245,92 @@ describe('pagination (#122)', () => { }) test('skipping a page re-runs only the conditions that read it', () => { - const data = vatPages() - // an unrelated value action on p-1: a == 'skip' fills c with 'auto' + const data = buildPages([[field('a'), field('d')], [field('b')], [field('c')]]) + skipWhen(data, 'skip', 'p-2') + // an unrelated value action on p-1: d == 'x' fills c with 'auto'; it still matches when p-2 comes back data.stages['p-1'].conditions.push({ - if: [{ source: 'fields.a', sourceProperty: 'value', comparison: '==', target: 'skip' }], + if: [{ source: 'fields.d', sourceProperty: 'value', comparison: '==', target: 'x' }], then: [{ target: 'fields.c', targetProperty: 'value', assignment: '=', value: 'auto' }], }) render('wizard', data) - typeInto(input('a'), 'skip') + typeInto(input('d'), 'x') typeInto(input('c'), 'mine') - typeInto(input('b'), 'yes') // b is on the skipped p-2; nothing that reads a should re-run - typeInto(input('a'), '') // p-2 comes back: only conditions watching p-2 (b) re-run + typeInto(input('a'), 'skip') + typeInto(input('a'), '') // p-2 comes back: re-running d's condition would overwrite the user's 'mine' assert.equal(input('c').value, 'mine') }) + test('a condition showing a page and the next one neither undoes its skip nor keeps the next page in play', () => { + // p-1: a skips p-2; p-2: b == 'yes' shows p-2 and p-3 in one condition, b != 'yes' skips p-3 + const data = skipWhen(buildPages([[field('a')], [field('b')], [field('c')]]), 'skip', 'p-2') + const clause = comparison => [{ source: 'fields.b', sourceProperty: 'value', comparison, target: 'yes' }] + data.stages['p-2'].conditions = [ + { + if: clause('=='), + then: [ + { target: 'stages.p-2', targetProperty: 'isVisible' }, + { target: 'stages.p-3', targetProperty: 'isVisible' }, + ], + }, + { if: clause('!='), then: [{ target: 'stages.p-3', targetProperty: 'isNotVisible' }] }, + ] + render('wizard', data) + typeInto(input('b'), 'yes') + assert.deepEqual(skipped(), [false, false, false]) + typeInto(input('a'), 'skip') + assert.deepEqual(skipped(), [false, true, true]) + }) + + test("an action on another page reads a skipped page as unanswered, even beside that page's own action", () => { + // p-1: a skips p-2, d is just another answer; p-2: b == 'yes' and d == 'go' skips p-2 and shows p-3 in ONE + // condition, b != 'yes' skips p-3 + const data = skipWhen(buildPages([[field('a'), field('d')], [field('b')], [field('c')]]), 'skip', 'p-2') + const b = comparison => ({ source: 'fields.b', sourceProperty: 'value', comparison, target: 'yes' }) + data.stages['p-2'].conditions = [ + { + if: [ + b('=='), + { source: 'fields.d', sourceProperty: 'value', comparison: '==', target: 'go', logical: '&&' }, + ], + then: [ + { target: 'stages.p-2', targetProperty: 'isNotVisible' }, + { target: 'stages.p-3', targetProperty: 'isVisible' }, + ], + }, + { if: [b('!=')], then: [{ target: 'stages.p-3', targetProperty: 'isNotVisible' }] }, + ] + render('wizard', data) + typeInto(input('b'), 'yes') + typeInto(input('a'), 'skip') + assert.deepEqual(skipped(), [false, true, true]) + // d re-runs the condition while p-2 is skipped: skipping p-2 again reads b as it is, showing p-3 must not + typeInto(input('d'), 'go') + assert.deepEqual(skipped(), [false, true, true]) + }) + + test('a condition that also acts on its own page still re-runs for its other pages when that page is skipped', () => { + // p-1: a skips p-2; p-2: b == 'yes' shows p-3, b != 'yes' skips p-3 and keeps p-2 in ONE condition + const data = skipWhen(buildPages([[field('a')], [field('b')], [field('c')]]), 'skip', 'p-2') + const clause = comparison => [{ source: 'fields.b', sourceProperty: 'value', comparison, target: 'yes' }] + data.stages['p-2'].conditions = [ + { if: clause('=='), then: [{ target: 'stages.p-3', targetProperty: 'isVisible' }] }, + { + if: clause('!='), + then: [ + { target: 'stages.p-3', targetProperty: 'isNotVisible' }, + { target: 'stages.p-2', targetProperty: 'isVisible' }, + ], + }, + ] + render('wizard', data) + typeInto(input('b'), 'yes') + typeInto(input('a'), 'skip') + // re-run for p-3 only: its p-2 action is left alone, so it doesn't undo the skip + assert.deepEqual(skipped(), [false, true, true]) + typeInto(input('a'), '') + assert.deepEqual(skipped(), [false, false, false]) + }) + test('a page skipped by its own answer stays skipped, while other pages read that answer as unanswered', () => { // p-2 skips itself when b says so; p-1 shows p-3 only when b == 'skip' const data = skipWhen(buildPages([[field('a')], [field('b')], [field('c')]]), 'skip', 'p-2', { diff --git a/src/lib/js/renderer/renderer.test.js b/src/lib/js/renderer/renderer.test.js index d7c4878f..f80593b6 100644 --- a/src/lib/js/renderer/renderer.test.js +++ b/src/lib/js/renderer/renderer.test.js @@ -1075,6 +1075,18 @@ describe('FormeoRenderer', () => { assert.equal(container.querySelectorAll('.formeo-render').length, 1) }) + test("releases the conditions' runners, so a later page skip can't run them", () => { + const renderer = new FormeoRenderer({ renderContainer: container }) + const formData = emptyStageForm() + formData.stages['s-1'].conditions = [ + { if: [{ source: 'fields.x', sourceProperty: 'value', comparison: '==', target: 'y' }], then: [] }, + ] + renderer.render(formData) + assert.equal(renderer.conditionRunners.length, 1) + renderer.destroy() + assert.deepEqual(renderer.conditionRunners, []) + }) + test('is safe to call twice or before render()', () => { const renderer = new FormeoRenderer({ renderContainer: container }) assert.doesNotThrow(() => { From 7694e844e33a75217e3ba5e5d1b8529fb0ab85dc Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 11:26:44 +0100 Subject: [PATCH 4/5] chore(deps): match the lockfile's formeo-languages range to package.json Refs #122 --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 236a17e8..98aed600 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "5.9.2", "license": "MIT", "dependencies": { - "@draggable/formeo-languages": "3.7.0", + "@draggable/formeo-languages": "^3.7.0", "@draggable/i18n": "^1.0.7", "@draggable/tooltip": "^1.2.2", "lodash": "^4.17.21", From 5b5dd3765fde8df3c0017ae139e7a7cbf05c832d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:12:54 +0000 Subject: [PATCH 5/5] fix(renderer): rerun self-skip conditions on page unskip Co-authored-by: kevinchappell <1457540+kevinchappell@users.noreply.github.com> --- src/lib/js/renderer/index.js | 20 ++++++++++++-------- src/lib/js/renderer/pagination.test.js | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/lib/js/renderer/index.js b/src/lib/js/renderer/index.js index 6908c527..5f6930a9 100644 --- a/src/lib/js/renderer/index.js +++ b/src/lib/js/renderer/index.js @@ -521,9 +521,9 @@ export default class FormeoRenderer { let running = false /** * @param {Event|{target: null}} evt - * @param {HTMLElement} [exceptPage] a page whose skip state just changed; its own actions are left alone + * @param {(runnerAction: {action: Object, page: HTMLElement|null}) => boolean} [skipAction] */ - const run = (evt, exceptPage) => { + const run = (evt, skipAction = () => false) => { if (running) { return } @@ -536,8 +536,9 @@ export default class FormeoRenderer { matches.set(page, this.evaluateClauseGroups(clauseGroups, page ? [page] : [])) } } - for (const { action, page } of actions) { - if (page !== exceptPage && matches.get(page)) { + for (const runnerAction of actions) { + const { action, page } = runnerAction + if (!skipAction(runnerAction) && matches.get(page)) { this.execResult(action, evt) } } @@ -552,7 +553,7 @@ export default class FormeoRenderer { this.listenForChanges(options || component, run) } const watched = [...watchedAddresses].map(address => this.getComponent(address)?.component).filter(Boolean) - this.conditionRunners.push({ watched, pages: actions.map(({ page }) => page), run }) + this.conditionRunners.push({ watched, run }) run({ target: null }) } @@ -572,9 +573,12 @@ export default class FormeoRenderer { * @param {HTMLElement} stage */ rerunConditionsReading = stage => { - for (const { watched, pages, run } of this.conditionRunners) { - if (pages.some(page => page !== stage) && watched.some(component => stage.contains(component))) { - run({ target: null }, stage) + for (const { watched, run } of this.conditionRunners) { + if (watched.some(component => stage.contains(component))) { + run({ target: null }, ({ action, page }) => { + const stageSkip = action && Object.hasOwn(STAGE_SKIP_PROPERTIES, action.targetProperty) + return page === stage && stageSkip && !STAGE_SKIP_PROPERTIES[action.targetProperty] + }) } } } diff --git a/src/lib/js/renderer/pagination.test.js b/src/lib/js/renderer/pagination.test.js index 3b1007cb..9d8188d3 100644 --- a/src/lib/js/renderer/pagination.test.js +++ b/src/lib/js/renderer/pagination.test.js @@ -1351,6 +1351,22 @@ describe('pagination (#122)', () => { assert.equal(renderer.getComponentProperty('fields.b', 'value', [stage('p-2')]), 'skip') }) + test('bringing back a self-skipping page re-runs its self-skip condition', () => { + const data = skipWhen(buildPages([[field('a')], [field('b')], [field('c')]]), 'skip', 'p-2') + const clause = comparison => [{ source: 'fields.b', sourceProperty: 'value', comparison, target: 'skip' }] + data.stages['p-2'].conditions = [ + { if: clause('=='), then: [{ target: 'stages.p-2', targetProperty: 'isNotVisible' }] }, + { if: clause('!='), then: [{ target: 'stages.p-2', targetProperty: 'isVisible' }] }, + ] + render('wizard', data) + typeInto(input('b'), 'skip') + assert.deepEqual(skipped(), [false, true, false]) + typeInto(input('a'), 'skip') + assert.deepEqual(skipped(), [false, true, false]) + typeInto(input('a'), '') + assert.deepEqual(skipped(), [false, true, false]) + }) + describe('navigation', () => { const next = () => container.querySelector('.formeo-pages-next') const previous = () => container.querySelector('.formeo-pages-previous')