diff --git a/apps/web/src/components/builder/field-editor.tsx b/apps/web/src/components/builder/field-editor.tsx index aed3c0f..50df77f 100644 --- a/apps/web/src/components/builder/field-editor.tsx +++ b/apps/web/src/components/builder/field-editor.tsx @@ -6,6 +6,7 @@ import { Card, Checkbox, Field, Input } from "@msk-forms/ui"; import { isLayoutType, needsOptions, + needsRows, needsSliderConfig, needsStarsConfig, } from "@/lib/builder-fields"; @@ -58,6 +59,18 @@ export function FieldEditor({ /** Parse a numeric config input, treating an empty string as "unset". */ const num = (s: string): number | undefined => (s === "" ? undefined : Number(s)); + function setRow(i: number, label: string) { + const rows = [...(field.rows ?? [])]; + rows[i] = { id: rows[i]?.id ?? crypto.randomUUID(), label }; + patch({ rows }); + } + function addRow() { + patch({ rows: [...(field.rows ?? []), { id: crypto.randomUUID(), label: "" }] }); + } + function removeRow(i: number) { + patch({ rows: (field.rows ?? []).filter((_, j) => j !== i) }); + } + return (
@@ -97,7 +110,7 @@ export function FieldEditor({ {needsOptions(field.type) && ( - +
{(field.options ?? []).map((opt, i) => (
@@ -122,6 +135,32 @@ export function FieldEditor({ )} + {needsRows(field.type) && ( + +
+ {(field.rows ?? []).map((row, i) => ( +
+ setRow(i, e.target.value)} + placeholder={`${t.rowPh} ${i + 1}`} + /> + removeRow(i)}> + ✕ + +
+ ))} + +
+
+ )} + {needsStarsConfig(field.type) && ( + | undefined; const YES_NO_OPTIONS = [ { value: "yes", label: "Yes" }, @@ -131,6 +139,16 @@ export function FieldInput({ ); } + case "matrix": + return ( + | undefined} + disabled={disabled} + onChange={onChange} + /> + ); + case "single_choice": return ( ; + +/** + * Matrix question: one radio per (row, column). Rows are the field's sub-questions, + * columns reuse the field's `options`. The answer is a `{ rowId: columnValue }` map. + */ +export function MatrixField({ + field, + value, + onChange, + disabled, +}: { + field: FormField; + value?: MatrixValue; + onChange: (value: MatrixValue) => void; + disabled?: boolean; +}) { + const rows = field.rows ?? []; + const cols = field.options ?? []; + const current = value ?? {}; + + function pick(rowId: string, colValue: string) { + onChange({ ...current, [rowId]: colValue }); + } + + return ( +
+ + + + + ))} + + + + {rows.map((row) => ( + + + {cols.map((col) => { + const name = `${field.id}-${row.id}`; + return ( + + ); + })} + + ))} + +
+ {cols.map((col) => ( + + {col.label} +
{row.label} + pick(row.id, col.value)} + className="h-4 w-4 accent-primary" + /> +
+
+ ); +} diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index d96981d..f59ed85 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -134,10 +134,11 @@ const en = { 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", + columns: "Columns", rows: "Rows", rowPh: "Row", addRow: "Add row", removeRow: "Remove row", 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", + rating_stars: "Star rating", nps: "NPS (0–10)", slider: "Slider", emoji_scale: "Emoji scale", matrix: "Matrix", date: "Date", file_upload: "File upload", image_upload: "Image upload", consent: "Consent", heading: "Heading", paragraph: "Paragraph", divider: "Divider", }, @@ -288,10 +289,11 @@ const de: Dictionary = { moveUp: "Nach oben", moveDown: "Nach unten", remove: "Entfernen", removeOption: "Option entfernen", labelPh: "Fragetext…", headingPh: "Abschnittsüberschrift…", stars: "Sterne", min: "Min", max: "Max", step: "Schritt", + columns: "Spalten", rows: "Zeilen", rowPh: "Zeile", addRow: "Zeile hinzufügen", removeRow: "Zeile entfernen", 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", + rating_stars: "Sterne-Bewertung", nps: "NPS (0–10)", slider: "Schieberegler", emoji_scale: "Emoji-Skala", matrix: "Matrix", 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 715826e..f21f78f 100644 --- a/apps/web/src/lib/builder-fields.ts +++ b/apps/web/src/lib/builder-fields.ts @@ -16,6 +16,7 @@ export const BUILDER_FIELDS: { type: FieldType; label: string }[] = [ { type: "nps", label: "NPS (0–10)" }, { type: "slider", label: "Slider" }, { type: "emoji_scale", label: "Emoji scale" }, + { type: "matrix", label: "Matrix" }, { type: "date", label: "Date" }, { type: "file_upload", label: "File upload" }, { type: "image_upload", label: "Image upload" }, @@ -25,7 +26,9 @@ export const BUILDER_FIELDS: { type: FieldType; label: string }[] = [ { type: "divider", label: "Divider" }, ]; -const CHOICE_TYPES: FieldType[] = ["single_choice", "dropdown", "multi_choice"]; +// `matrix` also needs an option editor — its options are the shared columns. +const CHOICE_TYPES: FieldType[] = ["single_choice", "dropdown", "multi_choice", "matrix"]; +const ROWS_CONFIG_TYPES: FieldType[] = ["matrix"]; // Distinct from shared LAYOUT_FIELD_TYPES: only the layout types the builder // currently offers (not every layout type the renderer can display). const BUILDER_LAYOUT_TYPES: FieldType[] = ["heading", "paragraph", "divider"]; @@ -42,3 +45,4 @@ export const needsOptions = (type: FieldType): boolean => CHOICE_TYPES.includes( 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); +export const needsRows = (type: FieldType): boolean => ROWS_CONFIG_TYPES.includes(type); diff --git a/packages/shared/src/form-spec.test.ts b/packages/shared/src/form-spec.test.ts index 21bb447..bb3e3a0 100644 --- a/packages/shared/src/form-spec.test.ts +++ b/packages/shared/src/form-spec.test.ts @@ -171,6 +171,32 @@ describe("buildAnswerSchema", () => { expect(s.safeParse({ s: 100 }).success).toBe(true); expect(s.safeParse({ s: 101 }).success).toBe(false); }); + + const matrixField = { + id: "m", + type: "matrix" as const, + options: [ + { value: "y", label: "Yes" }, + { value: "n", label: "No" }, + ], + rows: [ + { id: "r1", label: "Q1" }, + { id: "r2", label: "Q2" }, + ], + }; + + it("validates a required matrix against its rows and columns", () => { + const s = buildAnswerSchema(specWith({ ...matrixField, validation: { required: true } })); + expect(s.safeParse({ m: { r1: "y", r2: "n" } }).success).toBe(true); + expect(s.safeParse({ m: { r1: "y" } }).success).toBe(false); // r2 missing + expect(s.safeParse({ m: { r1: "x", r2: "n" } }).success).toBe(false); // bad column + }); + + it("allows a partial/empty matrix when optional", () => { + const s = buildAnswerSchema(specWith({ ...matrixField, validation: { required: false } })); + expect(s.safeParse({}).success).toBe(true); + expect(s.safeParse({ m: { r1: "y" } }).success).toBe(true); + }); }); describe("formatAnswerValue (rating family)", () => { @@ -183,4 +209,24 @@ describe("formatAnswerValue (rating family)", () => { expect(formatAnswerValue(stars, 4, L)).toBe("★ 4"); expect(formatAnswerValue(emoji, 5, L)).toContain("5/5"); }); + + it("renders a matrix answer as row: column pairs", () => { + const matrix: FormField = { + id: "m", + type: "matrix", + width: "full", + validation: { required: false }, + conditional: [], + options: [ + { value: "y", label: "Yes" }, + { value: "n", label: "No" }, + ], + rows: [ + { id: "r1", label: "Q1" }, + { id: "r2", label: "Q2" }, + ], + }; + expect(formatAnswerValue(matrix, { r1: "y", r2: "n" }, L)).toBe("Q1: Yes; Q2: No"); + expect(formatAnswerValue(matrix, {}, L)).toBe("—"); + }); }); diff --git a/packages/shared/src/form-spec.ts b/packages/shared/src/form-spec.ts index 3042b7d..f403667 100644 --- a/packages/shared/src/form-spec.ts +++ b/packages/shared/src/form-spec.ts @@ -99,6 +99,13 @@ export const fieldOptionSchema = z.object({ score: z.number().optional(), }); +/** A matrix row (sub-question). Columns reuse the field's `options`. */ +export const matrixRowSchema = z.object({ + id: z.string(), + label: z.string(), +}); +export type MatrixRow = z.infer; + export const formFieldSchema = z.object({ id: z.string(), type: fieldTypeSchema, @@ -108,6 +115,8 @@ export const formFieldSchema = z.object({ width: z.enum(["full", "half", "third"]).default("full"), defaultValue: z.unknown().optional(), options: z.array(fieldOptionSchema).optional(), + // Matrix sub-questions (rows); the shared column choices live in `options`. + rows: z.array(matrixRowSchema).optional(), validation: fieldValidationSchema.default({ required: false }), conditional: z.array(conditionRuleSchema).default([]), translations: z.record(z.string(), z.record(z.string(), z.string())).optional(), @@ -214,6 +223,15 @@ export function formatAnswerValue( const labelFor = (v: string) => field.options?.find((o) => o.value === v)?.label ?? v; + // Matrix answer: { rowId: columnValue } → "Row: Column; Row: Column". + if (field.type === "matrix" && typeof value === "object" && !Array.isArray(value)) { + const answers = value as Record; + const lines = (field.rows ?? []) + .filter((row) => answers[row.id] != null && answers[row.id] !== "") + .map((row) => `${row.label}: ${labelFor(String(answers[row.id]))}`); + return lines.length > 0 ? lines.join("; ") : labels.empty; + } + if (Array.isArray(value)) return value.map((v) => labelFor(String(v))).join(", "); // File-descriptor answer ({ key, name, size, mime }) — show the filename. if (typeof value === "object" && "name" in value) { @@ -278,6 +296,14 @@ function buildFieldSchema(field: FormField): z.ZodTypeAny { : arr; } else if (BOOLEAN_TYPES.includes(field.type)) { base = z.boolean(); + } else if (field.type === "matrix") { + // Answer is { rowId: columnValue }; each row picks one of the columns. + const col = optionValues.length > 0 ? z.enum(optionValues as [string, ...string[]]) : z.string(); + const shape: Record = {}; + for (const row of field.rows ?? []) { + shape[row.id] = v.required ? col : col.optional(); + } + base = z.object(shape); } else if ((FILE_FIELD_TYPES as readonly string[]).includes(field.type)) { base = fileAnswerSchema; } else if (SINGLE_CHOICE_TYPES.includes(field.type)) {