Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion apps/web/src/components/builder/field-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -47,6 +52,11 @@ export function FieldEditor({
function removeOption(i: number) {
patch({ options: (field.options ?? []).filter((_, j) => j !== i) });
}
function setValidation(partial: Partial<FormField["validation"]>) {
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 (
<Card className="flex flex-col gap-3 p-4">
Expand Down Expand Up @@ -112,6 +122,48 @@ export function FieldEditor({
</Field>
)}

{needsStarsConfig(field.type) && (
<Field label={t.stars}>
<Input
type="number"
min={1}
value={field.validation.max ?? ""}
placeholder="5"
onChange={(e) => setValidation({ max: num(e.target.value) })}
/>
</Field>
)}

{needsSliderConfig(field.type) && (
<div className="grid grid-cols-3 gap-2">
<Field label={t.min}>
<Input
type="number"
value={field.validation.min ?? ""}
placeholder="0"
onChange={(e) => setValidation({ min: num(e.target.value) })}
/>
</Field>
<Field label={t.max}>
<Input
type="number"
value={field.validation.max ?? ""}
placeholder="100"
onChange={(e) => setValidation({ max: num(e.target.value) })}
/>
</Field>
<Field label={t.step}>
<Input
type="number"
min={1}
value={field.validation.step ?? ""}
placeholder="1"
onChange={(e) => setValidation({ step: num(e.target.value) })}
/>
</Field>
</div>
)}

<Checkbox
label={t.required}
checked={field.validation.required}
Expand Down
52 changes: 50 additions & 2 deletions apps/web/src/components/form/field-input.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import type { FileAnswer, FormField } from "@msk-forms/shared";
import { EMOJI_SCALE, scaleBounds, type FileAnswer, type FormField } from "@msk-forms/shared";
import {
Checkbox,
CheckboxGroup,
Expand All @@ -11,6 +11,7 @@ import {
} from "@msk-forms/ui";

import { FileField, type FileFieldLabels } from "./file-field";
import { ScaleButtons, SliderInput, StarRating } from "./rating-fields";

export type FieldValue = string | number | boolean | string[] | FileAnswer | undefined;

Expand Down Expand Up @@ -70,7 +71,6 @@ export function FieldInput({
);

case "number":
case "slider":
return (
<Input
id={id}
Expand All @@ -83,6 +83,54 @@ export function FieldInput({
/>
);

case "rating_stars": {
const { max } = scaleBounds(field);
return (
<StarRating value={value as number | undefined} max={max} disabled={disabled} onChange={onChange} />
);
}

case "nps": {
const { min, max } = scaleBounds(field);
return (
<ScaleButtons
value={value as number | undefined}
min={min}
max={max}
disabled={disabled}
onChange={onChange}
/>
);
}

case "emoji_scale": {
const { min, max } = scaleBounds(field);
return (
<ScaleButtons
value={value as number | undefined}
min={min}
max={max}
renderLabel={(n) => EMOJI_SCALE[n - 1] ?? String(n)}
disabled={disabled}
onChange={onChange}
/>
);
}

case "slider": {
const { min, max, step } = scaleBounds(field);
return (
<SliderInput
value={value as number | undefined}
min={min}
max={max}
step={step}
disabled={disabled}
onChange={onChange}
/>
);
}

case "single_choice":
return (
<RadioGroup
Expand Down
104 changes: 104 additions & 0 deletions apps/web/src/components/form/rating-fields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"use client";

/**
* Rating-family input widgets (stars, 0–10 / emoji scale buttons, slider).
* All emit a numeric value; bounds come from the shared `scaleBounds` helper so
* the renderer, validation, and formatter stay in sync.
*/

interface BaseProps {
value?: number;
onChange: (value: number | undefined) => 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 (
<div className="flex items-center gap-1" role="radiogroup">
{Array.from({ length: max }, (_, i) => i + 1).map((n) => {
const active = (value ?? 0) >= n;
return (
<button
key={n}
type="button"
role="radio"
aria-checked={value === n}
aria-label={`${n}`}
disabled={disabled}
onClick={() => onChange(value === n ? undefined : n)}
className={`text-2xl leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
active ? "text-primary" : "text-muted-foreground/40 hover:text-muted-foreground"
}`}
>
{active ? "★" : "☆"}
</button>
);
})}
</div>
);
}

/** 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 (
<div className="flex flex-wrap gap-1.5" role="radiogroup">
{values.map((n) => {
const active = value === n;
return (
<button
key={n}
type="button"
role="radio"
aria-checked={active}
aria-label={`${n}`}
disabled={disabled}
onClick={() => onChange(active ? undefined : n)}
className={`flex h-10 min-w-10 items-center justify-center rounded-md border px-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
active
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:border-primary/40 hover:text-foreground"
}`}
>
{renderLabel ? renderLabel(n) : n}
</button>
);
})}
</div>
);
}

/** 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 (
<div className="flex items-center gap-3">
<input
type="range"
min={min}
max={max}
step={step}
value={current}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
className="h-2 w-full cursor-pointer accent-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-foreground">{current}</span>
</div>
);
}
4 changes: 4 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down Expand Up @@ -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",
},
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/lib/builder-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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);
47 changes: 47 additions & 0 deletions packages/shared/src/form-spec.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
buildAnswerSchema,
formatAnswerValue,
formSpecSchema,
isLayoutField,
type FormField,
Expand Down Expand Up @@ -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");
});
});
Loading