Skip to content

feat(DF-830): conditional payment amounts editor#1422

Open
mokhld wants to merge 14 commits intomainfrom
feat/df-830-payment-v2-designer
Open

feat(DF-830): conditional payment amounts editor#1422
mokhld wants to merge 14 commits intomainfrom
feat/df-830-payment-v2-designer

Conversation

@mokhld
Copy link
Copy Markdown
Contributor

@mokhld mokhld commented May 6, 2026

Adds a conditional payment amounts editor to PaymentField so editors can configure different charges per matched condition. All ticket flows verified.

mokhld added 11 commits May 1, 2026 10:08
…ow gaps

Three real bugs surfaced during /review with codex + claude adversarial passes:

1. mergeConditionalAmountsIntoOptions previously bailed out when state had
   zero conditional amounts. If a user opened the editor for a form with
   existing conditionalAmounts and removed them all, the save merge skipped
   the write and the on-disk JSON kept the old values. Distinguish "no
   session knowledge" (state.conditionalAmounts === undefined → preserve
   on-disk) from "user emptied the list" (state.conditionalAmounts === [] →
   write empty array so the deletion persists).

2. The POST handler reads state directly via getQuestionSessionState. If
   the yar session was evicted between GET and POST (Redis TTL, server
   restart, cookie reissue), state would be empty and saveQuestion would
   write a PaymentField stripped of its conditionalAmounts. Re-hydrate
   state.conditionalAmounts from the persisted component before merging,
   using the existing idempotent helper. The hydrate is a no-op when the
   session already has knowledge.

3. handleEnhancedActionOnPost dispatches conditional-amount actions before
   any state rebuild, but did not stage the form payload's questionDetails
   into session. So if the user typed paymentAmount/paymentDescription/etc
   and then clicked "Add payment amount", the typed values were lost on the
   redirect. Stage questionDetails into session before each conditional-
   amount action, mirroring the existing AddItem flow for radios.

Also tighten the helper guards to use ComponentType.PaymentField instead of
the string literal "PaymentField" for consistency with the rest of the
codebase.
If a user opens the editor in two tabs and removes a tile in tab A, then
clicks Save on the open edit form for that same tile in tab B, the editRow.id
points at an item that's no longer in state.conditionalAmounts. Previously
this would fall through to upsertConditionalAmount which appends a new entry,
silently turning what the user thought was an edit into a fresh insert under
a misleading "Payment amount 1" label.

Detect the stale id explicitly (matchedIndex === -1), and in that case mint a
fresh UUID and use items.length + 1 for the dynamic error label, mirroring
the Add path. Adds a test pinning the behaviour.
…faults

Three issues observed in local QA:

1. Save inside the inline conditional-amount form errored with "Enter item
   text". The radioText payload validator was firing for any enhancedAction
   not in [add-item, re-order] - which now includes the new conditional-
   amount actions. Inverted the gate to require radioText only on save-item
   (the radios save action). The new logic is also clearer: radioText is
   required if and only if the user is saving a list item.

2. The default payment amount input rendered blank instead of "0" because
   getPaymentAmount used a truthy check on `options.amount`, dropping the
   value when amount === 0. Treat 0 as a valid number and default to "0"
   for new components, matching the Figma. Non-zero amounts still render
   with two decimals via toFixed(2).

3. The "Add payment amount" button was rendering with the secondary
   (grey) variant. The Figma uses the GDS primary (green) action. Drop the
   --secondary class so it inherits the primary style consistent with the
   editor's other "add" buttons.
… condition select

handlePrecision in lib/utils.js used a falsy check (!value) to reject undefined
inputs, which also rejected the legitimate value 0. The base paymentAmountSchema
now allows min(0) so amount=0 is a valid Save and continue payload, but the
custom precision validator was firing 'any.required' for it (rendering as
"Enter a payment amount"). Tighten the guard to only reject undefined/non-numbers
and add a regression test.

Also visually-hide the "Select an existing condition" label on the inline
conditional-amount form. The dropdown already shows that text as the placeholder
option in its collapsed state, matching the Figma. The visible label was
duplicating that copy. Keep it as a screen-reader label so the field stays
accessible.
The page-overview listing iterated definition.pages in their natural array
order, which put the PaymentField page above Check your answers if that's how
the JSON happened to be saved. Per Figma node 2262-14406, the listing should
mirror the runtime flow: question pages, then Summary (Check your answers),
then Payment.

