Skip to content

Commit 35d6efc

Browse files
committed
feat: implement multi-page form
1 parent 9fa1b0a commit 35d6efc

14 files changed

+443
-71
lines changed

package-lock.json

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"ajv-formats": "2.1.1",
3434
"ajv-keywords": "5.1.0",
3535
"bootstrap": "5.1.3",
36+
"classnames": "2.3.1",
3637
"date-fns": "2.28.0",
3738
"fast-deep-equal": "3.1.3",
3839
"feather-icons": "4.28.0",
@@ -46,6 +47,7 @@
4647
"@nordicsemiconductor/asset-tracker-cloud-code-style": "11.0.19",
4748
"@nordicsemiconductor/object-to-env": "4.1.0",
4849
"@swc/jest": "0.2.17",
50+
"@types/classnames": "2.3.1",
4951
"@types/feather-icons": "4.7.0",
5052
"@types/jest": "27.4.0",
5153
"@types/react": "17.0.39",

src/app/App.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { FormGenerator } from 'app/pages/FormGenerator'
2+
import { Privacy } from 'app/pages/Privacy'
3+
import { Welcome } from 'app/pages/Welcome'
24
import { Navbar } from 'components/Navbar'
35
import { useAppConfig } from 'hooks/useAppConfig'
46
import { FormProvider } from 'hooks/useForm'
57
import { ResponseProvider } from 'hooks/useResponse'
68
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
9+
import { Assessment } from './pages/Assessment'
10+
import { Instructions } from './pages/Instructions'
711

