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
42 changes: 42 additions & 0 deletions apps/web/src/components/builder/field-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
needsRows,
needsSliderConfig,
needsStarsConfig,
needsYesNoScore,
} from "@/lib/builder-fields";
import type { Dictionary } from "@/i18n";

Expand Down Expand Up @@ -79,6 +80,24 @@ export function FieldEditor({
function removeOption(i: number) {
patch({ options: (field.options ?? []).filter((_, j) => j !== i) });
}
/**
* Set the score for the "yes" or "no" answer of a yes_no field. Scores live on
* two synthetic options (values "yes"/"no") so they flow through the shared
* scoring, seeded on first edit.
*/
function setYesNoScore(which: "yes" | "no", raw: string) {
const base =
field.options?.length === 2
? [...field.options]
: [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
];
const i = which === "yes" ? 0 : 1;
const n = raw === "" ? undefined : Number(raw);
base[i] = { ...base[i]!, score: n !== undefined && Number.isFinite(n) ? n : undefined };
patch({ options: base });
}
function setValidation(partial: Partial<FormField["validation"]>) {
patch({ validation: { ...field.validation, ...partial } });
}
Expand Down Expand Up @@ -197,6 +216,29 @@ export function FieldEditor({
</Field>
)}

{needsYesNoScore(field.type) && (
<Field label={t.points} hint={t.yesNoScoreHint}>
<div className="flex flex-col gap-2">
{(["yes", "no"] as const).map((which) => (
<div key={which} className="flex items-center gap-2">
<span className="flex-1 text-sm text-muted-foreground">
{which === "yes" ? t.yes : t.no}
</span>
<div className="w-24 shrink-0">
<Input
type="number"
value={field.options?.find((o) => o.value === which)?.score ?? ""}
onChange={(e) => setYesNoScore(which, e.target.value)}
placeholder={t.points}
title={t.points}
/>
</div>
</div>
))}
</div>
</Field>
)}

{needsRows(field.type) && (
<Field label={t.rows}>
<div className="flex flex-col gap-2">
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ const en = {
errSave: "Could not save the form.",
fieldLabel: "Label", fieldText: "Text", helpText: "Help text", helpHint: "Optional, shown below the label.",
options: "Options", optionPh: "Option", required: "Required", addOption: "Add option", points: "Points",
yes: "Yes", no: "No", yesNoScoreHint: "Optional points awarded for each answer (used for quiz scoring).",
moveUp: "Move up", moveDown: "Move down", remove: "Remove", removeOption: "Remove option",
labelPh: "Question label…", headingPh: "Section heading…",
consentText: "Consent text", consentHint: "Shown next to the checkbox.", consentPh: "I agree to …",
Expand Down Expand Up @@ -1035,6 +1036,7 @@ const de: Dictionary = {
errSave: "Formular konnte nicht gespeichert werden.",
fieldLabel: "Beschriftung", fieldText: "Text", helpText: "Hilfetext", helpHint: "Optional, unter der Beschriftung angezeigt.",
options: "Optionen", optionPh: "Option", required: "Pflichtfeld", addOption: "Option hinzufügen", points: "Punkte",
yes: "Ja", no: "Nein", yesNoScoreHint: "Optionale Punkte pro Antwort (für die Quiz-Bewertung).",
moveUp: "Nach oben", moveDown: "Nach unten", remove: "Entfernen", removeOption: "Option entfernen",
labelPh: "Fragetext…", headingPh: "Abschnittsüberschrift…",
consentText: "Consent-Text", consentHint: "Wird neben der Checkbox angezeigt.", consentPh: "Ich stimme … zu",
Expand Down Expand Up @@ -1582,6 +1584,7 @@ const hu: Dictionary = {
errSave: "Az űrlap mentése sikertelen.",
fieldLabel: "Címke", fieldText: "Szöveg", helpText: "Súgószöveg", helpHint: "Opcionális, a címke alatt jelenik meg.",
options: "Opciók", optionPh: "Opció", required: "Kötelező", addOption: "Opció hozzáadása", points: "Pont",
yes: "Igen", no: "Nem", yesNoScoreHint: "Opcionális pontszám válaszonként (kvíz-pontozáshoz).",
moveUp: "Mozgatás felfelé", moveDown: "Mozgatás lefelé", remove: "Eltávolítás", removeOption: "Opció eltávolítása",
labelPh: "Kérdés címkéje…", headingPh: "Szakasz címe…",
consentText: "Hozzájárulás szövege", consentHint: "A jelölőnégyzet mellett jelenik meg.", consentPh: "Elfogadom a …",
Expand Down Expand Up @@ -2129,6 +2132,7 @@ const fr: Dictionary = {
errSave: "Impossible d'enregistrer le formulaire.",
fieldLabel: "Libellé", fieldText: "Texte", helpText: "Texte d'aide", helpHint: "Optionnel, affiché sous le libellé.",
options: "Options", optionPh: "Option", required: "Obligatoire", addOption: "Ajouter une option", points: "Points",
yes: "Oui", no: "Non", yesNoScoreHint: "Points facultatifs pour chaque réponse (pour le score du quiz).",
moveUp: "Monter", moveDown: "Descendre", remove: "Supprimer", removeOption: "Supprimer l'option",
labelPh: "Libellé de la question…", headingPh: "Titre de section…",
consentText: "Texte de consentement", consentHint: "Affiché à côté de la case.", consentPh: "J'accepte …",
Expand Down Expand Up @@ -2677,6 +2681,7 @@ const es: Dictionary = {
errSave: "No se pudo guardar el formulario.",
fieldLabel: "Etiqueta", fieldText: "Texto", helpText: "Texto de ayuda", helpHint: "Opcional, se muestra debajo de la etiqueta.",
options: "Opciones", optionPh: "Opción", required: "Obligatorio", addOption: "Agregar opción", points: "Puntos",
yes: "Sí", no: "No", yesNoScoreHint: "Puntos opcionales por respuesta (para la puntuación del cuestionario).",
moveUp: "Mover arriba", moveDown: "Mover abajo", remove: "Eliminar", removeOption: "Eliminar opción",
labelPh: "Etiqueta de la pregunta…", headingPh: "Encabezado de sección…",
consentText: "Texto de consentimiento", consentHint: "Se muestra junto a la casilla.", consentPh: "Acepto …",
Expand Down Expand Up @@ -3225,6 +3230,7 @@ const pt: Dictionary = {
errSave: "Não foi possível salvar o formulário.",
fieldLabel: "Rótulo", fieldText: "Texto", helpText: "Texto de ajuda", helpHint: "Opcional, exibido abaixo do rótulo.",
options: "Opções", optionPh: "Opção", required: "Obrigatório", addOption: "Adicionar opção", points: "Pontos",
yes: "Sim", no: "Não", yesNoScoreHint: "Pontos opcionais por resposta (para a pontuação do quiz).",
moveUp: "Mover para cima", moveDown: "Mover para baixo", remove: "Remover", removeOption: "Remover opção",
labelPh: "Rótulo da pergunta…", headingPh: "Título da seção…",
consentText: "Texto de consentimento", consentHint: "Exibido ao lado da caixa.", consentPh: "Concordo com …",
Expand Down Expand Up @@ -3773,6 +3779,7 @@ const pl: Dictionary = {
errSave: "Nie udało się zapisać formularza.",
fieldLabel: "Etykieta", fieldText: "Tekst", helpText: "Tekst pomocy", helpHint: "Opcjonalnie, wyświetlany pod etykietą.",
options: "Opcje", optionPh: "Opcja", required: "Wymagane", addOption: "Dodaj opcję", points: "Punkty",
yes: "Tak", no: "Nie", yesNoScoreHint: "Opcjonalne punkty za każdą odpowiedź (do punktacji quizu).",
moveUp: "Przesuń w górę", moveDown: "Przesuń w dół", remove: "Usuń", removeOption: "Usuń opcję",
labelPh: "Etykieta pytania…", headingPh: "Nagłówek sekcji…",
consentText: "Tekst zgody", consentHint: "Wyświetlany obok pola wyboru.", consentPh: "Zgadzam się na …",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/builder-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const STARS_CONFIG_TYPES: FieldType[] = ["rating_stars"];
const SLIDER_CONFIG_TYPES: FieldType[] = ["slider"];

export const needsOptions = (type: FieldType): boolean => CHOICE_TYPES.includes(type);
/** Yes/No fields expose a points editor for the "yes" and "no" answers. */
export const needsYesNoScore = (type: FieldType): boolean => type === "yes_no";
export const isLayoutType = (type: FieldType): boolean => BUILDER_LAYOUT_TYPES.includes(type);
/** Calculated fields configure an arithmetic formula instead of an input. */
export const needsFormula = (type: FieldType): boolean => type === "calculated";
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/scoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ describe("scoreSubmission", () => {
expect(scoreSubmission(s, {})).toBe(0);
});

it("scores a yes_no field from its synthetic yes/no options", () => {
const s = spec([
{ id: "q", type: "yes_no", width: "full", validation: { required: false }, conditional: [], options: [{ value: "yes", label: "Yes", score: 10 }, { value: "no", label: "No", score: 0 }] },
]);
expect(scoreSubmission(s, { q: true })).toBe(10);
expect(scoreSubmission(s, { q: false })).toBe(0);
expect(scoreSubmission(s, {})).toBe(0); // unanswered
});

it("sums every selected option for multi-choice and across fields", () => {
const s = spec([
{ id: "q1", type: "multi_choice", width: "full", validation: { required: false }, conditional: [], options: [{ value: "x", label: "X", score: 3 }, { value: "y", label: "Y", score: 5 }, { value: "z", label: "Z", score: 0 }] },
Expand Down
20 changes: 15 additions & 5 deletions packages/shared/src/scoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ type Answers = Record<string, unknown>;
* `score`; a submission's score is the sum of the scores of the options the
* applicant selected, across every field. Returns null when no option in the
* form has a score (scoring isn't used) so non-quiz forms stay scoreless.
*
* `yes_no` fields join the same system: their score is stored on two synthetic
* options with the values `"yes"`/`"no"`, matched against the boolean answer.
*/
export function scoreSubmission(spec: FormSpec, answers: Answers): number | null {
let scored = false;
Expand All @@ -20,11 +23,18 @@ export function scoreSubmission(spec: FormSpec, answers: Answers): number | null
scored = true;

const answer = answers[field.id];
const selected = Array.isArray(answer)
? answer.map(String)
: answer === undefined || answer === null
? []
: [String(answer)];
const selected =
field.type === "yes_no"
? answer === true
? ["yes"]
: answer === false
? ["no"]
: []
: Array.isArray(answer)
? answer.map(String)
: answer === undefined || answer === null
? []
: [String(answer)];

for (const option of options) {
if (typeof option.score === "number" && selected.includes(option.value)) {
Expand Down