Skip to content
Open
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
a724843
wip
garethbowen Sep 14, 2026
6c8c970
more progress
garethbowen Sep 15, 2026
adb1328
everything works
garethbowen Sep 16, 2026
9f25e79
fix tests
garethbowen Sep 16, 2026
48ad33a
fix some tests
garethbowen Sep 16, 2026
59de05e
lint
garethbowen Sep 16, 2026
e526211
fix tests and isSelfRelevant inside scope
garethbowen Sep 16, 2026
29a1254
make xforms-engine evaluator
garethbowen Sep 17, 2026
dea8517
fix test helpers
garethbowen Sep 17, 2026
a4f02e5
resolve todos
garethbowen Sep 17, 2026
05239c9
fix test
garethbowen Sep 17, 2026
55e5ada
improve and translate error message
garethbowen Sep 17, 2026
4edbfa6
changeset
garethbowen Sep 17, 2026
02752b9
lint
garethbowen Sep 17, 2026
af884f9
revert format date to throw again
garethbowen Sep 18, 2026
6c9a8f6
cleanup
garethbowen Sep 18, 2026
9c73af9
check for violations in groups, repeats, attributes
garethbowen Sep 22, 2026
a7527fc
use property names to hold different errors
garethbowen Sep 22, 2026
d475771
fix pagination blocking
garethbowen Sep 22, 2026
0f0cbe8
replace the actual root element rather than hardcoding /data
garethbowen Sep 23, 2026
28b953a
fix error message
garethbowen Sep 23, 2026
040b6e8
fix tests
garethbowen Sep 23, 2026
ed46eef
don't forget about node attribute violations
garethbowen Sep 23, 2026
4b520a8
use correct node for attribute violations
garethbowen Sep 24, 2026
6e5c5fb
be more reactive
garethbowen Sep 28, 2026
19dca79
lint warnings
garethbowen Sep 28, 2026
67d9f6e
fix some tests
garethbowen Sep 28, 2026
1068f27
more testas
garethbowen Sep 28, 2026
6cb72f4
tests
garethbowen Sep 28, 2026
cfe1831
fix smoke test
garethbowen Sep 28, 2026
9647a29
don't spam example.com
garethbowen Sep 28, 2026
4cf9e96
revert change
garethbowen Sep 28, 2026
9d7a39e
add memo for value
garethbowen Sep 28, 2026
3b128b6
various cleanups and a performance improvement
garethbowen Sep 28, 2026
cd615b9
cache errors in memo
latin-panda Sep 29, 2026
67682a5
proposal: cache expression errors in the memo
garethbowen Sep 29, 2026
fbc9cb6
use result minimally
garethbowen Sep 30, 2026
36a44ae
missed one call
garethbowen Sep 30, 2026
bc856f1
add tests
garethbowen Sep 30, 2026
0311bc2
more tests
garethbowen Sep 30, 2026
a826b71
remove error fn on accessor
garethbowen Sep 30, 2026
fe73e46
catch errors in itemset labels
garethbowen Oct 1, 2026
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
7 changes: 7 additions & 0 deletions .changeset/cool-dragons-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@getodk/xforms-engine": minor
"@getodk/web-forms": minor
"@getodk/xpath": minor
---

