feat(questions): short_text + long_text modules (registry slots 2-3/7)
Two text-input types share the same shape — a short input vs. a multi-line
textarea is the only material difference at the participant level. Same
results rendering, same CSV expansion, same admin summary, same
sanitise-for-public strip-and-keep-count rule.
Files added:
- short_text.ts + long_text.ts — module data + schemas (each extends the
shared base with a `placeholder` field)
- short_text.input.svelte — single-line <input>
- long_text.input.svelte — multi-line <textarea> with rows=4
- short_text.builder.svelte — placeholder-text editor (shared between
both modules; long_text.ts re-imports it)
- text.results.svelte — shared answer-list / count-only results block
- text.test.ts — 11 cases covering schema accept/reject, isAnswerEmpty
(blank/whitespace/non-string vs. real text), ingest order + filter,
sanitizeForPublic strip-text-keep-count, CSV passthrough,
adminCellSummary
Both modules registered in QUESTION_MODULES.
types.ts fix: StatsForType<T> now uses intersection (`QuestionStats &
{ type: T }`) instead of `Extract<QuestionStats, { type: T }>`. The
existing TextStats variant declares `type: 'short_text' | 'long_text'`
as a single union, and `Extract<T, U>` narrows to `never` when T's
discriminator is a union and U narrows it; intersection works correctly.
80 server tests pass (was 70). svelte-check + bun run build 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",
|
||||
"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",
|
||||
"test:components": "bun --bun vitest run --config vitest.config.ts",
|
||||
"test": "bun run test:server && bun run test:components"
|
||||
},
|
||||
|
||||
17
src/lib/questions/long_text.input.svelte
Normal file
17
src/lib/questions/long_text.input.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { ParticipantInputProps } from './types';
|
||||
|
||||
let { question, answer, setAnswer }: ParticipantInputProps = $props();
|
||||
</script>
|
||||
|
||||
{#if question.type === 'long_text'}
|
||||
<textarea
|
||||
id={`q-${question.id}`}
|
||||
class="fb-textarea"
|
||||
placeholder={question.placeholder ?? ''}
|
||||
maxlength="5000"
|
||||
rows="4"
|
||||
value={(answer as string) ?? ''}
|
||||
oninput={(e) => setAnswer((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
{/if}
|
||||
65
src/lib/questions/long_text.ts
Normal file
65
src/lib/questions/long_text.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* `long_text` question type — multi-line textarea on participant side,
|
||||
* same answer-list / count-only result rendering as short_text.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { QuestionTypeModule, CsvColumn } from './types';
|
||||
import { FeedbackQuestionBaseSchema } from './_base';
|
||||
import LongTextInput from './long_text.input.svelte';
|
||||
import TextBuilder from './short_text.builder.svelte';
|
||||
import TextResults from './text.results.svelte';
|
||||
|
||||
export const LongTextQuestionSchema = FeedbackQuestionBaseSchema.extend({
|
||||
type: z.literal('long_text'),
|
||||
placeholder: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
export const LongTextQuestion: QuestionTypeModule<'long_text'> = {
|
||||
type: 'long_text',
|
||||
label: 'Long text',
|
||||
schema: LongTextQuestionSchema,
|
||||
|
||||
defaultStub() {
|
||||
return { id: 'q1', label: 'New question', required: false, type: 'long_text' };
|
||||
},
|
||||
|
||||
isAnswerEmpty(_q, answer) {
|
||||
if (typeof answer !== 'string') return true;
|
||||
return answer.trim() === '';
|
||||
},
|
||||
|
||||
emptyStats() {
|
||||
return { type: 'long_text', count: 0, answers: [] };
|
||||
},
|
||||
|
||||
ingest(stats, _q, answer, createdAt) {
|
||||
if (typeof answer !== 'string' || answer.trim() === '') return;
|
||||
stats.count++;
|
||||
stats.answers.push({ value: answer, created_at: createdAt });
|
||||
},
|
||||
|
||||
finalise() {
|
||||
// nothing to finalise — answers are appended in ingest order.
|
||||
},
|
||||
|
||||
sanitizeForPublic(stats) {
|
||||
return { type: 'long_text', count: stats.count, answers: [] };
|
||||
},
|
||||
|
||||
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: LongTextInput,
|
||||
BuilderEditor: TextBuilder,
|
||||
ResultsBlock: TextResults,
|
||||
};
|
||||
@@ -12,13 +12,17 @@
|
||||
import type { FeedbackQuestion } from '$lib/schemas';
|
||||
import type { AnyQuestionTypeModule } from './types';
|
||||
import { BooleanQuestion } from './boolean';
|
||||
import { ShortTextQuestion } from './short_text';
|
||||
import { LongTextQuestion } from './long_text';
|
||||
|
||||
// Order matters — drives the FormBuilder "+ Add" picker layout.
|
||||
// As the remaining six types land in subsequent commits they get appended
|
||||
// here. The wiring step at the end of Phase 2 flips legacy `q.type === '...'`
|
||||
// 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,
|
||||
BooleanQuestion as AnyQuestionTypeModule,
|
||||
];
|
||||
|
||||
|
||||
23
src/lib/questions/short_text.builder.svelte
Normal file
23
src/lib/questions/short_text.builder.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { BuilderEditorProps } from './types';
|
||||
|
||||
let { question, update }: BuilderEditorProps = $props();
|
||||
</script>
|
||||
|
||||
{#if question.type === 'short_text' || question.type === 'long_text'}
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for={`fb-builder-${question.id}-placeholder`}>
|
||||
Placeholder (optional)
|
||||
</label>
|
||||
<input
|
||||
id={`fb-builder-${question.id}-placeholder`}
|
||||
class="fb-input"
|
||||
maxlength="100"
|
||||
value={question.placeholder ?? ''}
|
||||
oninput={(e) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
update({ placeholder: v || undefined });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
17
src/lib/questions/short_text.input.svelte
Normal file
17
src/lib/questions/short_text.input.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { ParticipantInputProps } from './types';
|
||||
|
||||
let { question, answer, setAnswer }: ParticipantInputProps = $props();
|
||||
</script>
|
||||
|
||||
{#if question.type === 'short_text'}
|
||||
<input
|
||||
id={`q-${question.id}`}
|
||||
type="text"
|
||||
class="fb-input"
|
||||
placeholder={question.placeholder ?? ''}
|
||||
maxlength="500"
|
||||
value={(answer as string) ?? ''}
|
||||
oninput={(e) => setAnswer((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
{/if}
|
||||
66
src/lib/questions/short_text.ts
Normal file
66
src/lib/questions/short_text.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* `short_text` question type — single-line text input on participant side,
|
||||
* answer list (or count-only when sanitised) in results.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { QuestionTypeModule, CsvColumn } from './types';
|
||||
import { FeedbackQuestionBaseSchema } from './_base';
|
||||
import ShortTextInput from './short_text.input.svelte';
|
||||
import TextBuilder from './short_text.builder.svelte';
|
||||
import TextResults from './text.results.svelte';
|
||||
|
||||
export const ShortTextQuestionSchema = FeedbackQuestionBaseSchema.extend({
|
||||
type: z.literal('short_text'),
|
||||
placeholder: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
export const ShortTextQuestion: QuestionTypeModule<'short_text'> = {
|
||||
type: 'short_text',
|
||||
label: 'Short text',
|
||||
schema: ShortTextQuestionSchema,
|
||||
|
||||
defaultStub() {
|
||||
return { id: 'q1', label: 'New question', required: false, type: 'short_text' };
|
||||
},
|
||||
|
||||
isAnswerEmpty(_q, answer) {
|
||||
if (typeof answer !== 'string') return true;
|
||||
return answer.trim() === '';
|
||||
},
|
||||
|
||||
emptyStats() {
|
||||
return { type: 'short_text', count: 0, answers: [] };
|
||||
},
|
||||
|
||||
ingest(stats, _q, answer, createdAt) {
|
||||
if (typeof answer !== 'string' || answer.trim() === '') return;
|
||||
stats.count++;
|
||||
stats.answers.push({ value: answer, created_at: createdAt });
|
||||
},
|
||||
|
||||
finalise() {
|
||||
// nothing to finalise — answers are appended in ingest order.
|
||||
},
|
||||
|
||||
sanitizeForPublic(stats) {
|
||||
// PII / contributor identity: drop the text bodies, keep the count.
|
||||
return { type: 'short_text', count: stats.count, answers: [] };
|
||||
},
|
||||
|
||||
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: ShortTextInput,
|
||||
BuilderEditor: TextBuilder,
|
||||
ResultsBlock: TextResults,
|
||||
};
|
||||
28
src/lib/questions/text.results.svelte
Normal file
28
src/lib/questions/text.results.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { ResultsBlockProps } from './types';
|
||||
|
||||
let { stats }: ResultsBlockProps = $props();
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if stats.type === 'short_text' || stats.type === 'long_text'}
|
||||
{#if stats.answers.length === 0}
|
||||
<p class="fb-results__empty">{stats.count} responses (text answers hidden in public view).</p>
|
||||
{:else}
|
||||
<ul class="fb-results__answers">
|
||||
{#each stats.answers as a (a.created_at + a.value)}
|
||||
<li>
|
||||
<span class="fb-results__answer-date">{fmtDate(a.created_at)}</span>
|
||||
<span class="fb-results__answer-text">{a.value}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
98
src/lib/questions/text.test.ts
Normal file
98
src/lib/questions/text.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { ShortTextQuestion, ShortTextQuestionSchema } from './short_text';
|
||||
import { LongTextQuestion, LongTextQuestionSchema } from './long_text';
|
||||
|
||||
// Both modules share the same shape (single-line vs multi-line input is the
|
||||
// only material difference at the participant level). Test the contract once
|
||||
// for each — kept in one file so behaviour drift between the two is easy to
|
||||
// spot.
|
||||
|
||||
describe('ShortTextQuestion', () => {
|
||||
const q = ShortTextQuestion.defaultStub();
|
||||
|
||||
test('schema accepts valid + rejects bad', () => {
|
||||
expect(ShortTextQuestionSchema.safeParse(q).success).toBe(true);
|
||||
expect(
|
||||
ShortTextQuestionSchema.safeParse({ id: 'q1', label: 'X', type: 'short_text', placeholder: 'p' })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(ShortTextQuestionSchema.safeParse({ id: 'q1', label: 'X', type: 'long_text' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isAnswerEmpty: blank/whitespace/non-string → empty; real text → not', () => {
|
||||
expect(ShortTextQuestion.isAnswerEmpty(q, '')).toBe(true);
|
||||
expect(ShortTextQuestion.isAnswerEmpty(q, ' ')).toBe(true);
|
||||
expect(ShortTextQuestion.isAnswerEmpty(q, undefined)).toBe(true);
|
||||
expect(ShortTextQuestion.isAnswerEmpty(q, null)).toBe(true);
|
||||
expect(ShortTextQuestion.isAnswerEmpty(q, 42)).toBe(true);
|
||||
expect(ShortTextQuestion.isAnswerEmpty(q, 'hi')).toBe(false);
|
||||
});
|
||||
|
||||
test('ingest: appends in order, ignores blank/non-string, increments count', () => {
|
||||
const stats = ShortTextQuestion.emptyStats(q);
|
||||
ShortTextQuestion.ingest(stats, q, 'first', '2026-01-01');
|
||||
ShortTextQuestion.ingest(stats, q, '', '2026-01-02');
|
||||
ShortTextQuestion.ingest(stats, q, 'second', '2026-01-03');
|
||||
ShortTextQuestion.ingest(stats, q, 99, '2026-01-04');
|
||||
ShortTextQuestion.finalise(stats);
|
||||
expect(stats.count).toBe(2);
|
||||
expect(stats.answers.map((a) => a.value)).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
test('sanitizeForPublic: drops text, keeps count', () => {
|
||||
const stats = ShortTextQuestion.emptyStats(q);
|
||||
ShortTextQuestion.ingest(stats, q, 'secret', '2026-01-01');
|
||||
ShortTextQuestion.ingest(stats, q, 'sauce', '2026-01-02');
|
||||
const pub = ShortTextQuestion.sanitizeForPublic(stats);
|
||||
expect(pub.count).toBe(2);
|
||||
expect(pub.answers).toEqual([]);
|
||||
});
|
||||
|
||||
test('csv: one column with the question id, cell is the string', () => {
|
||||
const cols = ShortTextQuestion.csvColumns({ ...q, id: 'name' });
|
||||
expect(cols).toEqual([{ header: 'name', qid: 'name' }]);
|
||||
expect(ShortTextQuestion.csvCellFor(q, 'value', cols[0])).toBe('value');
|
||||
expect(ShortTextQuestion.csvCellFor(q, null, cols[0])).toBe('');
|
||||
});
|
||||
|
||||
test('adminCellSummary: passes through string, em-dash for missing', () => {
|
||||
expect(ShortTextQuestion.adminCellSummary(q, 'hi')).toBe('hi');
|
||||
expect(ShortTextQuestion.adminCellSummary(q, null)).toBe('—');
|
||||
expect(ShortTextQuestion.adminCellSummary(q, undefined)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LongTextQuestion', () => {
|
||||
const q = LongTextQuestion.defaultStub();
|
||||
|
||||
test('schema accepts valid + rejects bad', () => {
|
||||
expect(LongTextQuestionSchema.safeParse(q).success).toBe(true);
|
||||
expect(LongTextQuestionSchema.safeParse({ id: 'q1', label: 'X', type: 'short_text' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isAnswerEmpty: behaves like short_text', () => {
|
||||
expect(LongTextQuestion.isAnswerEmpty(q, '')).toBe(true);
|
||||
expect(LongTextQuestion.isAnswerEmpty(q, ' ')).toBe(true);
|
||||
expect(LongTextQuestion.isAnswerEmpty(q, 'multi\nline')).toBe(false);
|
||||
});
|
||||
|
||||
test('ingest: preserves multi-line text', () => {
|
||||
const stats = LongTextQuestion.emptyStats(q);
|
||||
LongTextQuestion.ingest(stats, q, 'line1\nline2', '2026-01-01');
|
||||
LongTextQuestion.finalise(stats);
|
||||
expect(stats.count).toBe(1);
|
||||
expect(stats.answers[0].value).toBe('line1\nline2');
|
||||
});
|
||||
|
||||
test('sanitizeForPublic: same strip behaviour', () => {
|
||||
const stats = LongTextQuestion.emptyStats(q);
|
||||
LongTextQuestion.ingest(stats, q, 'abc', '2026-01-01');
|
||||
const pub = LongTextQuestion.sanitizeForPublic(stats);
|
||||
expect(pub.count).toBe(1);
|
||||
expect(pub.answers).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -26,11 +26,12 @@ export type QuestionForType<T extends FeedbackQuestion['type']> = Extract<
|
||||
{ type: T }
|
||||
>;
|
||||
|
||||
/** Pull the variant of QuestionStats that has the given `type` literal. */
|
||||
export type StatsForType<T extends FeedbackQuestion['type']> = Extract<
|
||||
QuestionStats,
|
||||
{ type: T }
|
||||
>;
|
||||
/** Pull the variant of QuestionStats that has the given `type` literal.
|
||||
* Uses intersection rather than `Extract` because `TextStats` declares
|
||||
* `type: 'short_text' | 'long_text'` as a union (the two share one stats
|
||||
* shape), and `Extract<T, U>` returns never when T's discriminator is a
|
||||
* union and U narrows it. Intersection narrows correctly here. */
|
||||
export type StatsForType<T extends FeedbackQuestion['type']> = QuestionStats & { type: T };
|
||||
|
||||
export interface CsvColumn {
|
||||
/** Column header in the exported CSV (e.g. `q1` or `kickoff_when[opt1]`). */
|
||||
|
||||
Reference in New Issue
Block a user