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
14 changes: 3 additions & 11 deletions packages/scenario/src/assertion/extensions/answers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts';
import { getBlobText } from '@getodk/common/lib/web-compat/blob.ts';
import { constants, type ValidationCondition } from '@getodk/xforms-engine';
import { type ValidationCondition } from '@getodk/xforms-engine';
import { assert, expect } from 'vitest';
import { ComparableAnswer } from '../../answer/ComparableAnswer.ts';
import { ExpectedApproximateUOMAnswer } from '../../answer/ExpectedApproximateUOMAnswer.ts';
Expand Down Expand Up @@ -40,24 +40,16 @@ const assertAnswerResult: AssertAnswerResult = (value) => {
};

const matchDefaultMessage = (condition: ValidationCondition) => {
const expectedMessage = constants.VALIDATION_TEXT[`${condition}Msg`];

return {
node: {
validationState: {
[condition]: {
valid: false,
message: {
origin: 'engine',
asString: expectedMessage,
},
message: null,
},
violation: {
condition,
message: {
origin: 'engine',
asString: expectedMessage,
},
message: null,
},
},
},
Expand Down
8 changes: 8 additions & 0 deletions packages/web-forms/locales/strings_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@
"string": "Longitude: {longitude}.",
"developer_comment": "Displays the GPS longitude coordinate. {longitude} is the numeric longitude value."
},
"validation_message.required.error": {
"string": "This field is required.",
"developer_comment": "Default error shown when a required field has no value and the form designer did not specify a custom message."
},
"validation_message.constraint.error": {
"string": "This value doesn't meet the constraint.",
"developer_comment": "Default error shown when a field's value fails a constraint and the form designer did not specify a custom message."
},
"map_async.load_error.message": {
"string": "Unable to load map",
"developer_comment": "Error message shown when the map component fails to load."
Expand Down
8 changes: 8 additions & 0 deletions packages/web-forms/locales/strings_es.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@
"string": "Longitud: {longitude}.",
"developer_comment": "Displays the GPS longitude coordinate. {longitude} is the numeric longitude value."
},
"validation_message.required.error": {
"string": "Este campo es obligatorio.",
"developer_comment": "Default error shown when a required field has no value and the form designer did not specify a custom message."
},
"validation_message.constraint.error": {
"string": "No cumple los requisitos.",
"developer_comment": "Default error shown when a field's value fails a constraint and the form designer did not specify a custom message."
},
"map_async.load_error.message": {
"string": "No se puede cargar el mapa",
"developer_comment": "Error message shown when the map component fails to load."
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"validation_message.required.error": {
"string": "This field is required.",
"developer_comment": "Default error shown when a required field has no value and the form designer did not specify a custom message."
},
"validation_message.constraint.error": {
"string": "This value doesn't meet the constraint.",
"developer_comment": "Default error shown when a field's value fails a constraint and the form designer did not specify a custom message."
}
}
29 changes: 23 additions & 6 deletions packages/web-forms/src/components/common/ValidationMessage.vue
Original file line number Diff line number Diff line change
@@ -1,30 +1,47 @@
<script setup lang="ts">
import MarkdownBlock from '@/components/common/MarkdownBlock.vue';
import { QUESTION_HAS_ERROR } from '@/lib/constants/injection-keys.ts';
import type { MarkdownNode } from '@getodk/xforms-engine';
import { QUESTION_HAS_ERROR, TRANSLATE } from '@/lib/constants/injection-keys.ts';
import type { Translate } from '@/lib/locale/useLocale.ts';
import type { AnyViolation } from '@getodk/xforms-engine';
import { computed, type ComputedRef, inject } from 'vue';

withDefaults(
const props = withDefaults(
defineProps<{
message?: MarkdownNode[];
violation?: AnyViolation | null;
addPlaceholder?: boolean;
}>(),
{
message: undefined,
violation: undefined,
addPlaceholder: true,
}
);

const t: Translate = inject(TRANSLATE)!;
const showMessage = inject<ComputedRef<boolean>>(
QUESTION_HAS_ERROR,
computed(() => false)
);

const defaultMessage = computed(() => {
if (!props.violation || props.violation.message) {
return null;
}

if (props.violation.condition === 'required') {
return t('validation_message.required.error')
}

return t('validation_message.constraint.error');
});
</script>

<template>
<div :class="{ 'validation-placeholder': addPlaceholder }">
<span v-show="showMessage" class="validation-message">
<MarkdownBlock v-for="elem in message" :key="elem.id" :elem="elem" />
<template v-if="violation?.message">
<MarkdownBlock v-for="elem in violation.message.formatted" :key="elem.id" :elem="elem" />
</template>
<template v-else-if="defaultMessage">{{ defaultMessage }}</template>
</span>
</div>
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ const onDragEnd = (oldIndex: number | undefined, newIndex: number | undefined) =
</VueDraggable>
</div>

<ValidationMessage :message="question.validationState.violation?.message.formatted" />
<ValidationMessage :violation="question.validationState.violation" />
</template>

<style scoped lang="scss">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const isFormEditMode = inject(IS_FORM_EDIT_MODE);
<InputText :node="node" />
</template>
</div>
<ValidationMessage :message="node.validationState.violation?.message.formatted" />
<ValidationMessage :violation="node.validationState.violation" />
</template>

<style scoped lang="scss">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ watchEffect(() => {
</template>

<ValidationMessage
:message="question.validationState.violation?.message.formatted"
:violation="question.validationState.violation"
:add-placeholder="!hasFieldListRelatedAppearance"
/>
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ watchEffect(() => {
</template>

<ValidationMessage
:message="question.validationState.violation?.message.formatted"
:violation="question.validationState.violation"
:add-placeholder="!hasFieldListRelatedAppearance"
/>
</template>
Expand Down
9 changes: 8 additions & 1 deletion packages/web-forms/src/lib/locale/useLocale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ const resolveUILocaleCandidates = (formLanguage?: FormLanguage): string[] => {
return browserLanguages;
};

const findDefaultFormLanguage = (languages: FormLanguage[]) => {
return languages.find((lang) => lang.isDefault);
};

const findBrowserFormLanguage = (languages: FormLanguage[]) => {
const browserLanguages = navigator.languages ?? [navigator.language];
for (const lang of browserLanguages) {
Expand Down Expand Up @@ -215,7 +219,10 @@ export const useLocale = (formRef: Ref<RootNode | null>) => {
return;
}
const formLanguage =
findSavedFormLanguage(langs) ?? findBrowserFormLanguage(langs) ?? langs[0]!;
findSavedFormLanguage(langs) ??
findDefaultFormLanguage(langs) ??
findBrowserFormLanguage(langs) ??
langs[0]!;
setLanguage(formLanguage);
},
{ immediate: true }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ const mountComponent = async (questionNumber: number, submitPressed = false) =>
props: { question },
global: {
...globalMountOptions,
provide: { [SUBMIT_PRESSED]: ref(submitPressed), [IS_FORM_EDIT_MODE]: shallowRef(false) },
provide: {
...globalMountOptions.provide,
[SUBMIT_PRESSED]: ref(submitPressed),
[IS_FORM_EDIT_MODE]: shallowRef(false),
},
},
attachTo: document.body,
});
Expand All @@ -34,7 +38,7 @@ describe('InputControl', () => {
await input.setValue('lorem ipsum');
await input.setValue('');
expect(component.get('.validation-message').isVisible()).toBe(true);
expect(component.get('.validation-message').text()).toBe('Condition not satisfied: required');
expect(component.get('.validation-message').text()).toBe('validation_message.required.error');
});

it('hides validation message when user enters a valid value', async () => {
Expand All @@ -47,7 +51,7 @@ describe('InputControl', () => {
it('shows validation message on submit pressed even when no interaction is made with the component', async () => {
const component = await mountComponent(0, true);
expect(component.get('.validation-message').isVisible()).toBe(true);
expect(component.get('.validation-message').text()).toBe('Condition not satisfied: required');
expect(component.get('.validation-message').text()).toBe('validation_message.required.error');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ describe('SelectControl', () => {
const component = mountComponent(selectNode, true);

expect(component.get('.validation-message').isVisible()).toBe(true);
expect(component.get('.validation-message').text()).toBe('Condition not satisfied: required');
expect(component.get('.validation-message').text()).toBe('validation_message.required.error');
});
});
});
21 changes: 17 additions & 4 deletions packages/web-forms/tests/lib/locale/useLocale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ describe('useLocale', () => {
// onUnmounted state pollution (document.documentElement.lang) across tests
let wrappers: Array<ReturnType<typeof mount>> = [];

const makeLanguage = (language: string, localeCode: string): FormLanguage => ({
const makeLanguage = (language: string, localeCode: string, isDefault = false): FormLanguage => ({
isDefault,
language,
locale: new Intl.Locale(localeCode),
});
Expand Down Expand Up @@ -48,7 +49,7 @@ describe('useLocale', () => {
wrappers = [];
});

describe('language priority order (saved > browser > first)', () => {
describe('language priority order (saved > designer default > browser > first)', () => {
it('prefers saved locale over browser language', () => {
localStorage.setItem(STORAGE_KEY as string, 'fr');
vi.spyOn(navigator, 'languages', 'get').mockReturnValue(['en']);
Expand All @@ -59,7 +60,19 @@ describe('useLocale', () => {
expect(document.documentElement.lang).toBe('fr');
});

it('uses browser language when no saved locale', () => {
it('prefers designer default over browser language when no saved locale', () => {
vi.spyOn(navigator, 'languages', 'get').mockReturnValue(['en']);

const formRef = makeFormRef([
makeLanguage('English', 'en'),
makeLanguage('French', 'fr', true),
]);
mountLocale(formRef);

expect(document.documentElement.lang).toBe('fr');
});

it('uses browser language when no saved locale and no designer default', () => {
vi.spyOn(navigator, 'languages', 'get').mockReturnValue(['jp']);

const formRef = makeFormRef([makeLanguage('English', 'en'), makeLanguage('Japanese', 'jp')]);
Expand All @@ -68,7 +81,7 @@ describe('useLocale', () => {
expect(document.documentElement.lang).toBe('jp');
});

it('falls back to first available language when no saved locale and no browser match', () => {
it('falls back to first available language when no saved locale, no designer default, and no browser match', () => {
vi.spyOn(navigator, 'languages', 'get').mockReturnValue(['de']);

const formRef = makeFormRef([makeLanguage('English', 'en'), makeLanguage('French', 'fr')]);
Expand Down
6 changes: 6 additions & 0 deletions packages/xforms-engine/src/client/FormLanguage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ interface BaseFormLanguage {
// similar functionality, but we'll want to seriously consider how best to
// accomplish this if/as it becomes a priority.
readonly locale?: Intl.Locale | undefined;

/**
* Indicates the language explicitly designated as default by the form designer via
* the `default` attribute on an `<translation>` element.
*/
readonly isDefault: boolean;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/xforms-engine/src/client/NoteNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export interface NoteNodeState<V extends ValueType> extends BaseValueNodeState<N
*/
readonly readonly: true;

get label(): TextRange<'label', 'form'> | null;
get hint(): TextRange<'hint', 'form'> | null;
get label(): TextRange<'label'> | null;
get hint(): TextRange<'hint'> | null;
get children(): null;
get valueOptions(): null;

Expand Down
31 changes: 1 addition & 30 deletions packages/xforms-engine/src/client/TextRange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,34 +81,6 @@ export type ElementTextRole = 'hint' | 'label' | 'item-label';
export type ValidationTextRole = 'constraintMsg' | 'requiredMsg';
export type TextRole = ElementTextRole | ValidationTextRole;

/**
* Specifies the origin of a {@link TextRange}.
*
* - 'form': text is computed from the form definition, as specified for the
* {@link TextRole}. User-facing clients should present text with this origin
* where appropriate.
*
* - 'form-derived': the form definition lacks a text definition for the
* {@link TextRole}, but an appropriate one has been derived from a related
* (and semantically appropriate) aspect of the form (example: a select item
* without a label may derive that label from the item's value). User-facing
* clients should generally present text with this origin where provided; this
* origin clarifies the source of such text.
*
* - 'engine': the form definition lacks a definition for the {@link TextRole},
* but provides a constant default in its absence. User facing clients may
* disregard these constant text values, or may use them where a sensible
* default is desired. Clients may also use these constants as keys for
* translation purposes, as appropriate. Non-user facing clients may reference
* these constants for e.g. testing purposes.
*/
// prettier-ignore
export type TextOrigin =
// eslint-disable-next-line @typescript-eslint/sort-type-constituents
| 'form'
| 'form-derived'
| 'engine';

/**
* Represents aspects of a form which produce text, which _might_ be:
*
Expand Down Expand Up @@ -139,8 +111,7 @@ export type TextOrigin =
* a text range's role may correspond to the "short" or "guidance" `form` of a
* {@link https://getodk.github.io/xforms-spec/#languages | translation}).
*/
export interface TextRange<Role extends TextRole, Origin extends TextOrigin = TextOrigin> {
readonly origin: Origin;
export interface TextRange<Role extends TextRole> {
readonly role: Role;

[Symbol.iterator](): Iterable<TextChunk>;
Expand Down
10 changes: 0 additions & 10 deletions packages/xforms-engine/src/client/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { LoadForm } from './form/LoadForm.ts';
import type { ValidationTextRole } from './TextRange.ts';

export const MISSING_RESOURCE_BEHAVIOR = {
/**
Expand Down Expand Up @@ -59,15 +58,6 @@ export type MissingResourceBehavior =
| MissingResourceBehaviorError
| MissingResourceBehaviorBlank;

export const VALIDATION_TEXT = {
constraintMsg: 'Condition not satisfied: constraint',
requiredMsg: 'Condition not satisfied: required',
} as const satisfies Record<ValidationTextRole, string>;

type ValidationTextDefaults = typeof VALIDATION_TEXT;

export type ValidationTextDefault<Role extends ValidationTextRole> = ValidationTextDefaults[Role];

export const INSTANCE_FILE_NAME = 'xml_submission_file';
export type INSTANCE_FILE_NAME = typeof INSTANCE_FILE_NAME;

Expand Down
Loading