Skip to content

Commit

Permalink
Feature #130: Required and constraint validations
Browse files Browse the repository at this point in the history
  • Loading branch information
sadiqkhoja committed Jul 16, 2024
1 parent f84270e commit 838c99a
Show file tree
Hide file tree
Showing 10 changed files with 320 additions and 63 deletions.
94 changes: 94 additions & 0 deletions packages/ui-solid/fixtures/xforms/validation/1-validation.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?xml version="1.0"?>
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml"
xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:jr="http://openrosa.org/javarosa" xmlns:orx="http://openrosa.org/xforms"
xmlns:odk="http://www.opendatakit.org/xforms">
<h:head>
<h:title>Validation Form - with a very very very very very very very very very very very long
title</h:title>
<model odk:xforms-version="1.0.0">
<itext>
<translation lang="default" default="true()">
<text id="/data/profession:jr:requiredMsg">
<value>Please enter your profession</value>
</text>
<text id="/data/citizen:jr:constraintMsg">
<value>It has to be two</value>
</text>
</translation>
<translation lang="Urdu (ur)">
<text id="/data/profession:jr:requiredMsg">
<value>اپنا پروفیشن بتائیں</value>
</text>
<text id="/data/citizen:jr:constraintMsg">
<value>صرف دو ہی ہوسکتی ہیں</value>
</text>
</translation>
</itext>
<instance>
<data id="validation" version="20240708154514">
<first_name />
<last_name />
<profession />
<favourite_color />
<citizen />
<age />
<meta>
<instanceID />
</meta>
</data>
</instance>
<instance id="country">
<root>
<item>
<name>pk</name>
<label>Pakistan</label>
</item>
<item>
<name>ca</name>
<label>Canada</label>
</item>
<item>
<name>us</name>
<label>USA</label>
</item>
</root>
</instance>
<bind nodeset="/data/first_name" type="string" required="true()" />
<bind nodeset="/data/last_name" type="string" required="true()"
jr:requiredMsg="Please enter the Last Name" />
<bind nodeset="/data/profession" type="string" required="true()"
jr:requiredMsg="jr:itext('/data/profession:jr:requiredMsg')" />
<bind nodeset="/data/favourite_color" type="string" constraint=". = &quot;red&quot;" />
<bind nodeset="/data/citizen" type="string" constraint="count-selected(.) = 2"
jr:constraintMsg="jr:itext('/data/citizen:jr:constraintMsg')" />
<bind nodeset="/data/age" type="string" required="true()"
constraint=". &gt; 18 and . &lt; 100" jr:constraintMsg="Age has to be between 18 and 100" />
<bind nodeset="/data/meta/instanceID" type="string" readonly="true()" jr:preload="uid" />
</model>
</h:head>
<h:body>
<input ref="/data/first_name">
<label>First Name</label>
</input>
<input ref="/data/last_name">
<label>Last Name</label>
</input>
<input ref="/data/profession">
<label>Profession</label>
</input>
<input ref="/data/favourite_color">
<label>Favourite Color</label>
</input>
<select ref="/data/citizen">
<label>Citizenships</label>
<itemset nodeset="instance('country')/root/item">
<value ref="name" />
<label ref="label" />
</itemset>
</select>
<input ref="/data/age">
<label>Age</label>
</input>
</h:body>
</h:html>
164 changes: 117 additions & 47 deletions packages/web-forms/src/components/FormHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { type FormLanguage, type RootNode, type SyntheticDefaultLanguage } from
import PrimeButton from 'primevue/button';
import PrimeCard from 'primevue/card';
import PrimeMenu from 'primevue/menu';
import { ref } from 'vue';
import PrimeMessage from 'primevue/message';
import { computed, ref } from 'vue';
import FormLanguageDialog from './FormLanguageDialog.vue';
import FormLanguageMenu from './FormLanguageMenu.vue';
Expand All @@ -19,6 +20,14 @@ const languages = props.form.languages.filter(isFormLanguage);
const print = () => window.print();
const formErrorMessage = computed(() => {
const violationLength = props.form.validationState.violations.length;
if(violationLength === 0) return '';
else if(violationLength === 1) return '1 question with error';
else return `${violationLength} questions with errors`;
});
const items = ref([
{
label: 'Print',
Expand All @@ -38,54 +47,73 @@ if(languages.length > 0){
const handleLanguageChange = (event: FormLanguage) => {
props.form.setLanguage(event);
};
const scrollToFirstInvalidQuestion = () => {
document.getElementById(props.form.validationState.violations[0].nodeId + '_container')?.scrollIntoView({
behavior: 'smooth'
});
}
</script>

<template>
<!-- for desktop -->
<div class="hidden lg:flex justify-content-end flex-wrap gap-3 larger-screens">
<PrimeButton class="print-button" severity="secondary" rounded icon="icon-local_printshop" @click="print" />
<FormLanguageMenu
:active-language="form.currentState.activeLanguage"
:languages="languages"
@update:active-language="handleLanguageChange"
/>
</div>
<PrimeCard class="form-title hidden lg:block">
<template #content>
<!-- TODO/q: should the title be on the definition or definition.form be accessible instead of definition.bind.form -->
<h1>{{ form.definition.bind.form.title }}</h1>
<!-- last saved timestamp -->
</template>
</PrimeCard>

<!-- for mobile and tablet -->
<div class="flex lg:hidden align-items-center smaller-screens">
<h1 class="flex-grow-1">
{{ form.definition.bind.form.title }}
</h1>

<!-- for tablet -->
<div class="form-options hidden md:flex justify-content-end gap-3">
<div class="hidden lg:inline larger-screens">
<PrimeMessage v-if="formErrorMessage" severity="error" icon="icon-error_outline" class="form-error-message" :closable="false">
{{ formErrorMessage }}
<span class="fix-errors" @click="scrollToFirstInvalidQuestion()">Fix errors</span>
</PrimeMessage>
<div class="flex justify-content-end flex-wrap gap-3">
<PrimeButton class="print-button" severity="secondary" rounded icon="icon-local_printshop" @click="print" />
<FormLanguageMenu
:active-language="form.currentState.activeLanguage"
:languages="languages"
@update:active-language="handleLanguageChange"
/>
</div>
<PrimeCard class="form-title">
<template #content>
<!-- TODO/q: should the title be on the definition or definition.form be accessible instead of definition.bind.form -->
<h1>{{ form.definition.bind.form.title }}</h1>
<!-- last saved timestamp -->
</template>
</PrimeCard>
</div>


<!-- for mobile and tablet -->
<div class="inline lg:hidden smaller-screens">
<div class="flex align-items-center title-bar">
<h1 class="flex-grow-1">
{{ form.definition.bind.form.title }}
</h1>

<!-- for tablet -->
<div class="form-options hidden md:flex justify-content-end gap-3">
<PrimeButton class="print-button" severity="secondary" rounded icon="icon-local_printshop" @click="print" />
<FormLanguageMenu
:active-language="form.currentState.activeLanguage"
:languages="languages"
@update:active-language="handleLanguageChange"
/>
</div>

<!-- for mobile -->
<div class="form-options flex md:hidden">
<PrimeButton v-if="languages.length > 0" icon="icon-menu" class="btn-menu" text rounded aria-label="Menu" @click="menu?.toggle" />
<PrimeButton v-else class="print-button" severity="secondary" rounded icon="icon-local_printshop" @click="print" />
<PrimeMenu id="overlay_menu" ref="menu" :model="items" :popup="true" />
<FormLanguageDialog
v-model:state="languageDialogState"
:active-language="form.currentState.activeLanguage"
:languages="languages"
@update:active-language="handleLanguageChange"
/>
<!-- for mobile -->
<div class="form-options flex md:hidden">
<PrimeButton v-if="languages.length > 0" icon="icon-menu" class="btn-menu" text rounded aria-label="Menu" @click="menu?.toggle" />
<PrimeButton v-else class="print-button" severity="secondary" rounded icon="icon-local_printshop" @click="print" />
<PrimeMenu id="overlay_menu" ref="menu" :model="items" :popup="true" />
<FormLanguageDialog
v-model:state="languageDialogState"
:active-language="form.currentState.activeLanguage"
:languages="languages"
@update:active-language="handleLanguageChange"
/>
</div>
</div>
<PrimeMessage v-if="formErrorMessage" severity="error" icon="icon-error_outline" class="form-error-message" :closable="false">
{{ formErrorMessage }}
<span class="fix-errors" @click="scrollToFirstInvalidQuestion()">Fix errors</span>
</PrimeMessage>
</div>
</template>

Expand Down Expand Up @@ -124,22 +152,64 @@ const handleLanguageChange = (event: FormLanguage) => {
}
}
.smaller-screens {
background-color: var(--surface-0);
filter: drop-shadow(0px 2px 6px rgba(0, 0, 0, 0.15)) drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.30)) ;
.form-error-message.p-message.p-message-error {
border-radius: 10px;
background-color: var(--error-bg-color);
border: 1px solid var(--error-text-color);
width: 70%;
margin: 0rem auto 1rem auto;
position: sticky;
top: 0;
// Some PrimeVue components use z-index.
// Default value for those are either 1000 or 1100
// So 5000 here is safe.
z-index: 5000;
display: none;
:deep(.p-message-wrapper) {
padding: 0.75rem 0.75rem;
flex-grow: 1;
}
h1 {
padding-left: 10px;
font-size: 1.5rem;
:deep(.p-message-text){
font-weight: 400;
flex-grow: 1;
.fix-errors {
float: right;
cursor: pointer;
}
}
.form-options{
padding-right: 10px;
}
.smaller-screens {
.title-bar{
background-color: var(--surface-0);
filter: drop-shadow(0px 2px 6px rgba(0, 0, 0, 0.15)) drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.30)) ;
h1 {
padding-left: 10px;
font-size: 1.5rem;
}
.form-options{
padding-right: 10px;
}
.btn-menu{
color: var(--surface-900);
}
}
.btn-menu{
color: var(--surface-900);
.form-error-message.p-message.p-message-error {
margin-top: 1rem;
margin-bottom: 0;
}
}
:global(.submit-pressed .form-error-message.p-message.p-message-error ){
display: block;
}
</style>
43 changes: 42 additions & 1 deletion packages/web-forms/src/components/FormQuestion.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,52 @@ const isSelectNode = (n: AnyLeafNode) : n is SelectNode => n.nodeType === 'selec
</script>

<template>
<div class="flex flex-column gap-2">
<div :id="question.nodeId + '_container'" class="question-container" :class="{ invalid: question.validationState.violation?.valid === false}">
<InputText v-if="isStringNode(question)" :question="question" />

<SelectControl v-else-if="isSelectNode(question)" :question="question" />

<UnsupportedControl v-else :question="question" />

<div class="validation-placeholder">
<span class="validation-message">
{{ question.validationState.violation?.message.asString }}
</span>
</div>
</div>
</template>

<style scoped lang="scss">
.question-container {
display: flex;
flex-direction: column;
padding: 0.5rem 1rem 0 1rem;
scroll-margin-top: 60px;
}
.validation-placeholder{
height: 2rem;
.validation-message {
display: none;
color: var(--error-text-color);
margin-top: 0.5rem;
}
}
.invalid:has(.dirty) {
.validation-message {
display: block;
}
}
:global(.odk-form.submit-pressed .invalid){
background-color: var(--error-bg-color);
border-radius: 10px;
}
:global(.odk-form.submit-pressed .invalid .validation-message){
display: block;
}
</style>
Loading

0 comments on commit 838c99a

Please sign in to comment.