Sort the listed pages by a display rank (questions=0, Summary=1, Payment=2,
other end pages=3), with original index breaking ties so question pages keep
their authored order. pageNum still comes from the original definition.pages
index so the labels (Page 1..N) stay stable.
Previously, clicking Save and continue while the inline conditional-amount
form was open would discard the in-flight draft and only emit base PaymentField
validation errors (e.g. "Enter a payment description"). Figma node 2129-44794
shows the inline conditional fields should also be validated and their errors
surfaced alongside the base errors.

Three changes:

1. New helper buildInlineConditionalAmountError(request, stateId) in
   payment-conditional-amount-actions.js. Returns a Joi.ValidationError for
   the in-flight inline conditional fields (errors remapped to UI ids, with
   the dynamic "Payment amount {N}" label) when the edit row is expanded and
   the values fail validation. Returns null otherwise.

2. failAction (Hapi payload validation fails first, e.g. empty description)
   augments the validation error with inline conditional errors so they all
   show together in the error summary banner.

3. The POST handler (Hapi payload passes) calls handleSaveConditionalAmount
   when the inline form is still expanded. Valid inline fields auto-save as
   a new tile and the save flow proceeds; invalid inline fields flash the
   inline errors and redirect back to the editor with the inline form open.

Also fixes two ancillary bugs surfaced during live QA:

- The selectattr filter in payment-conditional-amount-edit.njk is Jinja2,
  not Nunjucks. It was silently no-op'ing and returning the first error in
  errorList for every field, so both inline fields displayed the same
  message. Switched to direct lookup on formErrors.

- overrideStateIfJsEnabled in question-details.js replaced the entire session
  state with only {questionType, editRow, listItems} when client JS sets
  jsEnabled=true (which application.js does unconditionally). That wiped
  conditionalAmounts and conditionalAmountEditRow on every validation
  redirect. Spread existing state so unrelated fields survive.
Three real issues surfaced by /review on this branch's polish phase:

1. pageDisplayRank had unreachable code and missed terminal pages.
   isAnEndPage in @defra/forms-model is summary || payment, both already
   checked above the third branch — that branch was dead code, AND pages
   with controller: ControllerType.Terminal fell through to question-page
   rank, mixing exit pages with question pages in the form overview.
   Replace with explicit ControllerType.Terminal check; introduce named
   DISPLAY_RANK_* constants so the order is documentable rather than four
   bare integers.

2. failAction did not persist the user's typed inline conditional-amount
   values into session before redirecting. If the inline form was open
   AND a base-field validation error fired (e.g. empty paymentDescription),
   the user's typed conditionalAmount/Condition values were lost on the
   redirect because handleSaveConditionalAmount (which DOES persist them)
   never ran. New persistInlineConditionalAmountDraft helper writes the
   payload values back to state.conditionalAmountEditRow before redirect.

3. overrideStateIfJsEnabled spread preserved unrelated session state
   correctly for same-type-question redirects, but if the user switched
   questionType mid-session (e.g. PaymentField → RadiosField), then
   triggered a JS-enabled validation redirect, conditionalAmounts and
   conditionalAmountEditRow leaked into the new question-type's session
   bag. Now we drop carry-over when existing.questionType differs from
   the payload's questionType.
Adds tests for the gaps surfaced by the testing specialist in /review:

- pages.test.js: mapPageData renders Summary before Payment regardless of
  authored array order, and pageNum stays tied to the original
  definition.pages index after the sort
- base-settings-fields.test.js: getFieldValue returns "0" for
  paymentAmount=0 (DF-832 zero-bypass) and for missing amount (new
  component default)
- base-settings-fields.test.js: radioText required only on
  enhancedAction=save-item; not required on add-item, re-order,
  add/save/cancel-conditional-amount, or absent enhancedAction (regression
  guard for the conditional-amount actions that the previous over-broad
  gate was blocking)
- payment-conditional-amount-actions.test.js: handleRemoveConditionalAmount
  no-op paths (undefined id, unknown id)