Improved error messages for invalid forms.
6 changes: 5 additions & 1 deletion packages/web-forms/locales/strings_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@
"string": "Next",
"developer_comment": "Label for the button that moves to the next page in a paginated form."
},
"odk_web_forms.evaluation.error": {
"string": "Error found while evaluating this form. Error message: \"{message}\". Reported by {count, plural, one {field} other {fields}}: {fields}. Please contact the person who sent you the form link.",
"developer_comment": "Form error banner message. {message} is the raw error message. {fields} is a string naming the fields affected. {count} is the number of fields reporting the error."
},
"odk_web_forms.validation.error": {
"string": "{count, plural, one {# question with error} other {# questions with errors}}",
"developer_comment": "Error banner message. {count} is the number of validation violations."
"developer_comment": "Validation error banner message. {count} is the number of validation issues."
},
"odk_web_forms.validation.view.label": {
"string": "View",
Expand Down
6 changes: 5 additions & 1 deletion packages/web-forms/src/components/OdkWebForm.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@
"string": "Next",
"developer_comment": "Label for the button that moves to the next page in a paginated form."
},
"odk_web_forms.evaluation.error": {
"string": "Error found while evaluating this form. Error message: \"{message}\". Reported by {count, plural, one {field} other {fields}}: {fields}. Please contact the person who sent you the form link.",
"developer_comment": "Form error banner message. {message} is the raw error message. {fields} is a string naming the fields affected. {count} is the number of fields reporting the error."
},
"odk_web_forms.validation.error": {
"string": "{count, plural, one {# question with error} other {# questions with errors}}",
"developer_comment": "Error banner message. {count} is the number of validation violations."
"developer_comment": "Validation error banner message. {count} is the number of validation issues."
},
"odk_web_forms.validation.view.label": {
"string": "View",
Expand Down
35 changes: 32 additions & 3 deletions packages/web-forms/src/components/OdkWebForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,28 @@ provide(REVEAL_VIOLATIONS, revealViolations);
// It returns violations for questions the user has seen.
const revealedViolations = computed(() => {
const violations = state.value.root?.validationState.violations ?? [];
const nonErrorViolations = violations.filter((violation) => violation.violation.condition !== 'error');
if (submitPressed.value) {
return violations;
return nonErrorViolations;
}
return violations.filter(({ nodeId }) => touchedQuestions.has(nodeId));
return nonErrorViolations.filter((violation) => touchedQuestions.has(violation.nodeId));
});

const errorViolations = computed(() => {
const violations = state.value.root?.validationState.violations ?? [];
const grouped = new Map<string, string[]>();
violations.forEach(error => {
const key = error.violation.condition === 'error' && error.violation.message;
if (!key) {
return;
}
const fieldReferences = grouped.get(key) ?? [];
const rootNodeName = state.value.root?.definition.nodeset;
const fieldReference = error.reference.replace(rootNodeName + '/', '');
fieldReferences.push(fieldReference);
grouped.set(key, fieldReferences);
});
return grouped;
});

const validationErrorMessage = computed(() => {
Expand All @@ -339,7 +357,11 @@ const showValidationError = computed(() => {
if (errorBannerDismissed.value) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I haven't tested it, but I think errorBannerDismissed should be set to false here so that new evaluations with errors display the banner.

@garethbowen garethbowen Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We don't currently do that for other violations until you hit the next page or submit button, right? I feel like it would just be annoying if you have explicitly dismissed it for it to pop up again, even if now the message is different.

return false;
}
return !!(validationErrorMessage.value.length || geolocationErrorMessage.value?.length);
return (
!!validationErrorMessage.value.length ||
!!geolocationErrorMessage.value?.length ||
!!errorViolations.value.size
);
});

onUnmounted(() => {
Expand Down Expand Up @@ -389,6 +411,13 @@ onUnmounted(() => {
>
<IconSVG name="mdiAlertCircleOutline" variant="error" />
<ul class="form-error-text-wrap">
<li v-for="[message, references] in errorViolations" :key="message">
{{ t('odk_web_forms.evaluation.error', {
message,
fields: references.join(', '),
count: references.length
}) }}
</li>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The most common case is a single error violation, potentially impacting multiple fields, so I've optimised the UX for that case.

<li v-if="validationErrorMessage?.length">
{{ validationErrorMessage }}
</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ const defaultMessage = computed(() => {
<div :class="{ 'validation-placeholder': addPlaceholder }">
<span v-show="showMessage" class="validation-message">
<template v-if="violation?.message">
<MarkdownBlock v-for="elem in violation.message.formatted" :key="elem.id" :elem="elem" />
<template v-if="violation?.condition === 'error'">
<span>{{ violation.message }}</span>
</template>
<template v-else>
<MarkdownBlock v-for="elem in violation.message.formatted" :key="elem.id" :elem="elem" />
</template>
</template>
<template v-else-if="defaultMessage">{{ defaultMessage }}</template>
</span>
Expand Down
16 changes: 4 additions & 12 deletions packages/web-forms/tests/components/OdkWebForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,24 +332,16 @@ describe('OdkWebForm', () => {
expect(component.get('.form-load-failure-dialog').isVisible()).toBe(true);
});

// TODO: tests failure which is currently produced by throwing a string.
// Checking the text content here is intended to ensure we are actually
// presenting the message to a user.
it('presents an error message when failing to load a form with a computation referencing an unknown XPath function', async () => {
const xpathUnknownFunctionFormXML = await getWebFormsTestFixture(
'xpath-unknown-function.xml'
);
const component = mountComponent(xpathUnknownFunctionFormXML);

await flushPromises();

const formLoadFailureDialog = component.get('.form-load-failure-dialog');

expect(formLoadFailureDialog.isVisible()).toBe(true);

const message = formLoadFailureDialog.get('.message');

expect(message.text()).toMatch(/\bnope\b/);
expectErrorBanner(
component,
'Error found while evaluating this form. Error message: "Unknown function in form definition: \'nope\'". Reported by field: first-question. Please contact the person who sent you the form link.'
);
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/xforms-engine/src/client/TextRange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export interface TextChunk {
// eslint-disable-next-line @typescript-eslint/sort-type-constituents
export type ElementTextRole = 'hint' | 'label' | 'item-label';
export type ValidationTextRole = 'constraintMsg' | 'requiredMsg';
export type TextRole = ElementTextRole | ValidationTextRole;
export type TextRole = ElementTextRole | ValidationTextRole | 'errorMsg';

/**
* Represents aspects of a form which produce text, which _might_ be:
Expand Down
49 changes: 30 additions & 19 deletions packages/xforms-engine/src/client/validation.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type { Attribute } from '../instance/Attribute.ts';
import type { BaseNode, BaseNodeState } from './BaseNode.ts';
import type { AnyNode } from './hierarchy.ts';
import type { FormNodeID } from './identity.ts';
import type { OpaqueReactiveObjectFactory } from './OpaqueReactiveObjectFactory.ts';
import type { TextRange } from './TextRange.ts';

// This interface exists so that extensions can share JSDoc for `valid`.
interface BaseValidity {
interface BaseValidity<Condition> {
readonly condition: Condition;

/**
* Specifies the unambiguous validity state for each validity condition of a
* given node, or for the derived validity of any parent node whose descendants
Expand All @@ -24,6 +28,9 @@ interface BaseValidity {
* - \* = default (expression not defined)
* - ✅ = `valid: true`
* - ❌ = `valid: false`
*
* `error` condition represents an invalid state, for example, if an xpath expression
* cannot be parsed.
*/
readonly valid: boolean;
}
Expand All @@ -33,11 +40,12 @@ interface BaseValidity {
*
* @see {@link https://getodk.github.io/xforms-spec/#bind-attributes | `constraint` and `required` bind attributes}
*/
export type ValidationCondition = 'constraint' | 'required';
export type ValidationCondition = 'constraint' | 'error' | 'required';

interface ValidationConditionMessageRoles {
readonly constraint: 'constraintMsg';
readonly required: 'requiredMsg';
readonly error: 'errorMsg';
}

export type ValidationConditionMessageRole<Condition extends ValidationCondition> =
Expand All @@ -57,23 +65,33 @@ export interface ViolationMessage<Condition extends ValidationCondition> extends
get asString(): string;
}

export interface ConditionSatisfied<Condition extends ValidationCondition> extends BaseValidity {
readonly condition: Condition;
export interface ConditionSatisfied<
Condition extends ValidationCondition,
> extends BaseValidity<Condition> {
readonly valid: true;
readonly message: null;
}

export interface ConditionViolation<Condition extends ValidationCondition> extends BaseValidity {
readonly condition: Condition;
export interface ConstraintViolation extends BaseValidity<'constraint'> {
readonly valid: false;
readonly message: ViolationMessage<'constraint'> | null;
}

export interface RequiredViolation extends BaseValidity<'required'> {
readonly valid: false;
readonly message: ViolationMessage<Condition> | null;
readonly message: ViolationMessage<'required'> | null;
}

export interface ErrorViolation extends BaseValidity<'error'> {
readonly valid: false;
readonly message: string | null;
}

export type ConditionValidation<Condition extends ValidationCondition> =
| ConditionSatisfied<Condition>
| ConditionViolation<Condition>;
| AnyViolation
| ConditionSatisfied<Condition>;

export type AnyViolation = ConditionViolation<ValidationCondition>;
export type AnyViolation = ConstraintViolation | ErrorViolation | RequiredViolation;

/**
* Represents the validation state of a leaf (or value) node.
Expand Down Expand Up @@ -129,6 +147,7 @@ export interface LeafNodeValidationState {
export interface DescendantNodeViolationReference {
readonly nodeId: FormNodeID;

get node(): AnyNode | Attribute;
get reference(): string;
get violation(): AnyViolation;
}
Expand All @@ -150,15 +169,7 @@ export interface AncestorNodeValidationState {
get violations(): readonly DescendantNodeViolationReference[];
}

/**
* Convenience interface for nodes that cannot be invalid.
*/
export interface NullValidationState {
get violations(): readonly [];
}

// prettier-ignore
export type NodeValidationState =
| AncestorNodeValidationState
| LeafNodeValidationState
| NullValidationState;
| LeafNodeValidationState;
8 changes: 6 additions & 2 deletions packages/xforms-engine/src/instance/Attribute.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { XPathNodeKindKey } from '@getodk/xpath';
import type { Accessor } from 'solid-js';
import type { AttributeNode } from '../client/AttributeNode.ts';
import type { InstanceState, NullValidationState } from '../client/index.ts';
import type { AncestorNodeValidationState, InstanceState } from '../client/index.ts';
import type { XFormsXPathAttribute } from '../integration/xpath/adapter/XFormsXPathNode.ts';
import type { StaticAttribute } from '../integration/xpath/static-dom/StaticAttribute.ts';
import { createAttributeNodeInstanceState } from '../lib/client-reactivity/instance-state/createAttributeNodeInstanceState.ts';
Expand Down Expand Up @@ -51,7 +51,7 @@ export class Attribute

protected readonly state: SharedNodeState<AttributeStateSpec>;
protected readonly engineState: EngineState<AttributeStateSpec>;
readonly validationState: NullValidationState;
readonly validationState: AncestorNodeValidationState;

readonly nodeType = 'attribute';
readonly currentState: CurrentState<AttributeStateSpec>;
Expand Down Expand Up @@ -146,6 +146,10 @@ export class Attribute
};
}

protected override canReportViolation(): boolean {
return true;
}

setValue(value: string): Root {
this.setValueState(value);

Expand Down
41 changes: 32 additions & 9 deletions packages/xforms-engine/src/instance/Root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import type {
InstancePayloadType,
} from '../client/serialization/InstancePayloadOptions.ts';
import type { InstanceState } from '../client/serialization/InstanceState.ts';
import type { AncestorNodeValidationState, BlockingViolations } from '../client/validation.ts';
import type {
AncestorNodeValidationState,
BlockingViolations,
DescendantNodeViolationReference,
} from '../client/validation.ts';
import type { XFormsXPathElement } from '../integration/xpath/adapter/XFormsXPathNode.ts';
import { createRootInstanceState } from '../lib/client-reactivity/instance-state/createRootInstanceState.ts';
import {
Expand All @@ -34,10 +38,11 @@ import { createAggregatedViolations } from '../lib/reactivity/validation/createA
import type { BodyClassList } from '../parse/body/BodyDefinition.ts';
import type { RootDefinition } from '../parse/model/RootDefinition.ts';
import { DescendantNode } from './abstract/DescendantNode.ts';
import { ValueNode } from './abstract/ValueNode.ts';
import { buildAttributes } from './buildAttributes.ts';
import { Attribute } from './Attribute.ts';
import { buildChildren } from './children/buildChildren.ts';
import type { GeneralChildNode } from './hierarchy.ts';
import type { AnyControlInstanceNode, GeneralChildNode } from './hierarchy.ts';
import type { EvaluationContext } from './internal-api/EvaluationContext.ts';
import type { ClientReactiveSerializableParentNode } from './internal-api/serialization/ClientReactiveSerializableParentNode.ts';
import type { TranslationContext } from './internal-api/TranslationContext.ts';
Expand Down Expand Up @@ -73,6 +78,17 @@ interface RootStateSpec {
readonly navigationTarget: Accessor<FormNodeID | null>;
}

const findViolationControl = (
reference: DescendantNodeViolationReference
): AnyControlInstanceNode | null => {
const { node } = reference;
const target = node.nodeType === 'attribute' ? node.owner : node;
if (target instanceof ValueNode && target.nodeType !== 'model-value') {
return target;
}
return null;
};

export class Root
extends DescendantNode<RootDefinition, RootStateSpec, PrimaryInstance, GeneralChildNode>
implements
Expand Down Expand Up @@ -221,15 +237,20 @@ export class Root
return [];
}

// Nodes without a page (model-only values) have no leaf page id, so they never block.
private isViolationOnPage(reference: DescendantNodeViolationReference, page: PageBoundary) {
const control = findViolationControl(reference);
return control != null && this.pagination.getLeafPageId(control.nodeId) === page;
}

// Only violations of controls block, other nodes (groups, model-only values) have no page.
getBlockingViolations(): BlockingViolations {
const currentPage = this.pageNavigation.currentPage();
if (currentPage == null) {
return [];
}

return this.validationState.violations.filter(({ nodeId }) => {
return this.pagination.getLeafPageId(nodeId) === currentPage;
return this.validationState.violations.filter((reference) => {
return this.isViolationOnPage(reference, currentPage);
});
}

Expand All @@ -242,17 +263,19 @@ export class Root
}

navigateToFirstViolation(): void {
const violation = this.validationState.violations?.[0];
if (violation == null) {
const control = this.validationState.violations
.map((reference) => findViolationControl(reference))
.find((violationControl) => violationControl != null);
if (control == null) {
return;
}

batch(() => {
const page = this.pagination.getLeafPageId(violation.nodeId);
const page = this.pagination.getLeafPageId(control.nodeId);
if (page != null) {
this.setCurrentPage(page);
}
this.setNavigationTarget(violation.nodeId);
this.setNavigationTarget(control.nodeId);
});
}

Expand Down
Loading
Loading