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
41 changes: 40 additions & 1 deletion apps/web/src/components/builder/field-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Card, Checkbox, Field, Input } from "@msk-forms/ui";
import {
isLayoutType,
needsOptions,
needsRows,
needsSliderConfig,
needsStarsConfig,
} from "@/lib/builder-fields";
Expand Down Expand Up @@ -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 (
<Card className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
Expand Down Expand Up @@ -97,7 +110,7 @@ export function FieldEditor({
</Field>

{needsOptions(field.type) && (
<Field label={t.options}>
<Field label={needsRows(field.type) ? t.columns : t.options}>
<div className="flex flex-col gap-2">
{(field.options ?? []).map((opt, i) => (
<div key={i} className="flex gap-2">
Expand All @@ -122,6 +135,32 @@ export function FieldEditor({
</Field>
)}

{needsRows(field.type) && (
<Field label={t.rows}>
<div className="flex flex-col gap-2">
{(field.rows ?? []).map((row, i) => (
<div key={row.id} className="flex gap-2">
<Input
value={row.label}
onChange={(e) => setRow(i, e.target.value)}
placeholder={`${t.rowPh} ${i + 1}`}
/>
<IconButton label={t.removeRow} onClick={() => removeRow(i)}>
</IconButton>
</div>
))}
<button
type="button"
onClick={addRow}
className="self-start text-sm font-medium text-primary hover:underline"
>
+ {t.addRow}
</button>
</div>
</Field>
)}

{needsStarsConfig(field.type) && (
<Field label={t.stars}>
<Input
Expand Down
20 changes: 19 additions & 1 deletion apps/web/src/components/form/field-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@ import {
} from "@msk-forms/ui";

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

export type FieldValue = string | number | boolean | string[] | FileAnswer | undefined;
export type FieldValue =
| string
| number
| boolean
| string[]
| FileAnswer
| Record<string, string>
| undefined;

const YES_NO_OPTIONS = [
{ value: "yes", label: "Yes" },
Expand Down Expand Up @@ -131,6 +139,16 @@ export function FieldInput({
);
}

case "matrix":
return (
<MatrixField
field={field}
value={value as Record<string, string> | undefined}
disabled={disabled}
onChange={onChange}
/>
);

case "single_choice":
return (
<RadioGroup
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/form/form-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ function isEmpty(value: FieldValue): boolean {
if (value === undefined || value === null || value === "") return true;
if (Array.isArray(value)) return value.length === 0;
if (value === false) return true; // unchecked consent/age_check
// Matrix answer ({ rowId: column }) with nothing picked yet.
if (typeof value === "object" && Object.keys(value).length === 0) return true;
return false;
}

Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/components/form/matrix-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"use client";

import type { FormField } from "@msk-forms/shared";

type MatrixValue = Record<string, string>;

/**
* 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 (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th className="p-2" />
{cols.map((col) => (
<th
key={col.value}
className="p-2 text-center text-xs font-medium text-muted-foreground"
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-t border-border">
<td className="p-2 text-foreground">{row.label}</td>
{cols.map((col) => {
const name = `${field.id}-${row.id}`;
return (
<td key={col.value} className="p-2 text-center">
<input
type="radio"
name={name}
aria-label={`${row.label}: ${col.label}`}
value={col.value}
checked={current[row.id] === col.value}
disabled={disabled}
onChange={() => pick(row.id, col.value)}
className="h-4 w-4 accent-primary"
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
6 changes: 4 additions & 2 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down Expand Up @@ -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",
},
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/lib/builder-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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"];
Expand All @@ -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);
46 changes: 46 additions & 0 deletions packages/shared/src/form-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand All @@ -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("—");
});
});
26 changes: 26 additions & 0 deletions packages/shared/src/form-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof matrixRowSchema>;

export const formFieldSchema = z.object({
id: z.string(),
type: fieldTypeSchema,
Expand All @@ -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(),
Expand Down Expand Up @@ -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<string, unknown>;
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) {
Expand Down Expand Up @@ -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<string, z.ZodTypeAny> = {};
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)) {
Expand Down