From 1c3f96640a0b4ef8d1823c78d826b85be30ceb3f Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Sun, 21 Jun 2026 13:55:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(forms):=20rating-family=20field=20types=20?= =?UTF-8?q?(stars,=20NPS,=20slider,=20emoji)=20=E2=80=94=20slice=209a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First batch of Phase 2 field types. All four answer with a number on a bounded scale resolved by a single shared `scaleBounds` helper, so the renderer widgets, server-side validation, and answer formatter stay in sync. - shared: `scaleBounds`, `EMOJI_SCALE`, `SCALE_FIELD_TYPES`, NPS constants; add `step` to field validation; `buildAnswerSchema` enforces per-type scale bounds (NPS 0–10 incl. 0, emoji 1–5, stars 1–max default 5, slider min/max default 0–100); `formatAnswerValue` renders stars/NPS/emoji with their scale. - web: renderer widgets (`StarRating`, `ScaleButtons`, `SliderInput`); wire the four cases in `FieldInput`; builder offers the new types with star-max and slider min/max/step config; DE/EN labels + builder strings. - tests: scale validation for all four types + formatter output. Follow-ups: matrix (9b), signature (9c). --- .../src/components/builder/field-editor.tsx | 54 ++++++++- apps/web/src/components/form/field-input.tsx | 52 ++++++++- .../web/src/components/form/rating-fields.tsx | 104 ++++++++++++++++++ apps/web/src/i18n/dictionaries.ts | 4 + apps/web/src/lib/builder-fields.ts | 11 ++ packages/shared/src/form-spec.test.ts | 47 ++++++++ packages/shared/src/form-spec.ts | 53 ++++++++- 7 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/form/rating-fields.tsx diff --git a/apps/web/src/components/builder/field-editor.tsx b/apps/web/src/components/builder/field-editor.tsx index 0514754..aed3c0f 100644 --- a/apps/web/src/components/builder/field-editor.tsx +++ b/apps/web/src/components/builder/field-editor.tsx @@ -3,7 +3,12 @@ import type { FormField } from "@msk-forms/shared"; import { Card, Checkbox, Field, Input } from "@msk-forms/ui"; -import { isLayoutType, needsOptions } from "@/lib/builder-fields"; +import { + isLayoutType, + needsOptions, + needsSliderConfig, + needsStarsConfig, +} from "@/lib/builder-fields"; import type { Dictionary } from "@/i18n"; type BuilderDict = Dictionary["builder"]; @@ -47,6 +52,11 @@ export function FieldEditor({ function removeOption(i: number) { patch({ options: (field.options ?? []).filter((_, j) => j !== i) }); } + function setValidation(partial: Partial) { + patch({ validation: { ...field.validation, ...partial } }); + } + /** Parse a numeric config input, treating an empty string as "unset". */ + const num = (s: string): number | undefined => (s === "" ? undefined : Number(s)); return ( @@ -112,6 +122,48 @@ export function FieldEditor({ )} + {needsStarsConfig(field.type) && ( + + setValidation({ max: num(e.target.value) })} + /> + + )} + + {needsSliderConfig(field.type) && ( +
+ + setValidation({ min: num(e.target.value) })} + /> + + + setValidation({ max: num(e.target.value) })} + /> + + + setValidation({ step: num(e.target.value) })} + /> + +
+ )} + ); + case "rating_stars": { + const { max } = scaleBounds(field); + return ( + + ); + } + + case "nps": { + const { min, max } = scaleBounds(field); + return ( + + ); + } + + case "emoji_scale": { + const { min, max } = scaleBounds(field); + return ( + EMOJI_SCALE[n - 1] ?? String(n)} + disabled={disabled} + onChange={onChange} + /> + ); + } + + case "slider": { + const { min, max, step } = scaleBounds(field); + return ( + + ); + } + case "single_choice": return ( void; + disabled?: boolean; +} + +/** Clickable star rating (1…max). Clicking the current value clears it. */ +export function StarRating({ value, max, onChange, disabled }: BaseProps & { max: number }) { + return ( +
+ {Array.from({ length: max }, (_, i) => i + 1).map((n) => { + const active = (value ?? 0) >= n; + return ( + + ); + })} +
+ ); +} + +/** Row of discrete value buttons (NPS 0–10, emoji 1–5). */ +export function ScaleButtons({ + value, + min, + max, + renderLabel, + onChange, + disabled, +}: BaseProps & { min: number; max: number; renderLabel?: (n: number) => string }) { + const values = Array.from({ length: max - min + 1 }, (_, i) => min + i); + return ( +
+ {values.map((n) => { + const active = value === n; + return ( + + ); + })} +
+ ); +} + +/** Range slider with the current value shown alongside. */ +export function SliderInput({ + value, + min, + max, + step, + onChange, + disabled, +}: BaseProps & { min: number; max: number; step: number }) { + const current = value ?? min; + return ( +
+ onChange(Number(e.target.value))} + className="h-2 w-full cursor-pointer accent-primary disabled:cursor-not-allowed disabled:opacity-50" + /> + {current} +
+ ); +} diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index 3872203..d96981d 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -133,9 +133,11 @@ const en = { options: "Options", optionPh: "Option", required: "Required", addOption: "Add option", moveUp: "Move up", moveDown: "Move down", remove: "Remove", removeOption: "Remove option", labelPh: "Question label…", headingPh: "Section heading…", + stars: "Stars", min: "Min", max: "Max", step: "Step", ft: { short_text: "Short text", long_text: "Long text", email: "Email", number: "Number", phone: "Phone", url: "URL", single_choice: "Single choice", dropdown: "Dropdown", multi_choice: "Multiple choice", yes_no: "Yes / No", + rating_stars: "Star rating", nps: "NPS (0–10)", slider: "Slider", emoji_scale: "Emoji scale", date: "Date", file_upload: "File upload", image_upload: "Image upload", consent: "Consent", heading: "Heading", paragraph: "Paragraph", divider: "Divider", }, @@ -285,9 +287,11 @@ const de: Dictionary = { options: "Optionen", optionPh: "Option", required: "Pflichtfeld", addOption: "Option hinzufügen", moveUp: "Nach oben", moveDown: "Nach unten", remove: "Entfernen", removeOption: "Option entfernen", labelPh: "Fragetext…", headingPh: "Abschnittsüberschrift…", + stars: "Sterne", min: "Min", max: "Max", step: "Schritt", ft: { short_text: "Kurztext", long_text: "Langtext", email: "E-Mail", number: "Zahl", phone: "Telefon", url: "URL", single_choice: "Einfachauswahl", dropdown: "Dropdown", multi_choice: "Mehrfachauswahl", yes_no: "Ja / Nein", + rating_stars: "Sterne-Bewertung", nps: "NPS (0–10)", slider: "Schieberegler", emoji_scale: "Emoji-Skala", date: "Datum", file_upload: "Datei-Upload", image_upload: "Bild-Upload", consent: "Zustimmung", heading: "Überschrift", paragraph: "Absatz", divider: "Trenner", }, diff --git a/apps/web/src/lib/builder-fields.ts b/apps/web/src/lib/builder-fields.ts index d60d1a7..715826e 100644 --- a/apps/web/src/lib/builder-fields.ts +++ b/apps/web/src/lib/builder-fields.ts @@ -12,6 +12,10 @@ export const BUILDER_FIELDS: { type: FieldType; label: string }[] = [ { type: "dropdown", label: "Dropdown" }, { type: "multi_choice", label: "Multiple choice" }, { type: "yes_no", label: "Yes / No" }, + { type: "rating_stars", label: "Star rating" }, + { type: "nps", label: "NPS (0–10)" }, + { type: "slider", label: "Slider" }, + { type: "emoji_scale", label: "Emoji scale" }, { type: "date", label: "Date" }, { type: "file_upload", label: "File upload" }, { type: "image_upload", label: "Image upload" }, @@ -29,5 +33,12 @@ const BUILDER_LAYOUT_TYPES: FieldType[] = ["heading", "paragraph", "divider"]; export const fieldTypeLabel = (type: FieldType): string => BUILDER_FIELDS.find((f) => f.type === type)?.label ?? type; +// Star rating exposes a max; slider exposes min/max/step. NPS (0–10) and emoji +// (1–5) have fixed scales, so they need no extra config. +const STARS_CONFIG_TYPES: FieldType[] = ["rating_stars"]; +const SLIDER_CONFIG_TYPES: FieldType[] = ["slider"]; + export const needsOptions = (type: FieldType): boolean => CHOICE_TYPES.includes(type); export const isLayoutType = (type: FieldType): boolean => BUILDER_LAYOUT_TYPES.includes(type); +export const needsStarsConfig = (type: FieldType): boolean => STARS_CONFIG_TYPES.includes(type); +export const needsSliderConfig = (type: FieldType): boolean => SLIDER_CONFIG_TYPES.includes(type); diff --git a/packages/shared/src/form-spec.test.ts b/packages/shared/src/form-spec.test.ts index d8a82df..21bb447 100644 --- a/packages/shared/src/form-spec.test.ts +++ b/packages/shared/src/form-spec.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildAnswerSchema, + formatAnswerValue, formSpecSchema, isLayoutField, type FormField, @@ -136,4 +137,50 @@ describe("buildAnswerSchema", () => { ); expect(s.safeParse({ t: "anything" }).success).toBe(true); }); + + it("enforces the fixed NPS 0–10 scale (incl. 0)", () => { + const s = buildAnswerSchema(specWith({ id: "n", type: "nps", validation: { required: true } })); + expect(s.safeParse({ n: 0 }).success).toBe(true); + expect(s.safeParse({ n: 10 }).success).toBe(true); + expect(s.safeParse({ n: 11 }).success).toBe(false); + expect(s.safeParse({ n: -1 }).success).toBe(false); + }); + + it("defaults star rating to 1–5 and respects a custom max", () => { + const def = buildAnswerSchema(specWith({ id: "r", type: "rating_stars", validation: { required: true } })); + expect(def.safeParse({ r: 5 }).success).toBe(true); + expect(def.safeParse({ r: 6 }).success).toBe(false); + expect(def.safeParse({ r: 0 }).success).toBe(false); + + const ten = buildAnswerSchema( + specWith({ id: "r", type: "rating_stars", validation: { required: true, max: 10 } }), + ); + expect(ten.safeParse({ r: 10 }).success).toBe(true); + }); + + it("enforces the emoji scale 1–5", () => { + const s = buildAnswerSchema(specWith({ id: "e", type: "emoji_scale", validation: { required: true } })); + expect(s.safeParse({ e: 1 }).success).toBe(true); + expect(s.safeParse({ e: 5 }).success).toBe(true); + expect(s.safeParse({ e: 6 }).success).toBe(false); + }); + + it("enforces slider bounds (default 0–100)", () => { + const s = buildAnswerSchema(specWith({ id: "s", type: "slider", validation: { required: true } })); + expect(s.safeParse({ s: 0 }).success).toBe(true); + expect(s.safeParse({ s: 100 }).success).toBe(true); + expect(s.safeParse({ s: 101 }).success).toBe(false); + }); +}); + +describe("formatAnswerValue (rating family)", () => { + const L = { empty: "—", yes: "Yes", no: "No" }; + it("renders nps, stars and emoji with their scale", () => { + const nps: FormField = { id: "n", type: "nps", width: "full", validation: { required: false }, conditional: [] }; + const stars: FormField = { id: "r", type: "rating_stars", width: "full", validation: { required: false }, conditional: [] }; + const emoji: FormField = { id: "e", type: "emoji_scale", width: "full", validation: { required: false }, conditional: [] }; + expect(formatAnswerValue(nps, 8, L)).toBe("8 / 10"); + expect(formatAnswerValue(stars, 4, L)).toBe("★ 4"); + expect(formatAnswerValue(emoji, 5, L)).toContain("5/5"); + }); }); diff --git a/packages/shared/src/form-spec.ts b/packages/shared/src/form-spec.ts index 8b8c426..3042b7d 100644 --- a/packages/shared/src/form-spec.ts +++ b/packages/shared/src/form-spec.ts @@ -89,6 +89,8 @@ export const fieldValidationSchema = z.object({ allowedMimeTypes: z.array(z.string()).optional(), // Capped at the hard server limit so a crafted spec can't raise it. maxFileSizeMb: z.number().positive().max(MAX_FILE_SIZE_MB).optional(), + // Step size for slider fields (defaults to 1 when unset). + step: z.number().positive().optional(), }); export const fieldOptionSchema = z.object({ @@ -156,6 +158,38 @@ export function isLayoutField(type: FieldType): boolean { return (LAYOUT_FIELD_TYPES as readonly string[]).includes(type); } +/** Emoji shown for an `emoji_scale` field, lowest → highest (value = index + 1). */ +export const EMOJI_SCALE = ["😡", "😕", "😐", "🙂", "😍"] as const; + +/** NPS is a fixed 0–10 scale. */ +export const NPS_MIN = 0; +export const NPS_MAX = 10; + +/** Rating-family field types whose answer is a number on a bounded scale. */ +export const SCALE_FIELD_TYPES = ["rating_stars", "nps", "slider", "emoji_scale"] as const; + +/** + * Resolved scale bounds for a rating-family field. Per-type defaults: NPS is a + * fixed 0–10, emoji is 1–5, stars default to 1–5 (overridable via `max`), and a + * slider falls back to 0–100 step 1. Shared by the renderer widgets, the + * server-side validation, and the answer formatter so they can't drift. + */ +export function scaleBounds(field: FormField): { min: number; max: number; step: number } { + const v = field.validation; + switch (field.type) { + case "nps": + return { min: NPS_MIN, max: NPS_MAX, step: 1 }; + case "emoji_scale": + return { min: 1, max: EMOJI_SCALE.length, step: 1 }; + case "rating_stars": + return { min: 1, max: v.max ?? 5, step: 1 }; + case "slider": + return { min: v.min ?? 0, max: v.max ?? 100, step: v.step ?? 1 }; + default: + return { min: v.min ?? 0, max: v.max ?? 0, step: v.step ?? 1 }; + } +} + /** Caller-supplied labels for {@link formatAnswerValue} (lets each surface i18n). */ export interface AnswerValueLabels { /** Shown for an empty/missing answer. */ @@ -185,11 +219,18 @@ export function formatAnswerValue( if (typeof value === "object" && "name" in value) { return String((value as { name: unknown }).name); } + // Rating-family fields read better with their scale shown. + if (field.type === "emoji_scale") { + const emoji = EMOJI_SCALE[Math.round(Number(value)) - 1]; + return emoji ? `${emoji} (${Number(value)}/${EMOJI_SCALE.length})` : String(value); + } + if (field.type === "nps") return `${value} / ${NPS_MAX}`; + if (field.type === "rating_stars") return `★ ${value}`; if (field.options) return labelFor(String(value)); return String(value); } -const NUMBER_TYPES = ["number", "slider", "nps", "rating_stars"]; +const NUMBER_TYPES = ["number", "slider", "nps", "rating_stars", "emoji_scale"]; const MULTI_TYPES = ["multi_choice", "multi_select", "ranking"]; const BOOLEAN_TYPES = ["yes_no", "consent", "age_check"]; const SINGLE_CHOICE_TYPES = ["single_choice", "dropdown"]; @@ -218,8 +259,14 @@ function buildFieldSchema(field: FormField): z.ZodTypeAny { if (NUMBER_TYPES.includes(field.type)) { let n = z.number(); - if (v.min !== undefined) n = n.min(v.min); - if (v.max !== undefined) n = n.max(v.max); + if ((SCALE_FIELD_TYPES as readonly string[]).includes(field.type)) { + // Rating-family: enforce the resolved scale bounds, not raw min/max. + const { min, max } = scaleBounds(field); + n = n.min(min).max(max); + } else { + if (v.min !== undefined) n = n.min(v.min); + if (v.max !== undefined) n = n.max(v.max); + } base = n; } else if (MULTI_TYPES.includes(field.type)) { let arr = z.array(z.string());