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
2 changes: 1 addition & 1 deletion docs/features/configuration-based/page-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The payload takes the following shape:
```jsonc
{
"meta": {
"schemaVersion": "2",
"schemaVersion": 1,
"timestamp": "2025-03-25T10:00:00Z",
"definition": {
// This object would be a full copy of the form definition at the time of submission. It is excluded for brevity.
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 @@ -92,7 +92,7 @@
},
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@defra/forms-model": "^3.0.698",
"@defra/forms-model": "^3.0.704",
"@defra/hapi-tracing": "^1.29.0",
"@defra/interactive-map": "0.0.33-alpha",
"@elastic/ecs-pino-format": "^1.5.0",
Expand Down
31 changes: 25 additions & 6 deletions src/server/plugins/engine/models/FormModel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ComponentType,
ConditionEvaluationOutcome,
ConditionsModel,
ControllerPath,
ControllerType,
Expand All @@ -8,6 +9,7 @@ import {
formDefinitionSchema,
formDefinitionV2Schema,
generateConditionAlias,
getErrorMessage,
hasComponents,
hasComponentsEvenIfNoNext,
hasRepeater,
Expand Down Expand Up @@ -54,7 +56,10 @@ import { extractBaseTranslations } from '~/src/server/plugins/engine/i18n/extrac
import { createFormI18nInstance } from '~/src/server/plugins/engine/i18n/index.js'
import { getAvailableLanguages } from '~/src/server/plugins/engine/i18n/languages.js'
import { type Translator } from '~/src/server/plugins/engine/i18n/types.js'
import { type ExecutableCondition } from '~/src/server/plugins/engine/models/types.js'
import {
type ConditionEvaluation,
type ExecutableCondition
} from '~/src/server/plugins/engine/models/types.js'
import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'
import {
createPage,
Expand Down Expand Up @@ -304,21 +309,35 @@ export class FormModel {
throw new ConditionBuildError(displayName, { cause })
}

const fn = (evaluationState: FormState) => {
const evaluate = (evaluationState: FormState): ConditionEvaluation => {
const ctx = this.toConditionContext(evaluationState, this.conditions)

try {
return expr.evaluate(ctx) as boolean
} catch {
return false
return {
outcome: (expr.evaluate(ctx) as boolean)
? ConditionEvaluationOutcome.True
: ConditionEvaluationOutcome.False
}
} catch (err) {
return {
outcome: ConditionEvaluationOutcome.Error,
error: getErrorMessage(err)
}
}
}

// A failed evaluation continues to route as `false`. `evaluate` exists so
// that the two can be told apart when recording outcomes for submission.
const fn = (evaluationState: FormState) =>
evaluate(evaluationState).outcome === ConditionEvaluationOutcome.True

return {
name,
displayName,
value,
expr,
fn
fn,
evaluate
}
}

Expand Down
31 changes: 31 additions & 0 deletions src/server/plugins/engine/models/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type ConditionEvaluationOutcome,
type ConditionWrapper,
type FormComponentsDef,
type Section
Expand All @@ -16,9 +17,39 @@ import {
type FormSubmissionError
} from '~/src/server/plugins/engine/types.js'

/**
* The result of evaluating a condition, keeping a failed evaluation distinct
* from one that legitimately returned `false`.
* @see {@link ExecutableCondition.evaluate}
*/
export interface ConditionEvaluation {
outcome: ConditionEvaluationOutcome
error?: string
}

/**
* A form condition paired with the parsed expression and callbacks needed to
* run it against a form submission state
* Created by `FormModel.makeCondition`
*/
export type ExecutableCondition = ConditionWrapper & {
/**
* Parsed expression for the condition's {@link ConditionWrapper.value},
* evaluated against a context built from the submission state
*/
expr: Expression

/**
* Evaluates the condition, used for page routing and component visibility
* A failed evaluation is reported as `false`
*/
fn: (evaluationState: FormState) => boolean

/**
* As `fn`, but reports whether evaluation failed rather than defaulting a
* failure to `false`. Used to record condition outcomes on submission.
*/
evaluate: (evaluationState: FormState) => ConditionEvaluation
}

/**
Expand Down
90 changes: 89 additions & 1 deletion src/server/plugins/engine/outputFormatters/adapter/v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ import {
import { format } from '~/src/server/plugins/engine/outputFormatters/adapter/v1.js'
import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js'
import { FormAdapterSubmissionSchemaVersion } from '~/src/server/plugins/engine/types/index.js'
import { formAdapterSubmissionMessagePayloadSchema } from '~/src/server/plugins/engine/types/schema.js'
import {
FileStatus,
UploadStatus,
type FileState,
type FormAdapterSubmissionMessagePayload
type FormAdapterSubmissionMessagePayload,
type FormContext
} from '~/src/server/plugins/engine/types.js'
import { FormStatus } from '~/src/server/routes/types.js'
import joinedConditionsDefinition from '~/test/form/definitions/joined-conditions-simple-v2.js'
import definition from '~/test/form/definitions/repeat-mixed.js'

const submitResponse = {
Expand Down Expand Up @@ -871,3 +874,88 @@ describe('Adapter v1 formatter', () => {
})
})
})

describe('conditionEvaluations', () => {
const formStatus = {
isPreview: false,
state: FormStatus.Live
}

const formMetadata = {
id: '68a8b0449ab460290c28940a',
slug: 'order-a-pizza',
notificationEmail: 'submissions@example.com'
} as FormMetadata

const v2Model = new FormModel(joinedConditionsDefinition, {
basePath: 'test'
})

// The formatter only reads the reference number, translator and evaluation
// state from the context, so the full page-walk state is not needed here
const v2Context = {
referenceNumber: 'foobar',
evaluationState: { userName: 'Bob', isOverEighteen: true }
} as unknown as FormContext

const formatV2Definition = () =>
JSON.parse(
format(
v2Context,
items,
v2Model,
submitResponse,
formStatus,
formMetadata
)
) as FormAdapterSubmissionMessagePayload

const formatV1Definition = () =>
JSON.parse(
format(
context,
items,
model,
submitResponse as SubmitResponsePayload,
formStatus,
formMetadata
)
) as FormAdapterSubmissionMessagePayload

it('should record the outcome of every condition for a V2 definition', () => {
const { conditionEvaluations } = formatV2Definition()

expect(conditionEvaluations).toHaveLength(3)
expect(conditionEvaluations?.[0]).toMatchObject({
conditionId: 'd15aff7a-6224-40a2-8e5f-51a5af2f7910',
outcome: 'true',
references: [
{
componentId: '87b987e8-bcf9-4ff9-92af-57c34c45995a',
componentName: 'userName',
answered: true
}
]
})
})

it('should be empty, not absent, for a V1 definition', () => {
// forms-notify-listener treats an absent property as "this message
// predates conditional emails" and resolves the recipients itself, so
// every message published from here has to carry the property
expect(formatV1Definition().conditionEvaluations).toEqual([])
})

it('should produce a payload the schema accepts', () => {
// The runner publishes with allowUnknown: false and throws on failure, so
// a formatter and schema that disagree would fail every submission
for (const payload of [formatV1Definition(), formatV2Definition()]) {
const { error } = formAdapterSubmissionMessagePayloadSchema.validate(
payload,
{ abortEarly: false, allowUnknown: false }
)

expect(error).toBeUndefined()
}
})
})
16 changes: 15 additions & 1 deletion src/server/plugins/engine/outputFormatters/adapter/v1.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
Engine,
type FormMetadata,
type SubmitResponsePayload
} from '@defra/forms-model'
Expand All @@ -11,6 +12,7 @@ import {
import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
import { type DetailItem } from '~/src/server/plugins/engine/models/types.js'
import { categoriseData } from '~/src/server/plugins/engine/outputFormatters/machine/v2.js'
import { buildConditionEvaluations } from '~/src/server/plugins/engine/pageControllers/helpers/submission.js'
import { FormAdapterSubmissionSchemaVersion } from '~/src/server/plugins/engine/types/enums.js'
import {
type FormAdapterSubmissionMessageData,
Expand Down Expand Up @@ -73,7 +75,19 @@ export function format(
const payload: FormAdapterSubmissionMessagePayload = {
meta,
data,
result
result,

// Recorded here because only the engine holds the walked evaluation state
// the conditions were judged against; forms-notify-listener receives the
// flat submitted answers and resolves the outputs that qualify from these
// outcomes alone. Condition ids are only stable in V2, so a V1 definition
// has nothing to report - the property is still emitted, because its
// absence is what marks a message as predating this and sends the listener
// down its legacy path.
conditionEvaluations:
model.engine === Engine.V2
? buildConditionEvaluations(model, context)
: []
}

return JSON.stringify(payload)
Expand Down
Loading
Loading