feat(questions): resources content block (markdown question type)
Adds a non-answerable 'resources' question type so admins can place a markdown content block anywhere in the questions array. Renders inline on /f/<slug> as headings, lists, and links; no answer is collected, no CSV column, no stats. Admin Results tab filters resources out so it never shows an empty results card. Shape per Phase 2 registry: lib/questions/resources.ts + .input.svelte (participant render via shared lib/markdown.ts helper) + .builder.svelte (textarea for authoring) + .results.svelte (placeholder so the registry slot is consistent). Schema discriminated union picks up the new type via lib/schemas.ts + registry.ts. Description on /f/<slug> now routes through the same renderMarkdown() helper instead of its inlined copy. Motivation: HL PA UPC Deadlines training 2026-05-28 — m wanted the resources block to be its own positionable thing inside the form, not crammed into the description above the title.
This commit is contained in:
1
.worktrees/iris
Submodule
1
.worktrees/iris
Submodule
Submodule .worktrees/iris added at b49f2e0dcc
@@ -14,7 +14,7 @@
|
||||
{#if results.total_submissions === 0}
|
||||
<div class="fb-results__empty">Noch keine Antworten.</div>
|
||||
{:else}
|
||||
{#each results.questions as q (q.id)}
|
||||
{#each results.questions.filter((q) => q.type !== 'resources') as q (q.id)}
|
||||
{@const Block = getQuestion(q.type).ResultsBlock}
|
||||
<div class="fb-results__q">
|
||||
<div class="fb-results__label">{q.label}</div>
|
||||
|
||||
6
src/lib/markdown.ts
Normal file
6
src/lib/markdown.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
export function renderMarkdown(src: string): string {
|
||||
return DOMPurify.sanitize(marked.parse(src, { breaks: true, gfm: true, async: false }) as string);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { ScaleQuestion } from './scale';
|
||||
import { SingleChoiceQuestion } from './single_choice';
|
||||
import { MultiChoiceQuestion } from './multi_choice';
|
||||
import { DateRankedChoiceQuestion } from './date_ranked_choice';
|
||||
import { ResourcesQuestion } from './resources';
|
||||
|
||||
// Order matters — drives the FormBuilder "+ Add" picker layout.
|
||||
// The wiring step at the end of Phase 2 flips legacy `q.type === '...'`
|
||||
@@ -31,6 +32,7 @@ export const QUESTION_MODULES: readonly AnyQuestionTypeModule[] = [
|
||||
ScaleQuestion as AnyQuestionTypeModule,
|
||||
BooleanQuestion as AnyQuestionTypeModule,
|
||||
DateRankedChoiceQuestion as AnyQuestionTypeModule,
|
||||
ResourcesQuestion as AnyQuestionTypeModule,
|
||||
];
|
||||
|
||||
/** Look up the module for a question type. Throws on unknown — every type
|
||||
|
||||
20
src/lib/questions/resources.builder.svelte
Normal file
20
src/lib/questions/resources.builder.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { BuilderEditorProps } from './types';
|
||||
|
||||
let { question, update }: BuilderEditorProps = $props();
|
||||
const md = $derived(question.type === 'resources' ? (question.markdown ?? '') : '');
|
||||
</script>
|
||||
|
||||
<label class="fb-field">
|
||||
<span class="fb-field__label">Inhalt (Markdown)</span>
|
||||
<textarea
|
||||
class="fb-input fb-input--ta"
|
||||
rows="10"
|
||||
placeholder="### Section heading - [Label](https://example.com)"
|
||||
value={md}
|
||||
oninput={(e) => update({ markdown: (e.target as HTMLTextAreaElement).value })}
|
||||
></textarea>
|
||||
<span class="fb-field__help">
|
||||
Markdown wird mit Headings, Bullet-Listen und Links gerendert.
|
||||
</span>
|
||||
</label>
|
||||
87
src/lib/questions/resources.input.svelte
Normal file
87
src/lib/questions/resources.input.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import type { ParticipantInputProps } from './types';
|
||||
import { renderMarkdown } from '$lib/markdown';
|
||||
|
||||
let { question }: ParticipantInputProps = $props();
|
||||
|
||||
const md = $derived(question.type === 'resources' ? (question.markdown ?? '') : '');
|
||||
</script>
|
||||
|
||||
{#if md}
|
||||
<div class="fb-resources">{@html renderMarkdown(md)}</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.fb-resources :global(h1),
|
||||
.fb-resources :global(h2),
|
||||
.fb-resources :global(h3) {
|
||||
margin: 1rem 0 0.4rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.fb-resources :global(h4),
|
||||
.fb-resources :global(h5),
|
||||
.fb-resources :global(h6) {
|
||||
margin: 0.8rem 0 0.3rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.fb-resources :global(p) {
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.fb-resources :global(ul),
|
||||
.fb-resources :global(ol) {
|
||||
margin: 0.25rem 0 0.6rem;
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
.fb-resources :global(ul) {
|
||||
list-style: disc;
|
||||
}
|
||||
.fb-resources :global(ol) {
|
||||
list-style: decimal;
|
||||
}
|
||||
.fb-resources :global(li) {
|
||||
margin: 0.2rem 0;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.fb-resources :global(li > p) {
|
||||
margin: 0;
|
||||
}
|
||||
.fb-resources :global(a) {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.fb-resources :global(a:hover) {
|
||||
color: var(--color-primary-hover);
|
||||
border-bottom-color: currentColor;
|
||||
}
|
||||
.fb-resources :global(a:focus-visible) {
|
||||
outline: 2px solid var(--color-border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.fb-resources :global(code) {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
background: var(--color-bg-tertiary);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.fb-resources :global(> :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
.fb-resources :global(> :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
8
src/lib/questions/resources.results.svelte
Normal file
8
src/lib/questions/resources.results.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { ResultsBlockProps } from './types';
|
||||
|
||||
// Content block — no answers, no stats, no admin Results rendering.
|
||||
// Results.svelte filters resources out, so this component is a placeholder
|
||||
// the registry can still point at.
|
||||
let { question: _q, stats: _s }: ResultsBlockProps = $props();
|
||||
</script>
|
||||
72
src/lib/questions/resources.ts
Normal file
72
src/lib/questions/resources.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* `resources` — non-answerable content block. Renders author-provided
|
||||
* markdown inline among questions on the participant page. No answer is
|
||||
* collected, no stats, no CSV column. Authors place it anywhere in the
|
||||
* questions array to insert a Resources / Links / context section.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { QuestionTypeModule, CsvColumn } from './types';
|
||||
import { FeedbackQuestionBaseSchema } from './_base';
|
||||
import ResourcesInput from './resources.input.svelte';
|
||||
import ResourcesBuilder from './resources.builder.svelte';
|
||||
import ResourcesResults from './resources.results.svelte';
|
||||
|
||||
export const ResourcesQuestionSchema = FeedbackQuestionBaseSchema.extend({
|
||||
type: z.literal('resources'),
|
||||
markdown: z.string().max(5000),
|
||||
});
|
||||
|
||||
type Q = z.infer<typeof ResourcesQuestionSchema>;
|
||||
|
||||
export const ResourcesQuestion: QuestionTypeModule<'resources'> = {
|
||||
type: 'resources',
|
||||
label: 'Resources / Markdown block',
|
||||
schema: ResourcesQuestionSchema,
|
||||
|
||||
defaultStub() {
|
||||
return {
|
||||
id: 'r1',
|
||||
label: 'Resources',
|
||||
type: 'resources',
|
||||
markdown: '### Resources\n\n- [Link](https://example.com)',
|
||||
};
|
||||
},
|
||||
|
||||
isAnswerEmpty(_q: Q, _answer: unknown): boolean {
|
||||
// Content blocks never have an answer — treat as always empty so
|
||||
// required-checking and aggregation skip them naturally.
|
||||
return true;
|
||||
},
|
||||
|
||||
emptyStats() {
|
||||
return { type: 'resources', count: 0 };
|
||||
},
|
||||
|
||||
ingest() {
|
||||
// No-op — no answers to fold.
|
||||
},
|
||||
|
||||
finalise() {
|
||||
// No-op.
|
||||
},
|
||||
|
||||
sanitizeForPublic(stats) {
|
||||
return stats;
|
||||
},
|
||||
|
||||
csvColumns(_q: Q): CsvColumn[] {
|
||||
return [];
|
||||
},
|
||||
|
||||
csvCellFor() {
|
||||
return '';
|
||||
},
|
||||
|
||||
adminCellSummary() {
|
||||
return '';
|
||||
},
|
||||
|
||||
ParticipantInput: ResourcesInput,
|
||||
BuilderEditor: ResourcesBuilder,
|
||||
ResultsBlock: ResourcesResults,
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DateRankedChoiceQuestionSchema,
|
||||
DateRankedOptionSchema as PerTypeDateRankedOptionSchema,
|
||||
} from './questions/date_ranked_choice';
|
||||
import { ResourcesQuestionSchema } from './questions/resources';
|
||||
|
||||
/** Re-exported for callers that need the option shape standalone. */
|
||||
export const DateRankedOptionSchema = PerTypeDateRankedOptionSchema;
|
||||
@@ -32,6 +33,7 @@ export const FeedbackQuestionSchema = z.discriminatedUnion('type', [
|
||||
ScaleQuestionSchema,
|
||||
BooleanQuestionSchema,
|
||||
DateRankedChoiceQuestionSchema,
|
||||
ResourcesQuestionSchema,
|
||||
]);
|
||||
|
||||
/** Version stamp like `0.260505` (YYMMDD) or `0.260505.b` for same-day re-edits. */
|
||||
|
||||
@@ -58,7 +58,18 @@ export interface DateRankedChoiceStats {
|
||||
options: DateRankedOptionStats[];
|
||||
}
|
||||
|
||||
export type QuestionStats = ScaleStats | ChoiceStats | BooleanStats | TextStats | DateRankedChoiceStats;
|
||||
export interface ResourcesStats {
|
||||
type: 'resources';
|
||||
count: 0;
|
||||
}
|
||||
|
||||
export type QuestionStats =
|
||||
| ScaleStats
|
||||
| ChoiceStats
|
||||
| BooleanStats
|
||||
| TextStats
|
||||
| DateRankedChoiceStats
|
||||
| ResourcesStats;
|
||||
|
||||
export interface QuestionResult {
|
||||
id: string;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { renderMarkdown } from '$lib/markdown';
|
||||
import type { FeedbackFormDefinition, FeedbackQuestion } from '$lib/schemas';
|
||||
import type { AggregatedResults } from '$lib/server/results';
|
||||
import { getQuestion } from '$lib/questions/registry';
|
||||
@@ -10,10 +9,6 @@
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function renderDescription(src: string): string {
|
||||
return DOMPurify.sanitize(marked.parse(src, { breaks: true, gfm: true, async: false }));
|
||||
}
|
||||
|
||||
const isClosed = data.status === 'closed';
|
||||
const formDef = data.form_definition as FeedbackFormDefinition | null;
|
||||
const chatEnabled = data.chat_enabled;
|
||||
@@ -508,7 +503,7 @@
|
||||
<header class="fb-header">
|
||||
<h1>{data.title}</h1>
|
||||
{#if data.description}
|
||||
<div class="fb-description">{@html renderDescription(data.description)}</div>
|
||||
<div class="fb-description">{@html renderMarkdown(data.description)}</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user