feat(questions): registry shape + types — empty, awaiting per-type modules
Establishes the contract every per-question-type module must satisfy: - lib/questions/types.ts — QuestionTypeModule<T> interface (schema, defaultStub, isAnswerEmpty, emptyStats, ingest, finalise, sanitizeForPublic, csvColumns, csvCellFor, adminCellSummary, ParticipantInput, BuilderEditor, ResultsBlock). Plus the helper aliases QuestionForType<T> and StatsForType<T> (extract the discriminated-union variant for type T) and the shared Svelte component prop shapes. - lib/questions/registry.ts — QUESTION_MODULES (empty for now), getQuestion (throws on unknown), hasQuestion, listQuestionTypes. - lib/questions/registry.test.ts — locks the contract: getQuestion throws with a helpful error pointing at lib/questions/<type>.ts when a module is missing, hasQuestion returns false for nonsense, listQuestionTypes matches the modules array. Registry is intentionally empty in this commit — the legacy `q.type === '...'` strips in FormBuilder, participant page, Results.svelte, results.ts, submit, and export keep working. Per-type modules land in the next commits; the final commit flips callers to use getQuestion(q.type) and the legacy strips disappear. Svelte component slots accept broadly-typed props and narrow internally on question.type. The dispatch is sound at the call site (caller looked up by matching type) but TypeScript can't prove the cross-component relationship without significant generic gymnastics — runtime narrowing inside each component is cheaper and keeps the registry literal simple. 58 server tests + 2 component tests pass. 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",
|
||||
"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",
|
||||
"test:components": "bun --bun vitest run --config vitest.config.ts",
|
||||
"test": "bun run test:server && bun run test:components"
|
||||
},
|
||||
|
||||
28
src/lib/questions/registry.test.ts
Normal file
28
src/lib/questions/registry.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { QUESTION_MODULES, getQuestion, hasQuestion, listQuestionTypes } from './registry';
|
||||
|
||||
// Once per-type modules land, this file's expectations grow to:
|
||||
// - all 7 types present, in the documented picker order
|
||||
// - schemas registry assembles a working discriminatedUnion
|
||||
// - cross-type smoke (round-trip a tiny form through the registry)
|
||||
//
|
||||
// For now, the registry is empty by design (legacy paths still own dispatch).
|
||||
// These cases lock the contract that getQuestion throws on unknown types.
|
||||
|
||||
describe('registry shape', () => {
|
||||
test('QUESTION_MODULES is a readonly array', () => {
|
||||
expect(Array.isArray(QUESTION_MODULES)).toBe(true);
|
||||
});
|
||||
|
||||
test('hasQuestion returns false for unknown types', () => {
|
||||
expect(hasQuestion('definitely_not_a_real_type')).toBe(false);
|
||||
});
|
||||
|
||||
test('getQuestion throws a helpful error for missing modules', () => {
|
||||
expect(() => getQuestion('boolean')).toThrow(/lib\/questions\/<type>\.ts/);
|
||||
});
|
||||
|
||||
test('listQuestionTypes returns the array of registered type literals', () => {
|
||||
expect(listQuestionTypes()).toEqual(QUESTION_MODULES.map((m) => m.type));
|
||||
});
|
||||
});
|
||||
46
src/lib/questions/registry.ts
Normal file
46
src/lib/questions/registry.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Central question-type registry.
|
||||
*
|
||||
* One entry per type. The schemas index, the FormBuilder's "+ Add" picker,
|
||||
* the participant input dispatcher, the results aggregator, and the CSV
|
||||
* export all read from here. Adding a new question type = create
|
||||
* `lib/questions/<type>.ts` + add the import + push into QUESTION_MODULES.
|
||||
*
|
||||
* Order in the array matters for the FormBuilder picker — that's the order
|
||||
* "+ Add" buttons render.
|
||||
*/
|
||||
import type { FeedbackQuestion } from '$lib/schemas';
|
||||
import type { AnyQuestionTypeModule } from './types';
|
||||
|
||||
// Per-type modules will be imported here as they land in the next commits.
|
||||
// QUESTION_MODULES intentionally starts empty — the wiring step (final
|
||||
// commit of Phase 2) flips the legacy callers over to the registry once all
|
||||
// seven types are in place. Until then, both code paths coexist: the legacy
|
||||
// `q.type === '...'` strips in FormBuilder / participant page / Results.svelte
|
||||
// / results.ts / submit / export keep working, and per-type tests exercise
|
||||
// the modules' pure logic in isolation.
|
||||
export const QUESTION_MODULES: readonly AnyQuestionTypeModule[] = [];
|
||||
|
||||
/** Look up the module for a question type. Throws on unknown — every type
|
||||
* in `FeedbackQuestion['type']` must have a module registered. */
|
||||
export function getQuestion<T extends FeedbackQuestion['type']>(type: T): AnyQuestionTypeModule {
|
||||
const mod = QUESTION_MODULES.find((m) => m.type === type);
|
||||
if (!mod) {
|
||||
throw new Error(
|
||||
`Unknown question type: ${type}. Add a module under lib/questions/<type>.ts and register it in lib/questions/registry.ts.`,
|
||||
);
|
||||
}
|
||||
return mod;
|
||||
}
|
||||
|
||||
/** Test if a question type has a registered module. Used by the wiring
|
||||
* step's runtime sanity check. */
|
||||
export function hasQuestion(type: string): type is FeedbackQuestion['type'] {
|
||||
return QUESTION_MODULES.some((m) => m.type === type);
|
||||
}
|
||||
|
||||
/** Ordered list of registered type literals — drives the FormBuilder's
|
||||
* "+ Add" picker order. */
|
||||
export function listQuestionTypes(): FeedbackQuestion['type'][] {
|
||||
return QUESTION_MODULES.map((m) => m.type);
|
||||
}
|
||||
121
src/lib/questions/types.ts
Normal file
121
src/lib/questions/types.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Shape of a per-question-type module. One such module lives in
|
||||
* `lib/questions/<type>.ts` for each kind of question fdbck supports.
|
||||
*
|
||||
* The registry (`./registry.ts`) holds the seven concrete modules. Anywhere
|
||||
* that used to dispatch on `q.type === '...'` now calls
|
||||
* `getQuestion(q.type).method(q, ...)`. Adding a new type = creating one
|
||||
* file + one line in the registry array.
|
||||
*
|
||||
* The Svelte component slots (`ParticipantInput`, `BuilderEditor`,
|
||||
* `ResultsBlock`) accept broadly-typed props and narrow internally on
|
||||
* `question.type`. The dispatch is always sound (callers look up via
|
||||
* `getQuestion(q.type)` so the module already matches the question), but
|
||||
* TypeScript can't prove the cross-component relationship without a lot of
|
||||
* generic gymnastics — the runtime check inside each component is cheaper
|
||||
* and it makes the registry literal stay simple.
|
||||
*/
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
import type { Component } from 'svelte';
|
||||
import type { FeedbackQuestion } from '$lib/schemas';
|
||||
import type { QuestionStats, QuestionResult } from '$lib/server/results';
|
||||
|
||||
/** Pull the variant of FeedbackQuestion that has the given `type` literal. */
|
||||
export type QuestionForType<T extends FeedbackQuestion['type']> = Extract<
|
||||
FeedbackQuestion,
|
||||
{ type: T }
|
||||
>;
|
||||
|
||||
/** Pull the variant of QuestionStats that has the given `type` literal. */
|
||||
export type StatsForType<T extends FeedbackQuestion['type']> = Extract<
|
||||
QuestionStats,
|
||||
{ type: T }
|
||||
>;
|
||||
|
||||
export interface CsvColumn {
|
||||
/** Column header in the exported CSV (e.g. `q1` or `kickoff_when[opt1]`). */
|
||||
header: string;
|
||||
/** Question id this column belongs to. */
|
||||
qid: string;
|
||||
/** Option id, only set for multi-column types like date_ranked_choice. */
|
||||
optId?: string;
|
||||
}
|
||||
|
||||
export interface ParticipantInputProps {
|
||||
question: FeedbackQuestion;
|
||||
answer: unknown;
|
||||
setAnswer(value: unknown): void;
|
||||
}
|
||||
|
||||
export interface BuilderEditorProps {
|
||||
question: FeedbackQuestion;
|
||||
update(patch: Partial<FeedbackQuestion>): void;
|
||||
}
|
||||
|
||||
export interface ResultsBlockProps {
|
||||
question: QuestionResult;
|
||||
stats: QuestionStats;
|
||||
}
|
||||
|
||||
export interface QuestionTypeModule<T extends FeedbackQuestion['type']> {
|
||||
/** Discriminator literal — matches the question's `type` field. */
|
||||
readonly type: T;
|
||||
|
||||
/** Human-readable name for the FormBuilder type picker. */
|
||||
readonly label: string;
|
||||
|
||||
/** Zod schema for this question's shape. The schemas registry assembles
|
||||
* the discriminated union from all modules' schemas. */
|
||||
readonly schema: ZodTypeAny;
|
||||
|
||||
/** Build a fresh question of this type for the FormBuilder "+ Add" button.
|
||||
* The caller will overwrite `id` to a fresh uid. */
|
||||
defaultStub(): QuestionForType<T>;
|
||||
|
||||
/** Empty / required-violation answer test. The single source of truth for
|
||||
* "is this answer missing?" — used by both the client-side validator on
|
||||
* /f/[slug] AND the server-side gate in /api/.../submit. */
|
||||
isAnswerEmpty(question: QuestionForType<T>, answer: unknown): boolean;
|
||||
|
||||
/** Initial aggregator state for this question. */
|
||||
emptyStats(question: QuestionForType<T>): StatsForType<T>;
|
||||
|
||||
/** Fold one answer into the aggregator. Mutates `stats` in place. */
|
||||
ingest(
|
||||
stats: StatsForType<T>,
|
||||
question: QuestionForType<T>,
|
||||
answer: unknown,
|
||||
createdAt: string,
|
||||
): void;
|
||||
|
||||
/** Aggregator close-out: compute means, sort options, drop accumulators. */
|
||||
finalise(stats: StatsForType<T>): void;
|
||||
|
||||
/** Strip PII / contributor-identifying answer text for the public results
|
||||
* endpoint that anonymous participants see after submitting. */
|
||||
sanitizeForPublic(stats: StatsForType<T>): StatsForType<T>;
|
||||
|
||||
/** CSV column expansion. Most types return one column; date_ranked_choice
|
||||
* returns one column per option. */
|
||||
csvColumns(question: QuestionForType<T>): CsvColumn[];
|
||||
|
||||
/** CSV cell value for a given column (used after `csvColumns` to fill rows). */
|
||||
csvCellFor(
|
||||
question: QuestionForType<T>,
|
||||
answer: unknown,
|
||||
col: CsvColumn,
|
||||
): string;
|
||||
|
||||
/** One-line cell summary for the admin /[id] submissions table. */
|
||||
adminCellSummary(question: QuestionForType<T>, answer: unknown): string;
|
||||
|
||||
/** Svelte components — see ParticipantInputProps / BuilderEditorProps /
|
||||
* ResultsBlockProps for the shared shapes. */
|
||||
ParticipantInput: Component<ParticipantInputProps>;
|
||||
BuilderEditor: Component<BuilderEditorProps>;
|
||||
ResultsBlock: Component<ResultsBlockProps>;
|
||||
}
|
||||
|
||||
/** Erased shape — the registry stores modules at this level since the
|
||||
* per-type generic only matters at the call site. */
|
||||
export type AnyQuestionTypeModule = QuestionTypeModule<FeedbackQuestion['type']>;
|
||||
Reference in New Issue
Block a user