812
export const App = () => {
913
const { basename } = useAppConfig()
@@ -13,7 +17,11 @@ export const App = () => {
1317
<Router basename={basename}>
1418
<Navbar />
1519
<Routes>
16-
<Route index element={<FormGenerator />} />
20+
<Route index element={<Welcome />} />
21+
<Route path="/instructions" element={<Instructions />} />
22+
<Route path="/assessment" element={<Assessment />} />
23+
<Route path="/privacy" element={<Privacy />} />
24+
<Route path="/generator" element={<FormGenerator />} />
1725
</Routes>
1826
</Router>
1927
</ResponseProvider>

src/app/pages/Assessment.tsx

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import type { ErrorObject } from 'ajv'
2+
import {
3+
NextIcon,
4+
OkIcon,
5+
PrevIcon,
6+
WarningIcon,
7+
} from 'components/FeatherIcons'
8+
import { FormFooter, SectionComponent } from 'components/Form'
9+
import { useForm } from 'hooks/useForm'
10+
import { isHidden, useResponse } from 'hooks/useResponse'
11+
import { useValidation } from 'hooks/useValidation'
12+
import { useEffect, useState } from 'react'
13+
import formExample from 'schema/form.example.json'
14+
import type { Form as FormDefinition, Section } from 'schema/types'
15+
import { ajv, schemaUrl } from 'utils/validateSchema'
16+
import { withLocalStorage } from 'utils/withLocalStorage'
17+
18+
const storedFormDefinition = withLocalStorage<string>({
19+
key: 'formDefinition',
20+
defaultValue: JSON.stringify(formExample, null, 2),
21+
})
22+
23+
const validate = ajv.getSchema(schemaUrl)
24+
25+
export const Assessment = () => {
26+
const [formDefinition, setFormDefinition] = useState<string>(
27+
storedFormDefinition.get(),
28+
)
29+
const [, setFormErrors] = useState<(ErrorObject | Error)[]>([])
30+
const [, setFormValid] = useState<boolean>(false)
31+
const { form: parsedFormDefinition, setForm: setParsedFormDefinition } =
32+
useForm()
33+
34+
useEffect(() => {
35+
try {
36+
if (validate !== undefined) {
37+
const valid = validate(JSON.parse(formDefinition))
38+
setFormErrors(validate.errors ?? [])
39+
setFormValid(valid as boolean)
40+
if (valid === true) {
41+
try {
42+
setParsedFormDefinition(
43+
JSON.parse(formDefinition) as FormDefinition,
44+
)
45+
} catch {
46+
console.error(`form definition is not valid JSON`)
47+
}
48+
}
49+
}
50+
} catch (err) {
51+
console.error(err)
52+
setFormErrors([err as Error])
53+
}
54+
}, [formDefinition, setParsedFormDefinition])
55+
56+
return (
57+
<main className="container mt-4">
58+
<div className="row justify-content-center">
59+
<section className="col-md-6">
60+
{parsedFormDefinition !== undefined && (
61+
<SectionizedForm form={parsedFormDefinition} />
62+
)}
63+
</section>
64+
</div>
65+
</main>
66+
)
67+
}
68+
69+
const SectionizedForm = ({ form }: { form: FormDefinition }) => {
70+
const [currentSection, setCurrentSection] = useState<string>()
71+
const { response } = useResponse()
72+
const { sectionValidation } = useValidation({ form, response })
73+
74+
useEffect(() => {
75+
if (form === undefined) return
76+
setCurrentSection(form.sections[0].id)
77+
}, [form])
78+
79+
const section =
80+
form.sections.find(({ id }) => id === currentSection) ?? form.sections[0]
81+
82+
// Find next section
83+
let nextSection: Section | undefined = undefined
84+
let nextSectionCandidate: Section | undefined = undefined
85+
let nextSectionIndex = form.sections.indexOf(section)
86+
do {
87+
nextSectionCandidate = form.sections[++nextSectionIndex]
88+
if (nextSectionCandidate === undefined) break
89+
if (!isHidden(nextSectionCandidate, response))
90+
nextSection = nextSectionCandidate
91+
} while (nextSectionCandidate !== undefined && nextSection === undefined)
92+
93+
// Find previous section
94+
let prevSection: Section | undefined = undefined
95+
let prevSectionCandidate: Section | undefined = undefined
96+
let prevSectionIndex = form.sections.indexOf(section)
97+
do {
98+
prevSectionCandidate = form.sections[--prevSectionIndex]
99+
if (prevSectionCandidate === undefined) break
100+
if (!isHidden(prevSectionCandidate, response))
101+
prevSection = prevSectionCandidate
102+
} while (prevSectionCandidate !== undefined && prevSection === undefined)
103+
104+
return (
105+
<form
106+
className="form"
107+
onSubmit={(event) => {
108+
event.preventDefault()
109+
}}
110+
>
111+
<h2 className="d-flex justify-content-end justify-content-between">
112+
{section.title}
113+
{sectionValidation[section.id] ? (
114+
<abbr title="Section is valid.">
115+
<OkIcon />
116+
</abbr>
117+
) : (
118+
<abbr title="Section is invalid.">
119+
<WarningIcon />
120+
</abbr>
121+
)}
122+
</h2>
123+
<SectionComponent form={form} section={section} />
124+
<footer className="mb-4">
125+
<div className="d-flex justify-content-between mt-4 mb-4">
126+
{prevSection !== undefined && (
127+
<button
128+
type="button"
129+
className="btn btn-outline-secondary d-flex align-items-center"
130+
onClick={() => {
131+
if (prevSection !== undefined) setCurrentSection(prevSection.id)
132+
}}
133+
>
134+
<PrevIcon />
135+
<span>{prevSection.title}</span>
136+
</button>
137+
)}
138+
{prevSection === undefined && <span></span>}
139+
{nextSection !== undefined && (
140+
<button
141+
type="button"
142+
className="btn btn-primary d-flex align-items-center"
143+
disabled={!sectionValidation[section.id]}
144+
onClick={() => {
145+
if (nextSection !== undefined) setCurrentSection(nextSection.id)
146+
}}
147+
>
148+
<span>{nextSection.title}</span>
149+
<NextIcon />
150+
</button>
151+
)}
152+
</div>
153+
{nextSection === undefined && (
154+
<>
155+
<hr />
156+
<FormFooter form={form} />
157+
</>
158+
)}
159+
</footer>
160+
</form>
161+
)
162+
}

src/app/pages/FormGenerator.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export const FormGenerator = () => {
5353
return (
5454
<main className="container mt-4">
5555
<div className="row justify-content-center">
56-
<section className="col-6">
56+
<section className="col-md-6">
5757
<h2>Define the form here:</h2>
5858
<label htmlFor="form-definition">
5959
Provide the form definition below.
@@ -103,7 +103,7 @@ export const FormGenerator = () => {
103103
</>
104104
)}
105105
</section>
106-
<section className="col-6">
106+
<section className="col-md-6">
107107
<h2>Form will appear here:</h2>
108108
{parseFormDefinition !== undefined && (
109109
<Form form={parseFormDefinition} />

src/app/pages/Instructions.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Link } from 'react-router-dom'
2+
3+
export const Instructions = () => (
4+
<main className="container mt-4">
5+
<div className="row justify-content-center">
6+
<section className="col-md-6">
7+
<h1>Instructions</h1>
8+
<ol>
9+
<li>
10+
Please fill out each question to the best of your ability, and
11+
submit the survey before the beginning of the quarter you are
12+
reporting your needs for.
13+
</li>
14+
<li>
15+
Please only consider your organisation's total needs for the quarter
16+
in question that you don't already have covered.
17+
</li>
18+
<li>
19+
It is okay to use estimates, and we understand that conditions &amp;
20+
needs change! We are only looking for a rough understanding of your
21+
needs.
22+
</li>
23+
<li>
24+
If your organisation operates in multiple regions, please fill out
25+
the survey multiple times. Please submit it once per region that you
26+
operate in, so we can keep the needs data separate.
27+
</li>
28+
<li>
29+
If you want to submit an assessment for a different quarter, please
30+
create a separate submission.
31+
</li>
32+
</ol>
33+
34+
<p className="d-flex justify-content-end">
35+
<Link className="btn btn-primary" to="/assessment">
36+
Continue
37+
</Link>
38+
</p>
39+
</section>
40+
</div>
41+
</main>
42+
)

src/app/pages/Privacy.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export const Privacy = () => (
2+
<main className="container mt-4">
3+
<div className="row justify-content-center">
4+
<section className="col-md-6">
5+
<h1>Privacy</h1>
6+
<p>
7+
Distribute Aid WILL NOT share individual survey responses with any
8+
other group or to the general public. We WILL provide you with a copy
9+
of your survey response(s), and you can choose to share that as you
10+
wish. Distribute Aid DOES NOT make decisions on where collection
11+
groups should send their aid, we only share open offers with frontline
12+
groups who decide together who gets what. We WILL use individual
13+
responses to compute regional needs and use both of these to increase
14+
the efficiency of our own efforts and highlight gaps before they
15+
happen. Our website team WILL show a public interactive summary of the
16+
combined needs for each region on our website, so that everyone can
17+
use it as they wish. Our website WILL NOT show any individual group's
18+
individual information or data. We will process your data in Google
19+
Drive. The data for the public summary of regional needs will be
20+
stored in Contentful, and hosted with the rest of our website on
21+
Netlify.
22+
</p>
23+
</section>
24+
</div>
25+
</main>
26+
)

src/app/pages/Welcome.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Link } from 'react-router-dom'
2+
3+
export const Welcome = () => (
4+
<main className="container mt-4">
5+
<div className="row justify-content-center">
6+
<section className="col-md-6">
7+
<h1>Welcome</h1>
8+
<p>
9+
Thank you for filling out our regional needs assessment survey! The
10+
results from this survey will allow{' '}
11+
<a href="https://distributeaid.org/">Distribute Aid</a> to understand
12+
your region’s and organisation’s needs over the next three months so
13+
that we can advise collection groups on what to collect, figure out
14+
which targeted campaigns to run, and decide who to reach out to for
15+
in-kind donations. In short, this assessment helps make sure the aid
16+
you receive is better suited to your needs.
17+
</p>
18+
<p>
19+
We will release a public, interactive summary of the data like{' '}
20+
<a href="https://prezi.com/i/f4hqrn4oq6v8/q2-need-assessment-report-2021/">
21+
the one here
22+
</a>
23+
. The public version will show the needs for each region, but
24+
individual group responses will be anonymized to protect privacy.
25+
</p>
26+
<p>
27+
If you have any questions, please contact Nicole Tingle at{' '}
28+
<a href="mailto:nicole@distributeaid.org">nicole@distributeaid.org</a>
29+
. Thank you for your participation!
30+
</p>
31+
<p className="d-flex justify-content-end">
32+
<Link className="btn btn-primary" to="/instructions">
33+
Start needs assessment
34+
</Link>
35+
</p>
36+
</section>
37+
</div>
38+
</main>
39+
)

src/components/FeatherIcons.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,3 +351,11 @@ export const MinusIcon = (options?: TypedIconOptions) => (
351351
export const PlusIcon = (options?: TypedIconOptions) => (
352352
<FeatherIcon {...options} type="plus-circle" title="+" />
353353
)
354+
355+
export const NextIcon = (options?: TypedIconOptions) => (
356+
<FeatherIcon {...options} type="arrow-right" title="next" />
357+
)
358+
359+
export const PrevIcon = (options?: TypedIconOptions) => (
360+
<FeatherIcon {...options} type="arrow-left" title="next" />
361+
)

0 commit comments

Comments
 (0)