- payment-conditional-amount-actions.test.js: buildInlineConditionalAmountError
  cases (collapsed → null; valid payload → null; invalid payload → remapped
  Joi error; stale id → labelIndex = items.length+1)
- payment-conditional-amount-actions.test.js:
  persistInlineConditionalAmountDraft writes payload values to editRow
  when expanded; no-op when collapsed or absent
- question-details.test.js: overrideStateIfJsEnabled preserves
  conditionalAmounts/conditionalAmountEditRow when same questionType,
  drops them when type changed, no-op when jsEnabled !== "true". Wires
  the real session-helper read/write into the auto-mocked module via
  jest.requireActual so the carry-over rule can be exercised.
PaymentDomElements.values must satisfy PaymentSettings, which now requires
paymentConditionalAmounts. The client-side preview reads typeable DOM
inputs (paymentAmount, paymentDescription) for live updates as the user
types; conditional amounts are server-side session state, so the client
returns [] to satisfy the type. The actual list flows through on the
next server render.
Bugs surfaced by /review specialists, Codex adversarial, and Claude
adversarial passes:

- Hydrate state.conditionalAmounts from the persisted component before
  the inline Save and continue path so a session-evicted save can no
  longer overwrite on-disk tiles with a single new entry.
- Stage the typed base fields (paymentAmount, paymentDescription, API
  keys) into session before the inline-error redirect so they survive
  the round trip and reappear in the editor.
- handleRemoveConditionalAmount no longer rewrites session state when
  the requested id does not match an existing tile (prevents undefined
  -> [] flips that would wipe disk tiles on the next save).
- Validate the chosen condition exists in the form's conditions list
  and reject duplicate conditions across tiles. Both errors land on
  the conditionalAmountCondition field with friendly messages.
- Cascade-clean PaymentField references when a condition is deleted,
  so the manager's deletion succeeds and PaymentField components no
  longer hold dangling condition ids.
- Page list and global summary fall back to the first conditional
  amount when the base amount is 0 and conditional amounts exist,
  matching the runtime preview behaviour.
- Export PAYMENT_CONDITIONAL_AMOUNTS_ANCHOR and import it in the route
  to remove the duplicated literal.

Adds integration tests covering the four POST behaviours: enhanced
action staging, Save and continue with the inline form expanded
(success and failure legs), failAction merge of base + inline errors,
and hydration on session eviction.
…2-designer

# Conflicts:
#	designer/client/src/javascripts/preview/payment.js
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 6, 2026

🧪 Acceptance Test Results

❌ Some acceptance tests failed

View Details

- Add braces to single-line ifs in pageDisplayRank.
- Extract getDisplayAmount helper from getPaymentInfo.
- Extract getEditRowContext shared between handleSaveConditionalAmount
  and buildInlineConditionalAmountError.
- Extract dispatchConditionalAmountAction from handleEnhancedActionOnPost.
- Extract buildOverridenState from overrideStateIfJsEnabled.
- Extract processInlineConditionalAmountSave from the POST handler.

No behaviour change; tests + lint + types pass.
@mokhld mokhld force-pushed the feat/df-830-payment-v2-designer branch from 5c8d3da to ce3892e Compare May 6, 2026 10:11
mokhld added 2 commits May 6, 2026 11:24
- Extract dispatchRadiosListAction from handleEnhancedActionOnPost.
- Extract handleSaveQuestionError from the POST handler catch block.

Pure structural extraction. No behaviour change.
- removePaymentConditionalAmountReference: PUT path, missing component,
  no matching condition, no conditionalAmounts at all.
- formatPaymentTotal: amount-zero with first conditional fallback,
  amount-zero with empty list.
- pageDisplayRank: Terminal page sorts after Payment.
- handleSaveConditionalAmount semantic validation: unknown condition,
  duplicate condition, same tile keeps its own condition on edit.
- condition-delete POST: cascade-cleans PaymentField references before
  deleting the condition.
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 6, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for configuring and persisting conditional payment amounts on PaymentField in Editor v2, including inline editor actions, validation, and ensuring references are cleaned up when related conditions are deleted.

