Skip to content

Commit 8e2d540

Browse files
committed
feat(QuestionStepper): add stepper component enabled by OdkWebForm stepperLayout prop
1 parent 608ab4d commit 8e2d540

File tree

2 files changed

+171
-3
lines changed

2 files changed

+171
-3
lines changed

packages/web-forms/src/components/OdkWebForm.vue

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,21 @@ import { FormInitializationError } from '../lib/error/FormInitializationError.ts
1414
import FormLoadFailureDialog from './Form/FormLoadFailureDialog.vue';
1515
import FormHeader from './FormHeader.vue';
1616
import QuestionList from './QuestionList.vue';
17+
import QuestionStepper from './QuestionStepper.vue';
1718
1819
const webFormsVersion = __WEB_FORMS_VERSION__;
1920
2021
interface OdkWebFormsProps {
2122
formXml: string;
2223
fetchFormAttachment: FetchFormAttachment;
24+
25+
/**
26+
* Note: by default all questions will be displayed in a single list,
27+
* with collapsable groups. This param changes to a stepper layout
28+
* closer to Collect.
29+
*/
30+
stepperLayout?: boolean;
31+
2332
missingResourceBehavior?: MissingResourceBehavior;
2433
2534
/**
@@ -29,7 +38,9 @@ interface OdkWebFormsProps {
2938
submissionMaxSize?: number;
3039
}
3140
32-
const props = defineProps<OdkWebFormsProps>();
41+
const props = withDefaults(defineProps<OdkWebFormsProps>(), {
42+
stepperLayout: false,
43+
});
3344
3445
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- evidently a type must be used for this to be assigned to a name (which we use!); as an interface, it won't satisfy the `Record` constraint of `defineEmits`.
3546
type OdkWebFormEmits = {
@@ -105,6 +116,7 @@ const emit = defineEmits<OdkWebFormEmits>();
105116
106117
const odkForm = ref<RootNode>();
107118
const submitPressed = ref(false);
119+
const showSendButton = ref(props.stepperLayout ? false : true);
108120
const initializeFormError = ref<FormInitializationError | null>();
109121
110122
initializeForm(props.formXml, {
@@ -197,13 +209,14 @@ watchEffect(() => {
197209
<template #content>
198210
<div class="form-questions">
199211
<div class="flex flex-column gap-2">
200-
<QuestionList :nodes="odkForm.currentState.children" />
212+
<QuestionList v-if="!stepperLayout" :nodes="odkForm.currentState.children" />
213+
<QuestionStepper v-if="stepperLayout" :nodes="odkForm.currentState.children" @endOfForm="showSendButton=true" />
201214
</div>
202215
</div>
203216
</template>
204217
</Card>
205218

206-
<div class="footer flex justify-content-end flex-wrap gap-3">
219+
<div v-if="showSendButton" class="footer flex justify-content-end flex-wrap gap-3">
207220
<!-- maybe current state is in odkForm.state.something -->
208221
<Button label="Send" rounded @click="handleSubmit()" />
209222
</div>
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<script setup lang="ts">
2+
import { ref, computed, watch, provide } from 'vue';
3+
import type { ComponentPublicInstance } from 'vue';
4+
import type {
5+
AnyControlNode,
6+
AnyUnsupportedControlNode,
7+
GeneralChildNode,
8+
GroupNode,
9+
RepeatRangeNode,
10+
} from '@getodk/xforms-engine';
11+
import FormGroup from './FormGroup.vue';
12+
import FormQuestion from './FormQuestion.vue';
13+
import RepeatRange from './RepeatRange.vue';
14+
import ExpectModelNode from './dev-only/ExpectModelNode.vue';
15+
import Steps from 'primevue/steps';
16+
import ProgressBar from 'primevue/progressbar';
17+
import Button from 'primevue/button';
18+
19+
const props = defineProps<{ nodes: readonly GeneralChildNode[] }>();
20+
const emit = defineEmits(['endOfForm']);
21+
22+
const isGroupNode = (node: GeneralChildNode): node is GroupNode => {
23+
return node.nodeType === 'group';
24+
};
25+
26+
type NonGroupNode = Exclude<GeneralChildNode, GroupNode>;
27+
28+
const isRepeatRangeNode = (node: NonGroupNode): node is RepeatRangeNode => {
29+
return (
30+
node.nodeType === 'repeat-range:controlled' || node.nodeType === 'repeat-range:uncontrolled'
31+
);
32+
};
33+
34+
type NonStructuralNode = Exclude<NonGroupNode, RepeatRangeNode>;
35+
36+
type ControlNode = AnyControlNode | AnyUnsupportedControlNode;
37+
38+
const isControlNode = (node: NonStructuralNode): node is ControlNode => {
39+
const { nodeType } = node;
40+
41+
return (
42+
nodeType === 'input' ||
43+
nodeType === 'note' ||
44+
nodeType === 'select' ||
45+
nodeType === 'trigger' ||
46+
nodeType === 'range' ||
47+
nodeType === 'rank' ||
48+
nodeType === 'upload'
49+
);
50+
};
51+
52+
// Compute step items (only groups, repeat ranges, and control nodes should be steps)
53+
const relevantNodes = computed(() =>
54+
props.nodes.filter(node => node.currentState.relevant)
55+
);
56+
const steps = computed(() =>
57+
relevantNodes.value
58+
.filter(node => isGroupNode(node) || isRepeatRangeNode(node) || isControlNode(node))
59+
);
60+
61+
// Handle stepper state
62+
const currentStep = ref(0);
63+
const isCurrentStepValidated = ref(true);
64+
const submitPressed = ref(false);
65+
provide('submitPressed', submitPressed);
66+
67+
const validateStep = () => {
68+
// Manually trigger submitPressed to display error messages
69+
submitPressed.value = true;
70+
71+
const currentNode = steps.value[currentStep.value];
72+
if (isGroupNode(currentNode) && currentNode.validationState.violations.length > 0) {
73+
isCurrentStepValidated.value = false;
74+
} else if (currentNode.validationState.violation) {
75+
isCurrentStepValidated.value = false;
76+
} else {
77+
isCurrentStepValidated.value = true;
78+
}
79+
}
80+
const nextStep = () => {
81+
validateStep();
82+
83+
if (isCurrentStepValidated.value && currentStep.value < steps.value.length - 1) {
84+
// Reset validation triggered later in the form
85+
submitPressed.value = false;
86+
// Also reset validation state of current node
87+
isCurrentStepValidated.value = true;
88+
currentStep.value++;
89+
}
90+
};
91+
const prevStep = () => {
92+
if (currentStep.value > 0) {
93+
currentStep.value--;
94+
}
95+
};
96+
const isLastStep = computed(() => currentStep.value === steps.value.length - 1);
97+
watch(isLastStep, (newValue) => {
98+
emit('endOfForm', newValue);
99+
});
100+
101+
// // Calculate stepper progress
102+
// const totalNodes = computed(() =>
103+
// Math.max(relevantNodes.value.length - 1, 1) // Ensure at least 1 to avoid division by zero
104+
// );
105+
//
106+
// const currentNodeIndex = computed(() => {
107+
// const activeNode = steps.value[currentStep.value];
108+
// if (!activeNode) return 0;
109+
//
110+
// // Find the current node's position among relevant nodes
111+
// const index = relevantNodes.value.findIndex(node => node.nodeId === activeNode.nodeId);
112+
// return Math.max(index, 0); // Ensure it starts at 0
113+
// });
114+
//
115+
// const progress = computed(() => {
116+
// if (totalNodes.value === 1) return 100; // If there's only one relevant node, treat it as complete
117+
// return (currentNodeIndex.value / totalNodes.value) * 100;
118+
// });
119+
</script>
120+
121+
<template>
122+
<!-- TODO -->
123+
<!-- <ProgressBar :value="progress"></ProgressBar> -->
124+
125+
<div class="stepper-container">
126+
<div v-for="(step, index) in steps" :key="step.nodeId">
127+
<template v-if="index === currentStep">
128+
<!-- Render group nodes -->
129+
<FormGroup v-if="isGroupNode(step)" :node="step" />
130+
131+
<!-- Render repeat nodes -->
132+
<RepeatRange v-else-if="isRepeatRangeNode(step)" :node="step" />
133+
134+
<!-- Render individual questions -->
135+
<FormQuestion v-else-if="isControlNode(step)" :question="step" />
136+
137+
<ExpectModelNode v-else :node="step" />
138+
</template>
139+
</div>
140+
141+
<div class="navigation-buttons">
142+
<Button label="Previous" @click="prevStep" :disabled="currentStep === 0" />
143+
<Button label="Next" @click="nextStep" :disabled="isCurrentStepValidated && currentStep === steps.length - 1" />
144+
</div>
145+
</div>
146+
</template>
147+
148+
<style scoped lang="scss">
149+
.navigation-buttons {
150+
display: flex;
151+
justify-content: space-between;
152+
width: 100%;
153+
margin-top: 1rem;
154+
}
155+
</style>

0 commit comments

Comments
 (0)