feat(questions): single_choice + multi_choice modules (registry slots 5-6/7)
Two choice types share the editor (option list with add/remove rows) and the results bar chart. The participant input differs (radio vs checkbox) and the answer shape differs (string vs string[]) — those are per-module. Files added: - single_choice.ts + multi_choice.ts — schemas (each with options array ≥2 items), defaultStubs (Option A / Option B), per-type isAnswerEmpty, ingest with per-option counting + other_count for choices that don't match (handles renamed options between form versions) - single_choice.input.svelte — radio-button rows - multi_choice.input.svelte — checkbox rows + toggle helper - choice.builder.svelte — shared option editor (add / remove with the ≥2 minimum invariant); both modules import this slot - choice.results.svelte — shared bar chart per option + an "other / dropped" muted row when other_count > 0 - choice.test.ts — 11 cases covering schema (≥2 options invariant), isAnswerEmpty (per-type rules), ingest (option counting + other_count), CSV (single-string vs pipe-joined), adminCellSummary (per-type) Both modules registered in QUESTION_MODULES (between long_text and scale per the picker order). 101 server tests pass (was 91). svelte-check clean.
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"start": "node build/index.js",
|
||||
"test:server": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts ./src/lib/server/admin-route.test.ts ./src/lib/server/feedback-pure.test.ts ./src/lib/questions/registry.test.ts ./src/lib/questions/boolean.test.ts ./src/lib/questions/text.test.ts ./src/lib/questions/scale.test.ts",
|
||||
"test:server": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts ./src/lib/server/admin-route.test.ts ./src/lib/server/feedback-pure.test.ts ./src/lib/questions/registry.test.ts ./src/lib/questions/boolean.test.ts ./src/lib/questions/text.test.ts ./src/lib/questions/scale.test.ts ./src/lib/questions/choice.test.ts",
|
||||
"test:components": "bun --bun vitest run --config vitest.config.ts",
|
||||
"test": "bun run test:server && bun run test:components"
|
||||
},
|
||||
|
||||
54
src/lib/questions/choice.builder.svelte
Normal file
54
src/lib/questions/choice.builder.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { BuilderEditorProps } from './types';
|
||||
|
||||
let { question, update }: BuilderEditorProps = $props();
|
||||
|
||||
function setOption(idx: number, val: string): void {
|
||||
if (question.type !== 'single_choice' && question.type !== 'multi_choice') return;
|
||||
const options = [...question.options];
|
||||
options[idx] = val;
|
||||
update({ options });
|
||||
}
|
||||
|
||||
function addOption(): void {
|
||||
if (question.type !== 'single_choice' && question.type !== 'multi_choice') return;
|
||||
update({ options: [...question.options, `Option ${question.options.length + 1}`] });
|
||||
}
|
||||
|
||||
function removeOption(idx: number): void {
|
||||
if (question.type !== 'single_choice' && question.type !== 'multi_choice') return;
|
||||
if (question.options.length <= 2) return;
|
||||
update({ options: question.options.filter((_, i) => i !== idx) });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if question.type === 'single_choice' || question.type === 'multi_choice'}
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for={`fb-builder-${question.id}-options`}>Options</label>
|
||||
<div id={`fb-builder-${question.id}-options`} class="fb-builder__options">
|
||||
{#each question.options as opt, optIdx (optIdx)}
|
||||
<div class="fb-builder__option-row">
|
||||
<input
|
||||
class="fb-input"
|
||||
maxlength="200"
|
||||
value={opt}
|
||||
oninput={(e) => setOption(optIdx, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="fb-builder__icon-btn fb-builder__icon-btn--danger"
|
||||
disabled={question.options.length <= 2}
|
||||
onclick={() => removeOption(optIdx)}
|
||||
aria-label="Remove option"
|
||||
><Icon name="x" size={14} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="fb-btn fb-btn--secondary fb-btn--sm fb-builder__add-option"
|
||||
onclick={addOption}
|
||||
><Icon name="plus" /> Option</button>
|
||||
</div>
|
||||
{/if}
|
||||
33
src/lib/questions/choice.results.svelte
Normal file
33
src/lib/questions/choice.results.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { ResultsBlockProps } from './types';
|
||||
|
||||
let { stats }: ResultsBlockProps = $props();
|
||||
|
||||
function pct(part: number, whole: number): number {
|
||||
if (whole === 0) return 0;
|
||||
return Math.round((part / whole) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if stats.type === 'single_choice' || stats.type === 'multi_choice'}
|
||||
<div class="fb-results__bars">
|
||||
{#each stats.options as o (o.option)}
|
||||
<div class="fb-results__row">
|
||||
<span class="fb-results__row-label fb-results__row-label--wide">{o.option}</span>
|
||||
<div class="fb-results__bar-track">
|
||||
<div class="fb-results__bar-fill" style="width: {pct(o.count, stats.count)}%;"></div>
|
||||
</div>
|
||||
<span class="fb-results__row-count">{o.count} ({pct(o.count, stats.count)}%)</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if stats.other_count > 0}
|
||||
<div class="fb-results__row fb-results__row--muted">
|
||||
<span class="fb-results__row-label fb-results__row-label--wide">other / dropped</span>
|
||||
<div class="fb-results__bar-track">
|
||||
<div class="fb-results__bar-fill fb-results__bar-fill--muted" style="width: {pct(stats.other_count, stats.count)}%;"></div>
|
||||
</div>
|
||||
<span class="fb-results__row-count">{stats.other_count}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
93
src/lib/questions/choice.test.ts
Normal file
93
src/lib/questions/choice.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SingleChoiceQuestion, SingleChoiceQuestionSchema } from './single_choice';
|
||||
import { MultiChoiceQuestion, MultiChoiceQuestionSchema } from './multi_choice';
|
||||
|
||||
describe('SingleChoiceQuestion', () => {
|
||||
const q = SingleChoiceQuestion.defaultStub();
|
||||
|
||||
test('schema requires ≥ 2 options', () => {
|
||||
expect(SingleChoiceQuestionSchema.safeParse(q).success).toBe(true);
|
||||
expect(
|
||||
SingleChoiceQuestionSchema.safeParse({ ...q, options: ['only-one'] }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('isAnswerEmpty: blank or non-string → empty', () => {
|
||||
expect(SingleChoiceQuestion.isAnswerEmpty(q, undefined)).toBe(true);
|
||||
expect(SingleChoiceQuestion.isAnswerEmpty(q, '')).toBe(true);
|
||||
expect(SingleChoiceQuestion.isAnswerEmpty(q, ' ')).toBe(true);
|
||||
expect(SingleChoiceQuestion.isAnswerEmpty(q, 'Option A')).toBe(false);
|
||||
});
|
||||
|
||||
test('ingest: matches options + tracks other_count', () => {
|
||||
const stats = SingleChoiceQuestion.emptyStats(q);
|
||||
SingleChoiceQuestion.ingest(stats, q, 'Option A', 'now');
|
||||
SingleChoiceQuestion.ingest(stats, q, 'Option A', 'now');
|
||||
SingleChoiceQuestion.ingest(stats, q, 'Option B', 'now');
|
||||
SingleChoiceQuestion.ingest(stats, q, 'Renamed-since-snapshot', 'now');
|
||||
SingleChoiceQuestion.finalise(stats);
|
||||
expect(stats.count).toBe(4);
|
||||
const a = stats.options.find((o) => o.option === 'Option A')!;
|
||||
const b = stats.options.find((o) => o.option === 'Option B')!;
|
||||
expect(a.count).toBe(2);
|
||||
expect(b.count).toBe(1);
|
||||
expect(stats.other_count).toBe(1);
|
||||
});
|
||||
|
||||
test('csv: one column, cell = the chosen string', () => {
|
||||
const [col] = SingleChoiceQuestion.csvColumns(q);
|
||||
expect(col).toEqual({ header: 'q1', qid: 'q1' });
|
||||
expect(SingleChoiceQuestion.csvCellFor(q, 'A', col)).toBe('A');
|
||||
expect(SingleChoiceQuestion.csvCellFor(q, null, col)).toBe('');
|
||||
});
|
||||
|
||||
test('adminCellSummary passes through string', () => {
|
||||
expect(SingleChoiceQuestion.adminCellSummary(q, 'A')).toBe('A');
|
||||
expect(SingleChoiceQuestion.adminCellSummary(q, null)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MultiChoiceQuestion', () => {
|
||||
const q = MultiChoiceQuestion.defaultStub();
|
||||
|
||||
test('schema requires ≥ 2 options', () => {
|
||||
expect(MultiChoiceQuestionSchema.safeParse(q).success).toBe(true);
|
||||
expect(MultiChoiceQuestionSchema.safeParse({ ...q, options: ['only-one'] }).success).toBe(false);
|
||||
});
|
||||
|
||||
test('isAnswerEmpty: missing array OR empty array → empty', () => {
|
||||
expect(MultiChoiceQuestion.isAnswerEmpty(q, undefined)).toBe(true);
|
||||
expect(MultiChoiceQuestion.isAnswerEmpty(q, null)).toBe(true);
|
||||
expect(MultiChoiceQuestion.isAnswerEmpty(q, [])).toBe(true);
|
||||
expect(MultiChoiceQuestion.isAnswerEmpty(q, 'Option A')).toBe(true);
|
||||
expect(MultiChoiceQuestion.isAnswerEmpty(q, ['Option A'])).toBe(false);
|
||||
});
|
||||
|
||||
test('ingest: counts each picked option, increments stats.count once per submission', () => {
|
||||
const stats = MultiChoiceQuestion.emptyStats(q);
|
||||
MultiChoiceQuestion.ingest(stats, q, ['Option A', 'Option B'], 'now');
|
||||
MultiChoiceQuestion.ingest(stats, q, ['Option A'], 'now');
|
||||
MultiChoiceQuestion.ingest(stats, q, ['ghost'], 'now');
|
||||
MultiChoiceQuestion.ingest(stats, q, [], 'now'); // empty array — skipped
|
||||
MultiChoiceQuestion.finalise(stats);
|
||||
expect(stats.count).toBe(3);
|
||||
const a = stats.options.find((o) => o.option === 'Option A')!;
|
||||
const b = stats.options.find((o) => o.option === 'Option B')!;
|
||||
expect(a.count).toBe(2);
|
||||
expect(b.count).toBe(1);
|
||||
expect(stats.other_count).toBe(1);
|
||||
});
|
||||
|
||||
test('csv: pipe-joined values', () => {
|
||||
const [col] = MultiChoiceQuestion.csvColumns(q);
|
||||
expect(MultiChoiceQuestion.csvCellFor(q, ['x', 'y'], col)).toBe('x|y');
|
||||
expect(MultiChoiceQuestion.csvCellFor(q, [], col)).toBe('');
|
||||
expect(MultiChoiceQuestion.csvCellFor(q, null, col)).toBe('');
|
||||
});
|
||||
|
||||
test('adminCellSummary: comma-joined or em-dash', () => {
|
||||
expect(MultiChoiceQuestion.adminCellSummary(q, ['A', 'B'])).toBe('A, B');
|
||||
expect(MultiChoiceQuestion.adminCellSummary(q, [])).toBe('—');
|
||||
expect(MultiChoiceQuestion.adminCellSummary(q, null)).toBe('—');
|
||||
});
|
||||
});
|
||||
30
src/lib/questions/multi_choice.input.svelte
Normal file
30
src/lib/questions/multi_choice.input.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { ParticipantInputProps } from './types';
|
||||
|
||||
let { question, answer, setAnswer }: ParticipantInputProps = $props();
|
||||
|
||||
function toggle(opt: string): void {
|
||||
const cur = (Array.isArray(answer) ? answer : []) as string[];
|
||||
const next = cur.includes(opt) ? cur.filter((x) => x !== opt) : [...cur, opt];
|
||||
setAnswer(next);
|
||||
}
|
||||
|
||||
function isChecked(opt: string): boolean {
|
||||
return Array.isArray(answer) && (answer as string[]).includes(opt);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if question.type === 'multi_choice'}
|
||||
<div class="fb-options">
|
||||
{#each question.options as opt (opt)}
|
||||
<label class="fb-option-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked(opt)}
|
||||
onchange={() => toggle(opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
81
src/lib/questions/multi_choice.ts
Normal file
81
src/lib/questions/multi_choice.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* `multi_choice` question type — checkbox list. Zero or more choices per
|
||||
* submission, returned as a string[].
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { QuestionTypeModule, CsvColumn } from './types';
|
||||
import { FeedbackQuestionBaseSchema } from './_base';
|
||||
import MultiChoiceInput from './multi_choice.input.svelte';
|
||||
import ChoiceBuilder from './choice.builder.svelte';
|
||||
import ChoiceResults from './choice.results.svelte';
|
||||
|
||||
export const MultiChoiceQuestionSchema = FeedbackQuestionBaseSchema.extend({
|
||||
type: z.literal('multi_choice'),
|
||||
options: z.array(z.string().min(1).max(200)).min(2).max(20),
|
||||
});
|
||||
|
||||
export const MultiChoiceQuestion: QuestionTypeModule<'multi_choice'> = {
|
||||
type: 'multi_choice',
|
||||
label: 'Multiple choice',
|
||||
schema: MultiChoiceQuestionSchema,
|
||||
|
||||
defaultStub() {
|
||||
return {
|
||||
id: 'q1',
|
||||
label: 'New question',
|
||||
required: false,
|
||||
type: 'multi_choice',
|
||||
options: ['Option A', 'Option B'],
|
||||
};
|
||||
},
|
||||
|
||||
isAnswerEmpty(_q, answer) {
|
||||
return !Array.isArray(answer) || answer.length === 0;
|
||||
},
|
||||
|
||||
emptyStats(question) {
|
||||
return {
|
||||
type: 'multi_choice',
|
||||
count: 0,
|
||||
options: question.options.map((option) => ({ option, count: 0 })),
|
||||
other_count: 0,
|
||||
};
|
||||
},
|
||||
|
||||
ingest(stats, _q, answer) {
|
||||
if (!Array.isArray(answer) || answer.length === 0) return;
|
||||
stats.count++;
|
||||
for (const choice of answer) {
|
||||
if (typeof choice !== 'string') continue;
|
||||
const hit = stats.options.find((o) => o.option === choice);
|
||||
if (hit) hit.count++;
|
||||
else stats.other_count++;
|
||||
}
|
||||
},
|
||||
|
||||
finalise() {
|
||||
// counts are final.
|
||||
},
|
||||
|
||||
sanitizeForPublic(stats) {
|
||||
return stats;
|
||||
},
|
||||
|
||||
csvColumns(q): CsvColumn[] {
|
||||
return [{ header: q.id, qid: q.id }];
|
||||
},
|
||||
|
||||
csvCellFor(_q, answer) {
|
||||
if (Array.isArray(answer)) return answer.join('|');
|
||||
return '';
|
||||
},
|
||||
|
||||
adminCellSummary(_q, answer) {
|
||||
if (Array.isArray(answer)) return answer.length === 0 ? '—' : answer.join(', ');
|
||||
return '—';
|
||||
},
|
||||
|
||||
ParticipantInput: MultiChoiceInput,
|
||||
BuilderEditor: ChoiceBuilder,
|
||||
ResultsBlock: ChoiceResults,
|
||||
};
|
||||
@@ -15,15 +15,18 @@ import { BooleanQuestion } from './boolean';
|
||||
import { ShortTextQuestion } from './short_text';
|
||||
import { LongTextQuestion } from './long_text';
|
||||
import { ScaleQuestion } from './scale';
|
||||
import { SingleChoiceQuestion } from './single_choice';
|
||||
import { MultiChoiceQuestion } from './multi_choice';
|
||||
|
||||
// Order matters — drives the FormBuilder "+ Add" picker layout.
|
||||
// As the remaining types land in subsequent commits they get appended here.
|
||||
// The wiring step at the end of Phase 2 flips legacy `q.type === '...'`
|
||||
// strips in FormBuilder / participant / Results.svelte / results.ts /
|
||||
// submit / export over to `getQuestion(q.type).method(...)`.
|
||||
export const QUESTION_MODULES: readonly AnyQuestionTypeModule[] = [
|
||||
ShortTextQuestion as AnyQuestionTypeModule,
|
||||
LongTextQuestion as AnyQuestionTypeModule,
|
||||
SingleChoiceQuestion as AnyQuestionTypeModule,
|
||||
MultiChoiceQuestion as AnyQuestionTypeModule,
|
||||
ScaleQuestion as AnyQuestionTypeModule,
|
||||
BooleanQuestion as AnyQuestionTypeModule,
|
||||
];
|
||||
|
||||
21
src/lib/questions/single_choice.input.svelte
Normal file
21
src/lib/questions/single_choice.input.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { ParticipantInputProps } from './types';
|
||||
|
||||
let { question, answer, setAnswer }: ParticipantInputProps = $props();
|
||||
</script>
|
||||
|
||||
{#if question.type === 'single_choice'}
|
||||
<div class="fb-options">
|
||||
{#each question.options as opt (opt)}
|
||||
<label class="fb-option-row">
|
||||
<input
|
||||
type="radio"
|
||||
name={`q-${question.id}`}
|
||||
checked={answer === opt}
|
||||
onchange={() => setAnswer(opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
76
src/lib/questions/single_choice.ts
Normal file
76
src/lib/questions/single_choice.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* `single_choice` question type — radio-button list. One choice per submission.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { QuestionTypeModule, CsvColumn } from './types';
|
||||
import { FeedbackQuestionBaseSchema } from './_base';
|
||||
import SingleChoiceInput from './single_choice.input.svelte';
|
||||
import ChoiceBuilder from './choice.builder.svelte';
|
||||
import ChoiceResults from './choice.results.svelte';
|
||||
|
||||
export const SingleChoiceQuestionSchema = FeedbackQuestionBaseSchema.extend({
|
||||
type: z.literal('single_choice'),
|
||||
options: z.array(z.string().min(1).max(200)).min(2).max(20),
|
||||
});
|
||||
|
||||
export const SingleChoiceQuestion: QuestionTypeModule<'single_choice'> = {
|
||||
type: 'single_choice',
|
||||
label: 'Single choice',
|
||||
schema: SingleChoiceQuestionSchema,
|
||||
|
||||
defaultStub() {
|
||||
return {
|
||||
id: 'q1',
|
||||
label: 'New question',
|
||||
required: false,
|
||||
type: 'single_choice',
|
||||
options: ['Option A', 'Option B'],
|
||||
};
|
||||
},
|
||||
|
||||
isAnswerEmpty(_q, answer) {
|
||||
return typeof answer !== 'string' || answer.trim() === '';
|
||||
},
|
||||
|
||||
emptyStats(question) {
|
||||
return {
|
||||
type: 'single_choice',
|
||||
count: 0,
|
||||
options: question.options.map((option) => ({ option, count: 0 })),
|
||||
other_count: 0,
|
||||
};
|
||||
},
|
||||
|
||||
ingest(stats, _q, answer) {
|
||||
if (typeof answer !== 'string') return;
|
||||
stats.count++;
|
||||
const hit = stats.options.find((o) => o.option === answer);
|
||||
if (hit) hit.count++;
|
||||
else stats.other_count++;
|
||||
},
|
||||
|
||||
finalise() {
|
||||
// nothing to compute — counts are final.
|
||||
},
|
||||
|
||||
sanitizeForPublic(stats) {
|
||||
return stats;
|
||||
},
|
||||
|
||||
csvColumns(q): CsvColumn[] {
|
||||
return [{ header: q.id, qid: q.id }];
|
||||
},
|
||||
|
||||
csvCellFor(_q, answer) {
|
||||
return typeof answer === 'string' ? answer : '';
|
||||
},
|
||||
|
||||
adminCellSummary(_q, answer) {
|
||||
if (answer === undefined || answer === null) return '—';
|
||||
return typeof answer === 'string' ? answer : String(answer);
|
||||
},
|
||||
|
||||
ParticipantInput: SingleChoiceInput,
|
||||
BuilderEditor: ChoiceBuilder,
|
||||
ResultsBlock: ChoiceResults,
|
||||
};
|
||||
Reference in New Issue
Block a user