623 lines
18 KiB
JavaScript
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>
|
|
);
|
|
}
|