Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions docs/editor/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"

Expand Down
12 changes: 7 additions & 5 deletions docs/renderer/renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -551,11 +551,13 @@ 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. 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
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/js/components/stages/page-text.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 0 additions & 5 deletions src/lib/js/components/stages/stages-pages.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
Expand Down
98 changes: 88 additions & 10 deletions src/lib/js/renderer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -192,6 +204,7 @@ export default class FormeoRenderer {
this.renderedForm?.remove()
this.renderedForm = null
this.components = Object.create(null)
this.conditionRunners = []
}

getRenderedForm(formData = this.form) {
Expand Down Expand Up @@ -271,6 +284,7 @@ export default class FormeoRenderer {
control.disabled = false
control.removeAttribute(SKIP_DISABLED_ATTR)
}
this.rerunConditionsReading(stage)
this.pager?.refresh()
return
}
Expand All @@ -289,6 +303,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
Expand Down Expand Up @@ -478,6 +493,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
Expand All @@ -496,18 +512,34 @@ export default class FormeoRenderer {

applyCondition = ({ if: ifConditions = [], then: thenConditions = [] }) => {
const clauseGroups = groupIfConditions(ifConditions)
// 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 {(runnerAction: {action: Object, page: HTMLElement|null}) => boolean} [skipAction]
*/
const run = (evt, skipAction = () => false) => {
if (running) {
return
}
running = true
try {
if (this.evaluateClauseGroups(clauseGroups)) {
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 runnerAction of actions) {
const { action, page } = runnerAction
if (!skipAction(runnerAction) && matches.get(page)) {
this.execResult(action, evt)
}
}
} finally {
Expand All @@ -520,16 +552,44 @@ 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, 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. 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, 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]
})
}
}
}

/**
* @param {Array<Array<Object>>} 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) {
Expand All @@ -552,22 +612,27 @@ export default class FormeoRenderer {

/**
* Evaulate conditions
* @param {Object} clause one if-clause
* @param {HTMLElement[]} [ownStages] stages the action being decided 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) {
return false
}

// 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)
}
Expand All @@ -591,7 +656,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 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
* @return {*}
*/
getComponentProperty = (address, propertyName, ownStages = []) => {
const { component, option } = this.getComponent(address) || {}

const elem = option || component
Expand All @@ -600,6 +673,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]
}
Expand Down
Loading