PPPAIMT-Crest/frontend/src/pages/SurveyPage.jsx

623 lines
18 KiB
JavaScript

import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { validateAnswers } from '../../../backend/src/survey/validate.js';
import { surveyApi } from '../surveyApi.js';
const ALREADY = 'This survey has already been submitted';
/**
* @param {Record<string, unknown>} question
* @param {unknown} value
*/
function formatAnswer(question, value) {
if (value === undefined || value === null || value === '') {
return '—';
}
if (question.type === 'short_text' || question.type === 'long_text') {
return String(value);
}
if (question.type === 'number' || question.type === 'percentage') {
return question.type === 'percentage' ? `${value}%` : String(value);
}
if (question.type === 'single_choice' && value && typeof value === 'object') {
const selected = question.options.find((option) => option.id === value.choice);
const label = selected?.label ?? '—';
return value.choice === 'other' && value.other ? `${label}: ${value.other}` : label;
}
if (question.type === 'multi_choice' && value && typeof value === 'object') {
const labels = (value.choices ?? []).map((choice) => {
const selected = question.options.find((option) => option.id === choice);
return choice === 'other' && value.other
? `${selected?.label}: ${value.other}`
: selected?.label;
});
return labels.filter(Boolean).join(', ') || '—';
}
if (
(question.type === 'grid_percentage' || question.type === 'grid_number') &&
value &&
typeof value === 'object'
) {
return question.rows
.map((row) => {
const cells = question.columns
.map((column) => {
const cell = value[row.id]?.[column.id];
const shown = cell === undefined || cell === '' ? '—' : cell;
return `${column.label} ${shown}${question.type === 'grid_percentage' ? '%' : question.suffix || ''}`;
})
.join(', ');
return `${row.label}: ${cells}`;
})
.join('; ');
}
if (question.type === 'likert_matrix' && value && typeof value === 'object') {
return question.rows
.map((row) => {
const point = question.scale.find((item) => item.id === value[row.id]);
return `${row.label}: ${point?.label ?? '—'}`;
})
.join('; ');
}
return '—';
}
/**
* @param {{ question: Record<string, unknown>, value: unknown, invalid?: boolean, hideLabel?: boolean, onChange: (value: unknown) => void }} props
*/
function QuestionField({ question, value, invalid = false, hideLabel = false, onChange }) {
const legendClass = hideLabel ? 'visually-hidden' : undefined;
if (question.type === 'help') {
return <p className="survey-help">{question.text}</p>;
}
if (question.type === 'section_header') {
return <h3>{question.label}</h3>;
}
if (question.type === 'short_text' || question.type === 'long_text') {
const Input = question.type === 'long_text' ? 'textarea' : 'input';
return (
<label>
{question.label}
<Input
value={typeof value === 'string' ? value : ''}
onChange={(event) => onChange(event.target.value)}
/>
</label>
);
}
if (question.type === 'number' || question.type === 'percentage') {
return (
<label>
{question.label}
<input
type="number"
inputMode={question.integer ? 'numeric' : 'decimal'}
min={question.min}
max={question.max}
aria-invalid={invalid || undefined}
value={value === undefined || value === null ? '' : value}
onChange={(event) => {
const raw = event.target.value;
onChange(raw === '' ? '' : Number(raw));
}}
/>
</label>
);
}
if (question.type === 'single_choice' || question.type === 'multi_choice') {
const current = value && typeof value === 'object' ? value : {};
return (
<fieldset>
<legend className={legendClass}>{question.label}</legend>
{question.options.map((option) => (
<label key={option.id} className="survey-choice">
<input
type={question.type === 'single_choice' ? 'radio' : 'checkbox'}
name={question.id}
checked={
question.type === 'single_choice'
? current.choice === option.id
: (current.choices ?? []).includes(option.id)
}
onChange={() => {
if (question.type === 'single_choice') {
onChange({ choice: option.id, other: current.other ?? '' });
return;
}
const choices = new Set(current.choices ?? []);
if (choices.has(option.id)) {
choices.delete(option.id);
} else {
choices.add(option.id);
}
onChange({ choices: [...choices], other: current.other ?? '' });
}}
/>
{option.label}
</label>
))}
{question.other &&
(current.choice === 'other' || (current.choices ?? []).includes('other')) ? (
<input
aria-label={`${question.label} other`}
value={current.other ?? ''}
onChange={(event) => onChange({ ...current, other: event.target.value })}
/>
) : null}
</fieldset>
);
}
if (question.type === 'grid_percentage' || question.type === 'grid_number') {
const grid = value && typeof value === 'object' ? value : {};
return (
<fieldset>
<legend className={legendClass}>{question.label}</legend>
<table className="survey-grid">
<thead>
<tr>
<th scope="col">Pack type</th>
{question.columns.map((column) => (
<th key={column.id} scope="col">
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{question.rows.map((row) => (
<tr key={row.id}>
<th scope="row">{row.label}</th>
{question.columns.map((column) => (
<td key={column.id}>
<span className="cell-label">{column.label}</span>
<div className="survey-grid-input">
<input
type="number"
inputMode={question.integer ? 'numeric' : 'decimal'}
min={question.min}
max={question.max}
aria-invalid={invalid || undefined}
aria-label={`${row.label}, ${column.label}`}
value={grid[row.id]?.[column.id] ?? ''}
onChange={(event) => {
const raw = event.target.value;
onChange({
...grid,
[row.id]: {
...(grid[row.id] ?? {}),
[column.id]: raw === '' ? '' : Number(raw),
},
});
}}
/>
{question.suffix ? (
<span className="survey-grid-suffix">{question.suffix}</span>
) : null}
</div>
</td>
))}
</tr>
))}
</tbody>
{question.type === 'grid_percentage' ? (
<tfoot>
<tr>
<th scope="row">Total</th>
{question.columns.map((column) => {
const numbers = question.rows.map((row) => grid[row.id]?.[column.id]);
const filled = numbers.filter((cell) => typeof cell === 'number');
const total = filled.reduce((sum, cell) => sum + cell, 0);
const ready = filled.length > 0;
const exact = ready && total === question.sumTo;
return (
<td key={column.id}>
<span className={exact || !ready ? 'survey-total' : 'survey-total warn'}>
{ready
? exact
? `Total: ${total}%`
: `Total: ${total}% — must equal ${question.sumTo}%`
: 'Total: —'}
</span>
</td>
);
})}
</tr>
</tfoot>
) : null}
</table>
</fieldset>
);
}
if (question.type === 'likert_matrix') {
const selected = value && typeof value === 'object' ? value : {};
return (
<fieldset>
<legend className={legendClass}>{question.label}</legend>
<table className="survey-grid">
<thead>
<tr>
<th scope="col">Attribute</th>
{question.scale.map((point) => (
<th key={point.id} scope="col">
{point.label}
</th>
))}
</tr>
</thead>
<tbody>
{question.rows.map((row) => (
<tr key={row.id}>
<th scope="row">{row.label}</th>
{question.scale.map((point) => (
<td key={point.id} className="survey-grid-choice">
<span className="cell-label">{point.label}</span>
<label className="survey-choice">
<input
type="radio"
name={`${question.id}-${row.id}`}
checked={selected[row.id] === point.id}
onChange={() => onChange({ ...selected, [row.id]: point.id })}
/>
<span className="visually-hidden">{point.label}</span>
</label>
</td>
))}
</tr>
))}
</tbody>
</table>
</fieldset>
);
}
return null;
}
export default function SurveyPage() {
const { token } = useParams();
const [screen, setScreen] = useState('loading');
const [message, setMessage] = useState('');
const [survey, setSurvey] = useState(null);
const [answers, setAnswers] = useState({});
const [sectionIndex, setSectionIndex] = useState(0);
const [error, setError] = useState('');
const [stepErrors, setStepErrors] = useState([]);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const suppressOpen = useRef(false);
const answersRef = useRef(answers);
const dirtyRef = useRef(false);
const persistRef = useRef(async () => {});
const stepAlertRef = useRef(null);
answersRef.current = answers;
useEffect(() => {
let active = true;
surveyApi(`/api/survey/${token}`)
.then((payload) => {
if (!active) {
return;
}
setSurvey(payload);
setAnswers(payload.answers ?? {});
setScreen('form');
})
.catch((loadError) => {
if (!active) {
return;
}
setMessage(loadError.message);
setScreen(loadError.message === ALREADY ? 'submitted' : 'invalid');
});
return () => {
active = false;
};
}, [token]);
persistRef.current = async () => {
if (!dirtyRef.current) {
return;
}
dirtyRef.current = false;
try {
await surveyApi(`/api/survey/${token}/progress`, {
method: 'PUT',
body: { answers: answersRef.current },
});
} catch (saveError) {
dirtyRef.current = true;
setError(saveError.message);
}
};
useEffect(() => {
if (screen !== 'form') {
return undefined;
}
const timer = setInterval(() => {
persistRef.current();
}, 10000);
return () => clearInterval(timer);
}, [screen, token]);
const shownSection = useRef(sectionIndex);
useEffect(() => {
if (shownSection.current === sectionIndex) {
return;
}
shownSection.current = sectionIndex;
const page = document.scrollingElement || document.documentElement;
page.scrollTo?.({ top: 0, behavior: 'smooth' });
}, [sectionIndex]);
useEffect(() => {
if (stepErrors.length > 0) {
stepAlertRef.current?.scrollIntoView?.({ block: 'nearest' });
}
}, [stepErrors]);
function updateAnswer(id, value) {
dirtyRef.current = true;
setStepErrors([]);
setAnswers((current) => ({ ...current, [id]: value }));
}
/**
* @param {{ questions: Record<string, unknown>[] }} section
*/
function problemsFor(section) {
return validateAnswers({ sections: [section] }, answersRef.current);
}
async function goNext() {
const section = survey.definition.sections[sectionIndex];
const problems = problemsFor(section);
if (problems.length > 0) {
setStepErrors(problems);
return;
}
setStepErrors([]);
setBusy(true);
await persistRef.current();
setSectionIndex((current) => current + 1);
setBusy(false);
}
function openConfirm() {
if (suppressOpen.current) {
return;
}
const sections = survey.definition.sections;
for (let index = 0; index < sections.length; index += 1) {
const problems = problemsFor(sections[index]);
if (problems.length > 0) {
setStepErrors(problems);
setSectionIndex(index);
return;
}
}
setConfirming(true);
}
function closeConfirm() {
suppressOpen.current = true;
setConfirming(false);
window.setTimeout(() => {
suppressOpen.current = false;
}, 400);
}
async function submit() {
setBusy(true);
setError('');
try {
await surveyApi(`/api/survey/${token}/submit`, {
method: 'POST',
body: { answers: answersRef.current },
});
setConfirming(false);
setScreen('thanks');
} catch (submitError) {
closeConfirm();
setError(submitError.message);
if (submitError.message === ALREADY) {
setScreen('submitted');
setMessage(submitError.message);
}
} finally {
setBusy(false);
}
}
if (screen === 'loading') {
return (
<main className="wide">
<p>Loading the survey…</p>
</main>
);
}
if (screen === 'invalid') {
return (
<main className="wide">
<h1>Survey link</h1>
<p role="alert">{message}</p>
</main>
);
}
if (screen === 'submitted') {
return (
<main className="wide">
<h1>Already submitted</h1>
<p>{message}</p>
</main>
);
}
if (screen === 'thanks') {
return (
<main className="wide">
<h1>Thank you</h1>
<p>Your survey has been submitted.</p>
</main>
);
}
const sections = survey.definition.sections;
const review = sectionIndex >= sections.length;
const section = sections[sectionIndex];
const progress = Math.round((sectionIndex / sections.length) * 100);
return (
<main className="wide">
<h1>{survey.title}</h1>
<div
className="progress"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
aria-label="Survey progress"
>
<span style={{ width: `${progress}%` }} />
</div>
{review ? (
<section className="survey-review">
<h2>Review</h2>
{sections.map((item) => (
<div key={item.id}>
<h3>{item.title}</h3>
{item.questions
.filter(
(question) => question.type !== 'help' && question.type !== 'section_header',
)
.map((question) => (
<p key={question.id}>
<strong>{question.label}</strong>
<br />
{formatAnswer(question, answers[question.id])}
</p>
))}
</div>
))}
</section>
) : (
<section>
<h2>{section.title}</h2>
{stepErrors.length > 0 ? (
<div ref={stepAlertRef} role="alert">
{stepErrors.map((item) => (
<p key={`${item.id}:${item.message}`} className="error">
{item.message}
</p>
))}
</div>
) : null}
<div className="survey-fields">
{section.questions.map((question, index) => (
<QuestionField
key={question.id}
question={question}
hideLabel={
section.questions[index - 1]?.type === 'section_header' &&
section.questions[index - 1].label === question.label
}
invalid={stepErrors.some((item) => item.id === question.id)}
value={answers[question.id]}
onChange={(value) => updateAnswer(question.id, value)}
/>
))}
</div>
</section>
)}
{error ? (
<p className="error" role="alert">
{error}
</p>
) : null}
<div className="survey-actions">
{sectionIndex > 0 ? (
<button
type="button"
className="secondary"
onClick={() => {
setStepErrors([]);
setSectionIndex((current) => current - 1);
}}
>
Back
</button>
) : null}
{review ? (
<button type="button" disabled={busy} onClick={openConfirm}>
Submit
</button>
) : (
<button type="button" disabled={busy} onClick={goNext}>
Next
</button>
)}
</div>
{confirming ? (
<div className="survey-dialog">
<div role="dialog" aria-modal="true" aria-labelledby="submit-title">
<h2 id="submit-title">Submit survey</h2>
<p>You cannot change answers after this.</p>
<div className="survey-actions">
<button
type="button"
disabled={busy}
onMouseDown={(event) => event.preventDefault()}
onClick={submit}
>
{busy ? 'Submitting…' : 'Submit'}
</button>
<button
type="button"
className="secondary"
onMouseDown={(event) => event.preventDefault()}
onClick={closeConfirm}
>
Cancel
</button>
</div>
</div>
</div>
) : null}
</main>
);
}