Changes:

  • Adds/updates Editor v2 routes and actions to create/edit/remove conditional payment amount “tiles”, including session hydration/merge behavior and failAction error merging.
  • Updates models to display a meaningful payment amount when the base amount is £0.00 (fallback to first conditional amount where applicable) and adjusts form overview page display ordering.
  • Adds cascade cleanup when deleting a condition so any PaymentField.options.conditionalAmounts[] references to that condition are removed before deletion.

Reviewed changes

Copilot reviewed 21 out of 22 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
designer/server/src/views/forms/editor-v2/custom-templates/payment-conditional-amounts.njk Tweaks “Add payment amount” button styling in the conditional amounts UI.
designer/server/src/views/forms/editor-v2/custom-templates/payment-conditional-amount-edit.njk Switches inline edit error wiring to formErrors and hides the select label visually.
designer/server/src/routes/forms/editor-v2/question-details.test.js Adds coverage for JS-enabled state overrides and PaymentField conditional-amount POST integration.
designer/server/src/routes/forms/editor-v2/question-details.js Implements inline conditional-amount save interception, state hydration/merge updates, and refactors error handling.
designer/server/src/routes/forms/editor-v2/question-details-helper.test.js Extends enhanced-action tests to cover conditional-amount actions staging details into session.
designer/server/src/routes/forms/editor-v2/question-details-helper.js Routes conditional-amount enhanced actions via a shared dispatcher and passes definition where needed.
designer/server/src/routes/forms/editor-v2/payment-conditional-amount-actions.test.js Adds tests for stale edit ids, semantic validation, draft persistence, and inline error building.
designer/server/src/routes/forms/editor-v2/payment-conditional-amount-actions.js Adds semantic (definition-aware) validation, draft persistence, and improved edit-context handling.
designer/server/src/routes/forms/editor-v2/condition-delete.test.js Adds test ensuring PaymentField conditional-amount references are cleaned before deleting a condition.
designer/server/src/routes/forms/editor-v2/condition-delete.js Performs cascade cleanup of PaymentField conditional-amount references before condition deletion.
designer/server/src/models/forms/editor-v2/preview-helpers.js Displays a fallback payment amount when the base amount is zero.
designer/server/src/models/forms/editor-v2/pages.test.js Adds tests for page display order sorting and payment total fallback behavior.
designer/server/src/models/forms/editor-v2/pages.js Implements display-order sorting for pages and formats Payment totals with fallback logic.
designer/server/src/models/forms/editor-v2/base-settings-fields.test.js Adds regression tests for zero payment amounts and radioText requiredness gating.
designer/server/src/models/forms/editor-v2/base-settings-fields.js Adjusts radioText requiredness to save-item only; ensures paymentAmount value handling supports 0.
designer/server/src/lib/utils.test.js Adds test ensuring handlePrecision accepts zero.
designer/server/src/lib/utils.js Updates handlePrecision to treat 0 as a valid number input.
designer/server/src/lib/payment-conditional-amount-helpers.test.js Updates merge semantics tests for conditionalAmounts undefined vs empty array.
designer/server/src/lib/payment-conditional-amount-helpers.js Adds state hydration from persisted component and refines merge semantics for conditional amounts.
designer/server/src/lib/editor.test.js Adds tests for removing PaymentField conditional-amount references during condition deletion.
designer/server/src/lib/editor.js Adds removePaymentConditionalAmountReference to update components by removing a deleted condition reference.
.gitignore Ignores .gstack/ directory.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

setQuestionSessionState(yar, stateId, hydrated)
}
}
handleSaveConditionalAmount(request, stateId)
* @param {string | undefined} listItemsData
* @returns {QuestionSessionState}
*/
function buildOverridenState(existing, questionType, listItemsData) {
Comment on lines +76 to +85
for (const ref of paymentFields) {
await removePaymentConditionalAmountReference(
formId,
token,
definition,
ref.pageId,
ref.componentId,
conditionId
)
}
Comment on lines +118 to +132
/**
* @param {PaymentFieldComponent} component
* @returns {string}
*/
function formatPaymentTotal(component) {
const amount = component.options.amount
if (amount > 0) {
return formatCurrency(amount)
}
const conditional = component.options.conditionalAmounts ?? []
if (conditional.length > 0) {
return formatCurrency(conditional[0].amount)
}
return formatCurrency(0)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants