feat(exercise): add open-ended thumbs up/down question type (#780)

* feat(ui): add THUMBS question type renderer

- Add seven new renderer files under renderers/thumbs/:
  thumbs-correct.ts, take.svelte, edit.svelte, preview.svelte,
  thumbs-review-toggle.svelte, review.svelte, submission.svelte
- Register THUMBS in renderer-contract.ts with edit/take/preview/submission/review
- Export ThumbsEdit, ThumbsTake, ThumbsPreview, ThumbsSubmission,
  ThumbsReview from exercise-question/index.ts
- Add THUMBS_FIXTURE, four thumbs label keys, and Thumbs story
  in storybook exercise-question files
- Add THUMBS case to question-type-modes.svelte submission builder

Co-authored-by: Rotimi Best <[email protected]>

* feat(question-types): add THUMBS contracts, scoring, and seed

Introduce the THUMBS question type (id 14) with boolean answer data,
codec, auto-grading, Zod validation, DB seed/schema docs, and binary
normalize support shared with TRUE_FALSE. Dashboard wiring is deferred.

Co-authored-by: Rotimi Best <[email protected]>

* feat(dashboard): wire THUMBS question type into exercise UI

Connect THUMBS option sync in edit/view modes, labels, picker icon,
and locale strings so the existing contracts surface in the dashboard.

Co-authored-by: Rotimi Best <[email protected]>

* fix(ui): polish thumbs question UI and move premium upgrade icon

Widen thumbs Yes/No toggles, inherit selected icon color via custom class,
replace badge-based review correctness with solid fill states and bare
Check/X icons, add a success pill in preview, and place the premium
upgrade icon at the end of the question type select row.

Co-authored-by: Rotimi Best <[email protected]>

* fix(ui): align thumbs review colors with prototypes

Tune correct/incorrect selected states so review buttons use dark
emerald/red content colors on solid fills, matching the design comps.

Co-authored-by: Rotimi Best <[email protected]>

* fix(ui): address remaining PR review comments

Use a shared timestamp local in thumbs edit fallback options.
Move border-beam animation-delay after the animation shorthand.
Alias --spacing to --ui-spacing for tw-animate compatibility.

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): sync question_type sequence before seeding new types

Avoid duplicate key errors when question_type_id_seq lags behind rows
inserted via restore or explicit ids (e.g. THUMBS on restored databases).

Co-authored-by: Rotimi Best <[email protected]>

* fix(exercise): resolve thumbs review label ICU crash

Remove {value} ICU placeholder from thumbs.review.correct_answer_label
since labels are resolved at map-build time without context. Append the
dynamic yes/no label in thumbs-review-toggle instead.

Also bind question points through the questionnaire store index to avoid
the Svelte non-reactive property binding warning in edit-mode.

Co-authored-by: Rotimi Best <[email protected]>

* docs(agent): add THUMBS guidance and sync question type docs

Add shared agent question-type guide helpers in @cio/question-types and
use them across the teacher prompt, add/update_questions tool schemas,
legacy ai-assistant schemas, and MCP exercise tools.

- Document when to use THUMBS (Yes/No sentiment) vs TRUE_FALSE (factual)
- Fix stale MCP question type ids and add THUMBS=14
- Exclude disabled MATCHING/HOTSPOT from enabled type lists

Co-authored-by: Rotimi Best <[email protected]>

* fix(thumbs): clarify auto-grading and add editable option labels

THUMBS is auto-graded like TRUE_FALSE; the difference is thumbs UI with
customizable Yes/No-style labels and the teacher selecting the correct
option. Render take/preview/review from question.options labels and add
label inputs in the thumbs edit renderer. Update agent and MCP docs to
stop implying THUMBS is manual or non-gradable.

Co-authored-by: Rotimi Best <[email protected]>

* chore(mcp): bump version to 0.0.10

Co-authored-by: Rotimi Best <[email protected]>

* fix(question-types): make THUMBS open-ended with no correct answer

THUMBS is a sentiment/poll question type, not an auto-graded binary like
TRUE_FALSE. Remove correct-answer UI, scoring, and validation; update agent
and MCP docs accordingly.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): stop treating THUMBS as auto-graded in editor validation

Normalize thumbs questions on load/save, show an Open-ended badge instead
of Auto, and only enforce points/correct-answer rules on auto-gradable types.

Co-authored-by: Rotimi Best <[email protected]>

* revert(dashboard): remove unsolicited open-ended grading badge

THUMBS uses the existing Manual badge like other non-auto-gradable types.

Co-authored-by: Rotimi Best <[email protected]>

* fix(exercise): show question title validation errors in editor

Display mapped title errors beneath the question title input instead of
only highlighting the question card border. Pass the exercise entity to
mapZodErrorsToTranslations so messages resolve to validations.exercise.*
keys, and use a translated snackbar key for save validation failures.

Co-authored-by: Rotimi Best <[email protected]>

* fix(mcp): build workspace deps before MCP publish bundle

The MCP esbuild step imports @cio/question-types and @cio/utils dist
outputs. CI failed on a clean checkout because mcp:build only built the
MCP package itself. Build those dependencies first so 0.0.10 can publish.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-31 14:59:38 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent 0abdfe2102
commit 20e34e29dd
69 changed files with 1022 additions and 166 deletions
@@ -200,6 +200,14 @@
];
}
function makeThumbsOptions() {
const ts = Date.now();
return [
{ id: `${ts}-yes-form`, label: 'Yes', value: 'true', isCorrect: false },
{ id: `${ts}-no-form`, label: 'No', value: 'false', isCorrect: false }
];
}
function onQuestionTypeChange(questionId: string | number, value: string) {
if (!value) return;
@@ -218,6 +226,9 @@
const correctValue = (current.settings as { correctValue?: boolean })?.correctValue ?? true;
nextSettings = { ...nextSettings, correctValue };
nextOptions = makeTrueFalseOptions(correctValue);
} else if (nextQuestionTypeKey === QUESTION_TYPE_KEY.THUMBS) {
nextSettings = { ...nextSettings };
nextOptions = makeThumbsOptions();
} else if (questionTypeSupportsOptions(nextQuestionTypeKey)) {
const hasActiveOptions = nextOptions.some((option) => !option.deletedAt);
if (!hasActiveOptions) {
@@ -276,7 +287,8 @@
let nextSettings = nextQuestion.settings ?? {};
let nextOptions = mappedOptions;
if (getQuestionTypeKey(current) === QUESTION_TYPE_KEY.TRUE_FALSE) {
const currentQuestionTypeKey = getQuestionTypeKey(current);
if (currentQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
const correctValue = resolveTrueFalseCorrectValue(nextSettings, nextOptions);
nextSettings = { ...nextSettings, correctValue };
nextOptions = syncTrueFalseOptions(nextOptions, correctValue);
@@ -411,15 +423,16 @@
{/snippet}
{#snippet questionEditor(question, index)}
{@const questionStoreIndex = $questionnaire.questions.findIndex((item) => item.id === question.id)}
<QuestionContainer
elementId={getQuestionElementId(question.id)}
key={String(question.id ?? `new-${index}`)}
onClose={onInitDeleteClicked(question.id)}
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
bind:points={question.points}
bind:points={$questionnaire.questions[questionStoreIndex].points}
hasError={!!errors[question.id]}
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
pointsHint={requiresPositivePointsForAutoGrade
pointsHint={isQuestionAutoGradable(question) && requiresPositivePointsForAutoGrade
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
: null}
onPointsChange={() => {
@@ -444,6 +457,7 @@
<div class="mt-2 flex flex-col">
<ExerciseQuestion.QuestionRenderer
showContainer={false}
titleError={getQuestionErrorMsg(errors, question, 'title')}
contract={{
mode: 'edit',
question: toExerciseQuestionModel(question),
@@ -1,6 +1,11 @@
import { getQuestionAnswerKey, getQuestionTypeKey, questionTypeSupportsOptions } from './question-type-utils';
import { QUESTION_TYPE_KEY, resolveTrueFalseCorrectValue, syncTrueFalseOptions } from '@cio/question-types';
import {
QUESTION_TYPE_KEY,
normalizeThumbsQuestion,
resolveTrueFalseCorrectValue,
syncTrueFalseOptions
} from '@cio/question-types';
import type { Question } from '$features/course/types';
import type { QuestionnaireMetaData } from './store';
import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
@@ -174,6 +179,25 @@ export function transformQuestionsToApiFormat(
};
}
if (questionTypeKey === QUESTION_TYPE_KEY.THUMBS) {
const normalized = normalizeThumbsQuestion({
settings,
options: formattedOptions
});
return {
id: normalizeId(q.id),
question: q.title,
points: q.points || 0,
questionTypeId: q.questionTypeId,
exerciseSectionId: q.exerciseSectionId ?? null,
order: typeof (q as { order?: number }).order === 'number' ? (q as { order: number }).order : index + 1,
settings: normalized.settings,
options: normalized.options,
...(shouldIncludeDeleted && q.deletedAt ? { deletedAt: q.deletedAt } : {})
};
}
return {
id: normalizeId(q.id),
question: q.title,
@@ -125,6 +125,17 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'true_false.preview.correct_value_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.true_false.preview.correct_value_label'
),
'thumbs.yes_label': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.thumbs.yes_label'),
'thumbs.no_label': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.thumbs.no_label'),
'thumbs.edit.positive_option_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.thumbs.edit.positive_option_label'
),
'thumbs.edit.negative_option_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.thumbs.edit.negative_option_label'
),
'thumbs.preview.helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.thumbs.preview.helper'
),
'short_answer.edit.instructions_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.edit.instructions_placeholder'
),
@@ -39,7 +39,7 @@
{#snippet upgradeButton({ typeEntry })}
<button
type="button"
class="ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-2 ui:pr-8 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground ui:relative"
class="ui:relative ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:justify-between ui:gap-2 ui:rounded-sm ui:px-2 ui:py-1.5 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground"
onclick={(clickEvent) => {
clickEvent.preventDefault();
clickEvent.stopPropagation();
@@ -47,8 +47,8 @@
}}
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
>
<PremiumIcon size={16} class="ui:text-blue-700 ui:dark:text-white ui:shrink-0" />
<span>{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
<PremiumIcon size={16} class="ui:ml-auto ui:shrink-0 ui:text-blue-700 ui:dark:text-white" />
</button>
{/snippet}
@@ -24,7 +24,8 @@ export function getExerciseEditorQuestionTypeLabel(
const OPTION_QUESTION_TYPE_KEYS = new Set<ExerciseQuestionTypeKey>([
QUESTION_TYPE_KEY.RADIO,
QUESTION_TYPE_KEY.CHECKBOX,
QUESTION_TYPE_KEY.TRUE_FALSE
QUESTION_TYPE_KEY.TRUE_FALSE,
QUESTION_TYPE_KEY.THUMBS
]);
export function getQuestionTypeId(question: Partial<Question>): number {
@@ -405,6 +405,7 @@
switch (answerValue.type) {
case 'RADIO':
case 'TRUE_FALSE':
case 'THUMBS':
case 'NUMERIC':
case 'STAR':
return true;
@@ -62,7 +62,7 @@
import * as Alert from '@cio/ui/base/alert';
import * as RadioGroup from '@cio/ui/base/radio-group';
import InfoIcon from '@lucide/svelte/icons/info';
import { isAutoGradableQuestionType } from '@cio/question-types';
import { isAutoGradableQuestionType, isAutoGradableQuestionTypeId } from '@cio/question-types';
import * as Dialog from '@cio/ui/base/dialog';
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
import {
@@ -368,18 +368,23 @@
});
if (!zodResult.success || Object.keys(sectionValidationErrors).length > 0) {
const zodErrors = !zodResult.success ? mapZodErrorsToTranslations(zodResult.error) : {};
const zodErrors = !zodResult.success ? mapZodErrorsToTranslations(zodResult.error, 'exercise') : {};
const questionErrors = mapZodErrorsToQuestionErrors(zodErrors, $questionnaire.questions);
questionnaireValidation.set({
...questionErrors,
...sectionValidationErrors
});
snackbar.error('Please fix all validation errors before saving');
snackbar.error('snackbar.exercise.validation_errors');
return;
}
if (requiresPositivePointsForAutoGrade) {
const zeroPointQuestions = ($questionnaire.questions ?? []).filter((q) => !q.deletedAt && Number(q.points) === 0);
const zeroPointQuestions = ($questionnaire.questions ?? []).filter(
(question) =>
!question.deletedAt &&
Number(question.points) === 0 &&
isAutoGradableQuestionTypeId(getQuestionTypeId(question))
);
if (zeroPointQuestions.length > 0) {
const pointErrors: Record<string, { option?: string; title?: string; points?: string }> = {};
@@ -430,7 +435,7 @@
// Check if there are validation errors from the API
if (Object.keys(exerciseApi.errors || {}).length > 0) {
console.log('Validation errors from API:', exerciseApi.errors);
snackbar.error('Please fix all validation errors before saving');
snackbar.error('snackbar.exercise.validation_errors');
isSaving = false;
return;
}
@@ -1,10 +1,15 @@
import type { ExerciseSectionAfterBehavior, ExerciseSectionColorTheme } from '@cio/question-types';
import { QUESTION_TYPE_KEY, normalizeThumbsQuestion } from '@cio/question-types';
import {
clearQuestionnaireValidation,
questionnaire,
questionnaireMetaData
} from '$features/course/components/exercise/store';
import { getQuestionTypeId, getQuestionTypeOptionById } from '$features/course/components/exercise/question-type-utils';
import {
getQuestionTypeId,
getQuestionTypeKey,
getQuestionTypeOptionById
} from '$features/course/components/exercise/question-type-utils';
import type { Exercise } from '$features/course/utils/types';
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
@@ -34,13 +39,27 @@ export function hydrateExercisePageData(exercise: Exercise, exerciseId: string)
if (exerciseQuestions.length > 0) {
const mappedQuestions: Question[] = exerciseQuestions.map((question) => {
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
return {
const baseQuestion = {
...question,
exerciseSectionId: question.exerciseSectionId ?? null,
questionTypeId: questionType.id,
questionType
};
if (getQuestionTypeKey(baseQuestion) !== QUESTION_TYPE_KEY.THUMBS) {
return baseQuestion;
}
const normalized = normalizeThumbsQuestion({
settings: (baseQuestion as Question & { settings?: Record<string, unknown> }).settings,
options: baseQuestion.options
});
return {
...baseQuestion,
settings: normalized.settings,
options: normalized.options
};
});
if (sections.length > 0) {
@@ -7,6 +7,7 @@ const QUESTION_TYPE_LABEL_KEYS: Partial<Record<QuestionTypeKey, string>> = {
[QUESTION_TYPE_KEY.CHECKBOX]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.multiple',
[QUESTION_TYPE_KEY.TEXTAREA]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.paragraph',
[QUESTION_TYPE_KEY.TRUE_FALSE]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.true_false',
[QUESTION_TYPE_KEY.THUMBS]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.thumbs',
[QUESTION_TYPE_KEY.SHORT_ANSWER]:
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.short_answer',
[QUESTION_TYPE_KEY.NUMERIC]: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_types.numeric',
@@ -321,6 +321,7 @@
"creating_new": "Oprettelse af nye oversættelser mislykkedes"
},
"exercise": {
"validation_errors": "Ret alle valideringsfejl, før du gemmer.",
"points_required_auto_grade": "Angiv en positiv pointværdi for hvert spørgsmål, før du gemmer.",
"success": "Gemt",
"submission_updated": "Indsendelse opdateret til",
@@ -1517,6 +1518,7 @@
"multiple": "Flere svar",
"paragraph": "Afsnit",
"true_false": "Sandt/falsk",
"thumbs": "Tommel op/ned",
"short_answer": "Kort svar",
"numeric": "Numerisk svar",
"fill_blank": "Udfyld det tomme felt",
@@ -1705,6 +1707,17 @@
"correct_answer_label": "Korrekt svar"
}
},
"thumbs": {
"yes_label": "Ja",
"no_label": "Nej",
"edit": {
"positive_option_label": "Tommel op-etiket",
"negative_option_label": "Tommel ned-etiket"
},
"preview": {
"helper": "Åben — elever vælger tommel op eller ned."
}
},
"checkbox": {
"edit": {
"helper": "Marker en eller flere muligheder som korrekte.",
@@ -321,6 +321,7 @@
"creating_new": "Das Erstellen neuer Übersetzungen ist fehlgeschlagen"
},
"exercise": {
"validation_errors": "Bitte beheben Sie alle Validierungsfehler, bevor Sie speichern.",
"points_required_auto_grade": "Legen Sie für jede Frage einen positiven Punktwert fest, bevor Sie speichern.",
"success": "Erfolgreich gespeichert",
"submission_updated": "Einreichung aktualisiert auf",
@@ -1517,6 +1518,7 @@
"multiple": "Mehrere Antworten",
"paragraph": "Absatz",
"true_false": "Richtig/Falsch",
"thumbs": "Daumen hoch/runter",
"short_answer": "Kurze Antwort",
"numeric": "Numerische Antwort",
"fill_blank": "Füllen Sie die Lücke aus",
@@ -1705,6 +1707,17 @@
"correct_answer_label": "Richtige Antwort"
}
},
"thumbs": {
"yes_label": "Ja",
"no_label": "Nein",
"edit": {
"positive_option_label": "Daumen-hoch-Label",
"negative_option_label": "Daumen-runter-Label"
},
"preview": {
"helper": "Offen — Lernende wählen Daumen hoch oder runter."
}
},
"checkbox": {
"edit": {
"helper": "Markieren Sie eine oder mehrere Optionen als richtig.",
@@ -2627,6 +2627,7 @@
"multiple": "Multiple answers",
"paragraph": "Paragraph",
"true_false": "True/False",
"thumbs": "Thumbs up/down",
"short_answer": "Short answer",
"numeric": "Numeric answer",
"fill_blank": "Fill in the blank",
@@ -2772,6 +2773,17 @@
"correct_value_label": "Correct value"
}
},
"thumbs": {
"yes_label": "Yes",
"no_label": "No",
"edit": {
"positive_option_label": "Thumbs up label",
"negative_option_label": "Thumbs down label"
},
"preview": {
"helper": "Open-ended — students choose thumbs up or down."
}
},
"short_answer": {
"edit": {
"instructions_placeholder": "Optional instructions"
@@ -4181,6 +4193,7 @@
}
},
"exercise": {
"validation_errors": "Please fix all validation errors before saving.",
"points_required_auto_grade": "Set a positive point value for every question before saving.",
"passed_requires_points": "Passed completion needs at least one point available.",
"success": "Saved Successfully",
@@ -321,6 +321,7 @@
"creating_new": "Error al crear nuevas traducciones"
},
"exercise": {
"validation_errors": "Corrija todos los errores de validación antes de guardar.",
"points_required_auto_grade": "Asigne un valor de puntos positivo a cada pregunta antes de guardar.",
"success": "Guardado exitosamente",
"submission_updated": "Envío actualizado a",
@@ -1517,6 +1518,7 @@
"multiple": "Múltiples respuestas",
"paragraph": "Párrafo",
"true_false": "Verdadero/Falso",
"thumbs": "Pulgares arriba/abajo",
"short_answer": "respuesta corta",
"numeric": "respuesta numérica",
"fill_blank": "Completa el espacio en blanco",
@@ -1705,6 +1707,17 @@
"correct_answer_label": "respuesta correcta"
}
},
"thumbs": {
"yes_label": "Sí",
"no_label": "No",
"edit": {
"positive_option_label": "Etiqueta de pulgar arriba",
"negative_option_label": "Etiqueta de pulgar abajo"
},
"preview": {
"helper": "Abierta — los estudiantes eligen pulgar arriba o abajo."
}
},
"checkbox": {
"edit": {
"helper": "Marque una o más opciones como correctas.",
@@ -321,6 +321,7 @@
"creating_new": "La création de nouvelles traductions a échoué"
},
"exercise": {
"validation_errors": "Veuillez corriger toutes les erreurs de validation avant d'enregistrer.",
"points_required_auto_grade": "Définissez un nombre de points positif pour chaque question avant d'enregistrer.",
"success": "Enregistré avec succès",
"submission_updated": "Soumission mise à jour pour",
@@ -1517,6 +1518,7 @@
"multiple": "Plusieurs réponses",
"paragraph": "Paragraphe",
"true_false": "Vrai/Faux",
"thumbs": "Pouce vers le haut/bas",
"short_answer": "Réponse courte",
"numeric": "Réponse numérique",
"fill_blank": "Remplissez le vide",
@@ -1705,6 +1707,17 @@
"correct_answer_label": "Bonne réponse"
}
},
"thumbs": {
"yes_label": "Oui",
"no_label": "Non",
"edit": {
"positive_option_label": "Libellé pouce vers le haut",
"negative_option_label": "Libellé pouce vers le bas"
},
"preview": {
"helper": "Ouverte — les apprenants choisissent pouce vers le haut ou vers le bas."
}
},
"checkbox": {
"edit": {
"helper": "Marquez une ou plusieurs options comme correctes.",
@@ -321,6 +321,7 @@
"creating_new": "नए अनुवाद बनाना विफल रहा"
},
"exercise": {
"validation_errors": "सहेजने से पहले सभी सत्यापन त्रुटियों को ठीक करें।",
"points_required_auto_grade": "सहेजने से पहले प्रत्येक प्रश्न के लिए धनात्मक अंक निर्धारित करें।",
"success": "सफलतापूर्वक सहेजा गया",
"submission_updated": "सबमिशन को अद्यतन किया गया",
@@ -1517,6 +1518,7 @@
"multiple": "एकाधिक उत्तर",
"paragraph": "अनुच्छेद",
"true_false": "सत्य/असत्य",
"thumbs": "अंगूठा ऊपर/नीचे",
"short_answer": "संक्षिप्त उत्तर",
"numeric": "संख्यात्मक उत्तर",
"fill_blank": "रिक्त स्थान भरें",
@@ -1705,6 +1707,17 @@
"correct_answer_label": "सही उत्तर"
}
},
"thumbs": {
"yes_label": "हाँ",
"no_label": "नहीं",
"edit": {
"positive_option_label": "अंगूठा ऊपर लेबल",
"negative_option_label": "अंगूठा नीचे लेबल"
},
"preview": {
"helper": "खुला प्रश्न — छात्र अंगूठा ऊपर या नीचे चुनते हैं।"
}
},
"checkbox": {
"edit": {
"helper": "एक या अधिक विकल्पों को सही के रूप में चिह्नित करें.",
@@ -321,6 +321,7 @@
"creating_new": "Tworzenie nowych tłumaczeń nie powiodło się"
},
"exercise": {
"validation_errors": "Popraw wszystkie błędy walidacji przed zapisaniem.",
"points_required_auto_grade": "Ustaw dodatnią liczbę punktów dla każdego pytania przed zapisaniem.",
"success": "Zapisano pomyślnie",
"submission_updated": "Zgłoszenie zaktualizowane do",
@@ -1517,6 +1518,7 @@
"multiple": "Wiele odpowiedzi",
"paragraph": "Akapit",
"true_false": "Prawda/Fałsz",
"thumbs": "Kciuk w górę/w dół",
"short_answer": "Krótka odpowiedź",
"numeric": "Odpowiedź numeryczna",
"fill_blank": "Wypełnij puste miejsce",
@@ -1705,6 +1707,17 @@
"correct_answer_label": "Prawidłowa odpowiedź"
}
},
"thumbs": {
"yes_label": "Tak",
"no_label": "Nie",
"edit": {
"positive_option_label": "Etykieta kciuka w górę",
"negative_option_label": "Etykieta kciuka w dół"
},
"preview": {
"helper": "Otwarte — uczniowie wybierają kciuk w górę lub w dół."
}
},
"checkbox": {
"edit": {
"helper": "Zaznacz jedną lub więcej opcji jako poprawną.",
@@ -321,6 +321,7 @@
"creating_new": "Falha ao criar novas traduções"
},
"exercise": {
"validation_errors": "Corrija todos os erros de validação antes de salvar.",
"points_required_auto_grade": "Defina um valor de pontos positivo para cada pergunta antes de salvar.",
"success": "Salvo com sucesso",
"submission_updated": "Envio atualizado para",
@@ -1517,6 +1518,7 @@
"multiple": "Múltiplas respostas",
"paragraph": "Parágrafo",
"true_false": "Verdadeiro/Falso",
"thumbs": "Polegar para cima/baixo",
"short_answer": "Resposta curta",
"numeric": "Resposta numérica",
"fill_blank": "Preencha o espaço em branco",
@@ -1705,6 +1707,17 @@
"correct_answer_label": "Resposta correta"
}
},
"thumbs": {
"yes_label": "Sim",
"no_label": "Não",
"edit": {
"positive_option_label": "Rótulo de polegar para cima",
"negative_option_label": "Rótulo de polegar para baixo"
},
"preview": {
"helper": "Aberta — os alunos escolhem polegar para cima ou para baixo."
}
},
"checkbox": {
"edit": {
"helper": "Marque uma ou mais opções como corretas.",
@@ -321,6 +321,7 @@
"creating_new": "Не удалось создать новые переводы."
},
"exercise": {
"validation_errors": "Исправьте все ошибки проверки перед сохранением.",
"points_required_auto_grade": "Установите положительное количество баллов для каждого вопроса перед сохранением.",
"success": "Сохранено успешно",
"submission_updated": "Представление обновлено до",
@@ -1517,6 +1518,7 @@
"multiple": "Несколько ответов",
"paragraph": "Параграф",
"true_false": "Верно/Неверно",
"thumbs": "Большой палец вверх/вниз",
"short_answer": "Короткий ответ",
"numeric": "Числовой ответ",
"fill_blank": "Заполните пробел",
@@ -1705,6 +1707,17 @@
"correct_answer_label": "Правильный ответ"
}
},
"thumbs": {
"yes_label": "Да",
"no_label": "Нет",
"edit": {
"positive_option_label": "Подпись «палец вверх»",
"negative_option_label": "Подпись «палец вниз»"
},
"preview": {
"helper": "Открытый вопрос — студенты выбирают палец вверх или вниз."
}
},
"checkbox": {
"edit": {
"helper": "Отметьте один или несколько вариантов как правильные.",
@@ -321,6 +321,7 @@
"creating_new": "Tạo bản dịch mới không thành công"
},
"exercise": {
"validation_errors": "Vui lòng sửa tất cả lỗi xác thực trước khi lưu.",
"points_required_auto_grade": "Đặt điểm dương cho mỗi câu hỏi trước khi lưu.",
"success": "Đã lưu thành công",
"submission_updated": "Đã cập nhật bài nộp vào",
@@ -1517,6 +1518,7 @@
"multiple": "Nhiều câu trả lời",
"paragraph": "Đoạn",
"true_false": "Đúng/Sai",
"thumbs": "Thích/Không thích",
"short_answer": "Câu trả lời ngắn",
"numeric": "Câu trả lời bằng số",
"fill_blank": "Điền vào chỗ trống",
@@ -1705,6 +1707,17 @@
"correct_answer_label": "Câu trả lời đúng"
}
},
"thumbs": {
"yes_label": "Có",
"no_label": "Không",
"edit": {
"positive_option_label": "Nhãn thích",
"negative_option_label": "Nhãn không thích"
},
"preview": {
"helper": "Mở — học viên chọn thích hoặc không thích."
}
},
"checkbox": {
"edit": {
"helper": "Đánh dấu một hoặc nhiều lựa chọn là đúng.",
+9 -5
View File
@@ -444,15 +444,19 @@ Supported `questionTypeId` values for exercise payloads:
- `1` `RADIO` - Single answer
- `2` `CHECKBOX` - Multiple answers
- `3` `TEXTAREA` - Paragraph
- `4` `TRUE_FALSE` - True/False
- `4` `TRUE_FALSE` - True/False (factual statements; options: True, False; auto-graded)
- `5` `SHORT_ANSWER` - Short answer
- `6` `NUMERIC` - Numeric answer
- `7` `FILL_BLANK` - Fill in the blank
- `8` `FILE_UPLOAD` - File upload
- `9` `MATCHING` - Matching
- `10` `ORDERING` - Ordering
- `11` `HOTSPOT` - Hotspot
- `12` `LINK` - Links
- `9` `ORDERING` - Ordering
- `10` `LINK` - Links
- `11` `WORD_BANK` - Word bank
- `12` `STAR` - Star rating
- `13` `VIDEO_RECORDING` - Video recording
- `14` `THUMBS` - Thumbs up/down (open-ended poll with customizable Yes/No labels; no correct answer)
`TRUE_FALSE` is auto-graded with `settings.correctValue` (boolean) and fixed True/False labels for factual statements. `THUMBS` is open-ended (not auto-graded): two customizable Yes/No-style options with thumbs icons for sentiment or poll-style questions — do not set `settings.correctValue`. `MATCHING` and `HOTSPOT` are disabled and must not be used.
## Operational Notes
+1 -1
View File
@@ -26,7 +26,7 @@
"website:dev": "pnpm --filter=@cio/website run dev",
"embeds:dev": "pnpm --filter=@cio/embeds --filter=@cio/ui run dev",
"storybook:dev": "pnpm --filter=@cio/storybook --filter=@cio/ui --filter=@cio/question-types run dev",
"mcp:build": "pnpm --filter=@classroomio/mcp run build",
"mcp:build": "pnpm --filter @cio/question-types build && pnpm --filter @cio/utils build && pnpm --filter @classroomio/mcp build",
"db:push": "pnpm --filter @cio/db db push"
},
"license": "MIT",
+5 -2
View File
@@ -59,7 +59,8 @@ Every question you pass to \`create_exercise\` or \`add_questions\` MUST include
**Pick the type that matches the cognitive skill being tested, not the type that's easiest to write:**
- **TRUE_FALSE** — testing a single misconception. Cheap and high-signal when the statement targets a *common* wrong belief, not a trivia detail.
- **TRUE_FALSE** — testing a single factual misconception. Cheap and high-signal when the statement targets a *common* wrong belief, not a trivia detail. Options must be labeled **True** and **False**. Auto-graded against the teacher's chosen correct answer.
- **THUMBS** — open-ended sentiment/poll with **thumbs up/down icons** and **customizable option labels** (defaults: Yes / No). There is **no correct answer** and it is **not auto-graded**. Use for recommendations, agreement, "would you…?", or other poll-style questions. Do **not** use THUMBS for factual true/false statements — use TRUE_FALSE instead.
- **SHORT_ANSWER** — recalling a specific term, command, value, or short phrase. Use when there's a small set of right answers you can list.
- **NUMERIC** — anything quantitative: math, calculations, counts, sizes, rates. Default for any "how many / what value / calculate X" question.
- **FILL_BLANK** — testing syntax, sequence completion, or code/sentence patterns where position matters ("\`SELECT \_\_ FROM users\`").
@@ -234,6 +235,7 @@ To change an existing question (its text, points, order, in-exercise section, co
- RADIO, CHECKBOX: on \`options[].isCorrect\`. Include an option's \`id\` to edit it; omit \`id\` to add a new option. The system automatically balances which position holds the correct answer across an exercise, so you don't need to vary it yourself — but DO keep the correct option from being an obvious tell: don't always make it the longest or most detailed choice, and avoid leaning on "all of the above"-style options.
- TRUE_FALSE: on \`settings.correctValue\` as a boolean. Use \`true\` when True is correct and \`false\` when False is correct. Keep exactly two options labeled True and False; their \`isCorrect\` values are synchronized from \`settings.correctValue\`.
- THUMBS: open-ended — no correct answer, not auto-graded. Keep exactly two options (default labels Yes and No; teachers may customize labels). Do not set \`settings.correctValue\`.
- NUMERIC: see the dedicated NUMERIC block below — \`settings.correctValue\` is required and \`settings.tolerance\` should almost always be set. \`options\` MUST be empty/absent.
- STAR: on \`settings.correctValue\` (1..max stars).
- WORD_BANK: on \`settings.correctAnswers\` (array, one per \`___\` blank) and \`settings.template\`.
@@ -349,7 +351,8 @@ When you create an exercise (especially during plan implementation), it must act
### Question writing
- Each question must reference something specific the lesson taught (a concept, a worked example, a definition, a step in a procedure). Do not write questions whose answer cannot be derived from the lesson.
- Prefer scenario / "what would happen if…" / "why does X work this way?" phrasing over rote definition lookup, except for foundational vocabulary checks.
- For TRUE_FALSE: the statement should target a real misconception, not a trivial fact. Use sparingly.
- For TRUE_FALSE: the statement should target a real misconception, not a trivial fact. Use sparingly. Options: True / False. Auto-graded.
- For THUMBS: use when Yes/No framing fits ("Would you recommend…?", "Do you agree…?"). Open-ended — no correct answer, not auto-graded. Labels default to Yes/No but may be customized. Use sparingly.
- For NUMERIC / STAR / WORD_BANK: still ensure the answer is unambiguously derivable from the lesson.
### Per-exercise structure
+23 -30
View File
@@ -1,5 +1,11 @@
import { z } from 'zod';
import { QUESTION_TYPE_REGISTRY } from '@cio/question-types';
import {
ENABLED_QUESTION_TYPE_REGISTRY,
ENABLED_QUESTION_TYPE_ID_DESCRIPTION,
QUESTION_OPTIONS_SCHEMA_HINT,
QUESTION_SETTINGS_SCHEMA_HINT,
UPDATE_QUESTIONS_BINARY_TYPES_HINT
} from '@cio/question-types';
import { CoursePlanSchema } from '../types';
import { CourseTemplateIdSchema, TemplateFormFieldSchema } from '../templates';
import {
@@ -8,7 +14,7 @@ import {
LANDING_PAGE_SECTION_HTML_AGENT_HINT
} from './landing-page-html-hint';
const QUESTION_TYPE_ID_DESCRIPTION = QUESTION_TYPE_REGISTRY.map((t) => `${t.id}=${t.typename} (${t.label})`).join(', ');
const ENABLED_QUESTION_TYPE_MAX_ID = Math.max(...ENABLED_QUESTION_TYPE_REGISTRY.map((type) => type.id));
/**
* Mutation tool schemas available only to teachers.
@@ -132,18 +138,13 @@ export const createExerciseSchema = {
.number()
.int()
.min(1)
.max(QUESTION_TYPE_REGISTRY.length)
.max(ENABLED_QUESTION_TYPE_MAX_ID)
.describe(
`Required on every question — never omit. Supported types: ${QUESTION_TYPE_ID_DESCRIPTION}. Vary types across questions (do not default everything to RADIO/1).`
`Required on every question — never omit. Supported types: ${ENABLED_QUESTION_TYPE_ID_DESCRIPTION}. Vary types across questions (do not default everything to RADIO/1).`
),
points: z.number().min(0).default(1).describe('Points for this question'),
order: z.number().int().min(0).describe('Display order'),
settings: z
.record(z.string(), z.unknown())
.optional()
.describe(
'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
),
settings: z.record(z.string(), z.unknown()).optional().describe(QUESTION_SETTINGS_SCHEMA_HINT),
options: z
.array(
z.object({
@@ -151,9 +152,7 @@ export const createExerciseSchema = {
isCorrect: z.boolean().describe('Whether this is a correct answer')
})
)
.describe(
'Answer options. RADIO and CHECKBOX questions MUST have at least 4 options with plausible distractors (not obvious filler). RADIO needs exactly one correct option; CHECKBOX needs at least 2 correct and at least 1 incorrect. TRUE_FALSE uses exactly 2 options labeled True and False; set the correct answer in settings.correctValue. Leave empty for NUMERIC/STAR/WORD_BANK.'
)
.describe(QUESTION_OPTIONS_SCHEMA_HINT)
})
)
.min(1)
@@ -250,7 +249,7 @@ export const updateExerciseSchema = {
export const addQuestionsSchema = {
description:
'Add questions to an existing exercise. Use this when the exercise already exists and you want to add more questions. When get_exercise_details returns a non-empty sections array, pass exerciseSectionId so new questions are placed in the right in-exercise block (same ids as update_exercise_section).',
'Add questions to an existing exercise. Use this when the exercise already exists and you want to add more questions. When get_exercise_details returns a non-empty sections array, pass exerciseSectionId so new questions are placed in the right in-exercise block (same ids as update_exercise_section). For THUMBS (14), use two options (default Yes/No labels, customizable) with no correct answer — open-ended, not auto-graded. For TRUE_FALSE (4), use True/False labels and settings.correctValue.',
parameters: z.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The existing exercise ID'),
@@ -268,32 +267,28 @@ export const addQuestionsSchema = {
.number()
.int()
.min(1)
.max(QUESTION_TYPE_REGISTRY.length)
.max(ENABLED_QUESTION_TYPE_MAX_ID)
.describe(
`Required on every question — never omit. Supported types: ${QUESTION_TYPE_ID_DESCRIPTION}. Vary types across questions (do not default everything to RADIO/1).`
`Required on every question — never omit. Supported types: ${ENABLED_QUESTION_TYPE_ID_DESCRIPTION}. Vary types across questions (do not default everything to RADIO/1).`
),
points: z.number().min(0).default(1).describe('Points for this question'),
order: z.number().int().min(0).describe('Display order'),
settings: z
.record(z.string(), z.unknown())
.optional()
.describe(
'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
),
options: z.array(
settings: z.record(z.string(), z.unknown()).optional().describe(QUESTION_SETTINGS_SCHEMA_HINT),
options: z
.array(
z.object({
label: z.string().min(1).describe('Option text'),
isCorrect: z.boolean().describe('Whether this is a correct answer')
})
)
.describe(QUESTION_OPTIONS_SCHEMA_HINT)
})
)
})
};
export const updateQuestionsSchema = {
description:
'Update existing questions in an exercise. Pass only the fields you want to change; `id` identifies the question. Optional `exerciseSectionId` (from get_exercise_details sections) moves the question to another in-exercise block; use null to clear section assignment. For TRUE_FALSE use `settings.correctValue` (boolean) and keep exactly two options labeled True and False. For NUMERIC the correct answer is `settings.correctValue` (a number), not an option — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue` (1..max). For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX use `options[].isCorrect`: include an option `id` to edit it, omit `id` to add a new one. `settings` is shallow-merged with the existing settings. Omit `options` entirely to leave existing options unchanged — if you pass an options array, any existing option not listed in it will be removed.',
description: `Update existing questions in an exercise. Pass only the fields you want to change; \`id\` identifies the question. Optional \`exerciseSectionId\` (from get_exercise_details sections) moves the question to another in-exercise block; use null to clear section assignment. ${UPDATE_QUESTIONS_BINARY_TYPES_HINT} For NUMERIC the correct answer is \`settings.correctValue\` (a number), not an option — do NOT add options to NUMERIC questions. For STAR use \`settings.correctValue\` (1..max). For WORD_BANK use \`settings.correctAnswers\` and \`settings.template\`. RADIO/CHECKBOX use \`options[].isCorrect\`: include an option \`id\` to edit it, omit \`id\` to add a new one. \`settings\` is shallow-merged with the existing settings. Omit \`options\` entirely to leave existing options unchanged — if you pass an options array, any existing option not listed in it will be removed.`,
parameters: z.object({
courseId: z.string().describe('The course ID'),
exerciseId: z.string().describe('The existing exercise ID'),
@@ -305,9 +300,9 @@ export const updateQuestionsSchema = {
.number()
.int()
.min(1)
.max(QUESTION_TYPE_REGISTRY.length)
.max(ENABLED_QUESTION_TYPE_MAX_ID)
.optional()
.describe(`Question type ID. Supported types: ${QUESTION_TYPE_ID_DESCRIPTION}`),
.describe(`Question type ID. Supported types: ${ENABLED_QUESTION_TYPE_ID_DESCRIPTION}`),
points: z.number().min(0).optional(),
order: z.number().int().min(0).optional(),
exerciseSectionId: z
@@ -321,9 +316,7 @@ export const updateQuestionsSchema = {
settings: z
.record(z.string(), z.unknown())
.optional()
.describe(
'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }. Shallow-merged with existing settings.'
),
.describe(`${QUESTION_SETTINGS_SCHEMA_HINT} Shallow-merged with existing settings.`),
options: z
.array(
z.object({
@@ -5,7 +5,12 @@ import {
LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT,
LANDING_PAGE_SECTION_HTML_AGENT_HINT
} from '@cio/ai-assistant/tools';
import { ENABLED_QUESTION_TYPE_REGISTRY } from '@cio/question-types';
import {
ADD_QUESTIONS_TOOL_DESCRIPTION,
ENABLED_QUESTION_TYPE_REGISTRY,
QUESTION_SETTINGS_SCHEMA_HINT,
UPDATE_QUESTIONS_BINARY_TYPES_HINT
} from '@cio/question-types';
import { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise';
import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdateFields } from '@cio/utils/validation/course';
@@ -83,12 +88,7 @@ export const questionSchema = z.object({
),
points: z.number().min(0).default(1),
order: z.number().int().min(0),
settings: z
.record(z.string(), z.unknown())
.optional()
.describe(
'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
),
settings: z.record(z.string(), z.unknown()).optional().describe(QUESTION_SETTINGS_SCHEMA_HINT),
options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() }))
});
@@ -211,9 +211,7 @@ export const updateQuestionSettingsSchema = z
.refine((settings) => Object.keys(settings).length > 0, {
message: 'Provide at least one settings field'
})
.describe(
'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean }. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
);
.describe(QUESTION_SETTINGS_SCHEMA_HINT);
export const updateOptionSchema = z.object({
id: z.number().int().optional(),
@@ -2,7 +2,13 @@ import { createHash } from 'node:crypto';
import { tool } from 'ai';
import { CoursePlanSchema, getCourseTemplate } from '@cio/ai-assistant';
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPE_ID_TO_KEY, QUESTION_TYPE_REGISTRY } from '@cio/question-types';
import {
ADD_QUESTIONS_TOOL_DESCRIPTION,
PREMIUM_QUESTION_TYPE_KEYS,
QUESTION_TYPE_ID_TO_KEY,
QUESTION_TYPE_REGISTRY,
UPDATE_QUESTIONS_BINARY_TYPES_HINT
} from '@cio/question-types';
import { AppError } from '@cio/utils/errors';
import { trackAgentEvent, AgentEvent } from '../../utils/tinybird';
import {
@@ -781,8 +787,7 @@ export function buildAgentTools(
}),
add_questions: tool({
description:
'Add questions to an existing exercise in this course. When get_exercise_details lists in-exercise sections, pass exerciseSectionId so new questions are added to the correct block.',
description: ADD_QUESTIONS_TOOL_DESCRIPTION,
inputSchema: addQuestionsParam,
execute: async (args) => {
return executeAgentTool('add_questions', { orgId, userId, courseId, args }, async () => {
@@ -847,8 +852,7 @@ export function buildAgentTools(
}),
update_questions: tool({
description:
'Update existing questions in an exercise. Pass only fields you want to change; `id` identifies the question. Optional `exerciseSectionId` moves the question to another in-exercise block (use get_exercise_details section ids), or null to unassign. For TRUE_FALSE use `settings.correctValue` (boolean) and keep exactly two options labeled True and False. For NUMERIC, the correct answer is `settings.correctValue` (number) — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue`. For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX use `options[].isCorrect` (include option `id` to edit, omit `id` to add). `settings` is shallow-merged with existing settings. Omit `options` entirely to leave existing options untouched.',
description: `Update existing questions in an exercise. Pass only fields you want to change; \`id\` identifies the question. Optional \`exerciseSectionId\` moves the question to another in-exercise block (use get_exercise_details section ids), or null to unassign. ${UPDATE_QUESTIONS_BINARY_TYPES_HINT} For NUMERIC, the correct answer is \`settings.correctValue\` (number) — do NOT add options to NUMERIC questions. For STAR use \`settings.correctValue\`. For WORD_BANK use \`settings.correctAnswers\` and \`settings.template\`. RADIO/CHECKBOX use \`options[].isCorrect\` (include option \`id\` to edit, omit \`id\` to add). \`settings\` is shallow-merged with existing settings. Omit \`options\` entirely to leave existing options untouched.`,
inputSchema: updateQuestionsParam,
execute: async (args) => {
return executeAgentTool('update_questions', { orgId, userId, courseId, args }, async () => {
+6 -6
View File
@@ -76,14 +76,14 @@ function areSettingsEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized);
}
const TRUE_FALSE_QUESTION_TYPE_ID = QUESTION_TYPE_IDS[QUESTION_TYPE_KEY.TRUE_FALSE];
const BINARY_BOOLEAN_QUESTION_TYPE_IDS = new Set([QUESTION_TYPE_IDS[QUESTION_TYPE_KEY.TRUE_FALSE]]);
function normalizeTrueFalseQuestion(
function normalizeBinaryBooleanQuestion(
question: ExerciseUpdateQuestion,
currentQuestion?: CurrentQuestion
): ExerciseUpdateQuestion {
const questionTypeId = question.questionTypeId ?? currentQuestion?.questionTypeId;
if (questionTypeId !== TRUE_FALSE_QUESTION_TYPE_ID) return question;
if (questionTypeId == null || !BINARY_BOOLEAN_QUESTION_TYPE_IDS.has(questionTypeId)) return question;
const settings = normalizeSettings(question.settings ?? currentQuestion?.settings);
const options = question.options ?? currentQuestion?.options ?? [];
@@ -126,7 +126,7 @@ export function categorizeQuestions(questions: NonNullable<TExerciseUpdate['ques
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
for (const rawQuestion of questions) {
const q = normalizeTrueFalseQuestion(rawQuestion);
const q = normalizeBinaryBooleanQuestion(rawQuestion);
if (q.deletedAt && q.id) {
deletedIds.push(q.id);
@@ -263,7 +263,7 @@ export function computeExerciseDiff(
) {
const questionMap = new Map(currentQuestions.map((q) => [q.id, q]));
const normalizedIncomingQuestions = incomingQuestions.map((incoming) =>
normalizeTrueFalseQuestion(incoming, incoming.id ? questionMap.get(incoming.id) : undefined)
normalizeBinaryBooleanQuestion(incoming, incoming.id ? questionMap.get(incoming.id) : undefined)
);
const deletedQuestionIds: number[] = [];
@@ -371,7 +371,7 @@ export async function createNewQuestionsWithOptions(
newQuestions: NonNullable<TExerciseUpdate['questions']>,
txClient: DbOrTxClient
): Promise<void> {
const normalizedQuestions = newQuestions.map((question) => normalizeTrueFalseQuestion(question));
const normalizedQuestions = newQuestions.map((question) => normalizeBinaryBooleanQuestion(question));
const createdQuestions = await createQuestions(
normalizedQuestions.map((q) => ({
exerciseId,
@@ -545,3 +545,19 @@ export async function syncQuestionIdSequence(dbClient: DbOrTxClient = db): Promi
`
);
}
/**
* Syncs the question_type table's identity sequence to the current max(id).
* Call before inserting question types to avoid duplicate key on question_type_pkey when the sequence is behind.
*/
export async function syncQuestionTypeIdSequence(dbClient: DbOrTxClient = db): Promise<void> {
await dbClient.execute(
sql`
SELECT setval(
pg_get_serial_sequence('question_type', 'id'),
COALESCE((SELECT MAX(id) FROM question_type), 1),
EXISTS(SELECT 1 FROM question_type)
)
`
);
}
+2 -2
View File
@@ -1968,8 +1968,8 @@ export const questionAnswer = pgTable(
/**
* Exercise question kinds (`typename` matches `@cio/question-types` keys). Expected ids:
* 1 RADIO, 2 CHECKBOX, 3 TEXTAREA, 4 TRUE_FALSE, 5 SHORT_ANSWER, 6 NUMERIC, 7 FILL_BLANK,
* 8 FILE_UPLOAD, 9 MATCHING, 10 ORDERING, 11 HOTSPOT, 12 LINK, 13 WORD_BANK, 14 STAR,
* 15 VIDEO_RECORDING.
* 8 FILE_UPLOAD, 9 ORDERING, 10 LINK, 11 WORD_BANK, 12 STAR, 13 VIDEO_RECORDING, 14 THUMBS.
* Disabled (no DB row): 15 MATCHING, 16 HOTSPOT.
*/
export const questionType = pgTable(
'question_type',
@@ -1,4 +1,5 @@
import { db, questionType } from '@db/drizzle';
import { syncQuestionTypeIdSequence } from '@db/queries/exercise';
export async function seedQuestionTypes() {
const existingQuestionTypes = await db.select().from(questionType);
@@ -82,10 +83,17 @@ export async function seedQuestionTypes() {
typename: 'VIDEO_RECORDING',
createdAt: '2026-04-30 00:00:00+00',
updatedAt: '2026-04-30 00:00:00+00'
},
{
label: 'Thumbs up/down',
typename: 'THUMBS',
createdAt: '2026-07-23 00:00:00+00',
updatedAt: '2026-07-23 00:00:00+00'
}
].filter((q) => !questionTypeNames.includes(q.typename));
if (questionTypesToInsert.length > 0) {
await syncQuestionTypeIdSequence();
await db.insert(questionType).values(questionTypesToInsert);
console.log(` ✓ Inserted ${questionTypesToInsert.length} question type(s)`);
} else {
+9 -5
View File
@@ -430,15 +430,19 @@ Supported `questionTypeId` values for exercise payloads:
- `1` `RADIO` - Single answer
- `2` `CHECKBOX` - Multiple answers
- `3` `TEXTAREA` - Paragraph
- `4` `TRUE_FALSE` - True/False
- `4` `TRUE_FALSE` - True/False (factual statements; options: True, False; auto-graded)
- `5` `SHORT_ANSWER` - Short answer
- `6` `NUMERIC` - Numeric answer
- `7` `FILL_BLANK` - Fill in the blank
- `8` `FILE_UPLOAD` - File upload
- `9` `MATCHING` - Matching
- `10` `ORDERING` - Ordering
- `11` `HOTSPOT` - Hotspot
- `12` `LINK` - Links
- `9` `ORDERING` - Ordering
- `10` `LINK` - Links
- `11` `WORD_BANK` - Word bank
- `12` `STAR` - Star rating
- `13` `VIDEO_RECORDING` - Video recording
- `14` `THUMBS` - Thumbs up/down (open-ended poll with customizable Yes/No labels; no correct answer)
`TRUE_FALSE` is auto-graded with `settings.correctValue` (boolean) and fixed True/False labels for factual statements. `THUMBS` is open-ended (not auto-graded): two customizable Yes/No-style options with thumbs icons for sentiment or poll-style questions — do not set `settings.correctValue`. `MATCHING` and `HOTSPOT` are disabled and must not be used.
## Operational Notes
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classroomio/mcp",
"version": "0.0.9",
"version": "0.0.10",
"private": false,
"type": "module",
"main": "./dist/index.js",
@@ -35,6 +35,7 @@
"registry": "https://registry.npmjs.org/"
},
"dependencies": {
"@cio/question-types": "workspace:*",
"@cio/utils": "workspace:*",
"@modelcontextprotocol/sdk": "^1.27.1"
},
+1 -1
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
const ZConfig = z.object({
CLASSROOMIO_API_URL: z.url().default('https://api.classroomio.com'),
CLASSROOMIO_API_KEY: z.string().min(1),
CLASSROOMIO_USER_AGENT: z.string().min(1).default('classroomio-mcp/0.0.9')
CLASSROOMIO_USER_AGENT: z.string().min(1).default('classroomio-mcp/0.0.10')
});
export type McpServerConfig = z.infer<typeof ZConfig>;
+2 -2
View File
@@ -28,6 +28,7 @@ import {
import type { ClassroomIoApiClient } from '../api-client';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { formatEnabledQuestionTypesGuide } from '@cio/question-types';
import { ZGetOrganizationCoursesQuery } from '@cio/utils/validation/organization';
import type { ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js';
@@ -104,8 +105,7 @@ const publishCourseDraftToExistingCourseShape =
ZPublishCourseDraftToExistingCourseToolInput.shape as unknown as ZodRawShapeCompat;
const tagCoursesShape = ZAutomationCourseTagAssignment.shape as unknown as ZodRawShapeCompat;
const SUPPORTED_QUESTION_TYPES_GUIDE =
'Supported questionTypeId values: RADIO=1 (Single answer), CHECKBOX=2 (Multiple answers), TEXTAREA=3 (Paragraph), TRUE_FALSE=4 (True/False), SHORT_ANSWER=5 (Short answer), NUMERIC=6 (Numeric answer), FILL_BLANK=7 (Fill in the blank), FILE_UPLOAD=8 (File upload), MATCHING=9 (Matching), ORDERING=10 (Ordering), HOTSPOT=11 (Hotspot), LINK=12 (Links), WORD_BANK=13 (Word bank). Use the numeric ids in exercise payloads.';
const SUPPORTED_QUESTION_TYPES_GUIDE = formatEnabledQuestionTypesGuide();
const LESSON_HTML_GUIDE =
'For draft lesson HTML, put only the lesson body in lessonLanguages[].content. Do not include the lesson title. Do not use h1 or h2 anywhere in lesson HTML. Start headings at h3 because that is the highest heading level allowed in lesson content.';
@@ -0,0 +1,38 @@
import { ENABLED_QUESTION_TYPE_REGISTRY } from './question-type-registry';
/** Compact id=typename (label) list for Zod `.describe()` on questionTypeId fields. */
export const ENABLED_QUESTION_TYPE_ID_DESCRIPTION = ENABLED_QUESTION_TYPE_REGISTRY.map(
(type) => `${type.id}=${type.typename} (${type.label})`
).join(', ');
/** Shared hint for `settings` on create/add/update question tool schemas. */
export const QUESTION_SETTINGS_SCHEMA_HINT =
'Per-type correct-answer storage. TRUE_FALSE: { correctValue: boolean } with exactly two options labeled True and False (factual statements). THUMBS: open-ended sentiment/poll — no correct answer; omit settings or use {}. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.';
/** Shared hint for `options` on create/add question tool schemas. */
export const QUESTION_OPTIONS_SCHEMA_HINT =
'Answer options. RADIO and CHECKBOX must have at least 4 plausible options. TRUE_FALSE: exactly 2 options labeled True and False; teacher sets correct answer in settings.correctValue. THUMBS: exactly 2 options (default Yes/No labels, customizable); no correct answer — not auto-graded. Omit or leave empty for NUMERIC, STAR, and WORD_BANK.';
/** Tool-level description fragment for update_questions (dashboard agent + legacy schemas). */
export const UPDATE_QUESTIONS_BINARY_TYPES_HINT =
'For TRUE_FALSE use settings.correctValue (boolean) with exactly two options labeled True and False. For THUMBS use two customizable options (default Yes/No) with no correct answer — open-ended, not auto-graded.';
/** Tool-level description for add_questions. */
export const ADD_QUESTIONS_TOOL_DESCRIPTION =
'Add questions to an existing exercise in this course. When get_exercise_details lists in-exercise sections, pass exerciseSectionId so new questions are added to the correct block. For THUMBS (14), use two options (default Yes/No labels, customizable) with no correct answer — open-ended sentiment/poll, not auto-graded. For TRUE_FALSE (4), use True/False labels and settings.correctValue.';
/** Full question-type id guide for MCP exercise tool descriptions. */
export function formatEnabledQuestionTypesGuide(): string {
const entries = ENABLED_QUESTION_TYPE_REGISTRY.map((type) => `${type.id}=${type.typename} (${type.label})`).join(
', '
);
return `Supported questionTypeId values: ${entries}. TRUE_FALSE (4) is auto-graded with settings.correctValue (boolean) and True/False labels for factual statements. THUMBS (14) is open-ended (no correct answer, not auto-graded): two customizable Yes/No-style options with thumbs icons for sentiment or poll-style questions. MATCHING and HOTSPOT are disabled and must not be used.`;
}
/** Markdown bullet list of enabled question types (for docs). */
export function formatEnabledQuestionTypesMarkdownList(): string {
return ENABLED_QUESTION_TYPE_REGISTRY.map((type) => `- \`${type.id}\` \`${type.typename}\` - ${type.label}`).join(
'\n'
);
}
@@ -5,6 +5,7 @@ import type {
RadioAnswerData,
CheckboxAnswerData,
TrueFalseAnswerData,
ThumbsAnswerData,
TextareaAnswerData,
ShortAnswerData,
NumericAnswerData,
@@ -86,6 +87,19 @@ const TRUE_FALSE_CODEC: AnswerCodec<TrueFalseAnswerData> = {
}
};
const THUMBS_CODEC: AnswerCodec<ThumbsAnswerData> = {
type: 'THUMBS',
toApiPayload(data, questionId) {
return { questionId, answer: String(data.value) };
},
fromApiPayload(payload) {
if (payload.answer === undefined) return null;
const resolved = labelToBoolean(payload.answer);
if (resolved === undefined) return null;
return { type: 'THUMBS', value: resolved };
}
};
const TEXTAREA_CODEC: AnswerCodec<TextareaAnswerData> = {
type: 'TEXTAREA',
toApiPayload(data, questionId) {
@@ -308,6 +322,7 @@ export const ANSWER_CODECS: Record<QuestionTypeKey, AnswerCodec> = {
[QUESTION_TYPE_KEY.RADIO]: RADIO_CODEC,
[QUESTION_TYPE_KEY.CHECKBOX]: CHECKBOX_CODEC,
[QUESTION_TYPE_KEY.TRUE_FALSE]: TRUE_FALSE_CODEC,
[QUESTION_TYPE_KEY.THUMBS]: THUMBS_CODEC,
[QUESTION_TYPE_KEY.TEXTAREA]: TEXTAREA_CODEC,
[QUESTION_TYPE_KEY.SHORT_ANSWER]: SHORT_ANSWER_CODEC,
[QUESTION_TYPE_KEY.NUMERIC]: NUMERIC_CODEC,
@@ -357,6 +372,7 @@ export function extractAnswerDisplayValues(data: AnswerData): {
selectedValues: data.optionIds
};
case 'TRUE_FALSE':
case 'THUMBS':
return {
selectedIds: [String(data.value)],
selectedValues: [data.value]
@@ -1,6 +1,7 @@
export type RadioAnswerData = { type: 'RADIO'; optionId: number };
export type CheckboxAnswerData = { type: 'CHECKBOX'; optionIds: number[] };
export type TrueFalseAnswerData = { type: 'TRUE_FALSE'; value: boolean };
export type ThumbsAnswerData = { type: 'THUMBS'; value: boolean };
export type TextareaAnswerData = { type: 'TEXTAREA'; text: string };
export type ShortAnswerData = { type: 'SHORT_ANSWER'; text: string };
export type NumericAnswerData = { type: 'NUMERIC'; value: number };
@@ -37,6 +38,7 @@ export type AnswerData =
| RadioAnswerData
| CheckboxAnswerData
| TrueFalseAnswerData
| ThumbsAnswerData
| TextareaAnswerData
| ShortAnswerData
| NumericAnswerData
@@ -49,6 +49,11 @@ export type ExerciseQuestionLabelKey =
| 'true_false.false_label'
| 'true_false.edit.correct_answer_label'
| 'true_false.preview.correct_value_label'
| 'thumbs.yes_label'
| 'thumbs.no_label'
| 'thumbs.edit.positive_option_label'
| 'thumbs.edit.negative_option_label'
| 'thumbs.preview.helper'
| 'short_answer.edit.instructions_placeholder'
| 'short_answer.take.placeholder'
| 'short_answer.preview.helper'
+2
View File
@@ -19,3 +19,5 @@ export * from './textarea-validation';
export * from './star-rating-settings';
export * from './video-recording-settings';
export * from './true-false-answer-key';
export * from './thumbs-question';
export * from './agent-question-type-guide';
@@ -5,6 +5,7 @@ export type QuestionAnswerPayload =
| CheckboxAnswerPayload
| TextareaAnswerPayload
| TrueFalseAnswerPayload
| ThumbsAnswerPayload
| ShortAnswerPayload
| NumericAnswerPayload
| FillBlankAnswerPayload
@@ -41,6 +42,11 @@ export interface TrueFalseAnswerPayload extends BaseAnswerPayload {
value: boolean;
}
export interface ThumbsAnswerPayload extends BaseAnswerPayload {
type: typeof QUESTION_TYPE_KEY.THUMBS;
value: boolean;
}
export interface ShortAnswerPayload extends BaseAnswerPayload {
type: typeof QUESTION_TYPE_KEY.SHORT_ANSWER;
text: string;
@@ -13,7 +13,8 @@ export const QUESTION_TYPE_KEY = {
LINK: 'LINK',
WORD_BANK: 'WORD_BANK',
STAR: 'STAR',
VIDEO_RECORDING: 'VIDEO_RECORDING'
VIDEO_RECORDING: 'VIDEO_RECORDING',
THUMBS: 'THUMBS'
} as const;
export type QuestionTypeKey = (typeof QUESTION_TYPE_KEY)[keyof typeof QUESTION_TYPE_KEY];
@@ -119,6 +119,15 @@ export const QUESTION_TYPE_REGISTRY: readonly QuestionTypeMetadata[] = [
supportsPartialCredit: false,
manualGradingRequired: true
},
{
key: QUESTION_TYPE_KEY.THUMBS,
typename: 'THUMBS',
label: 'Thumbs up/down',
id: 14,
autoGradable: false,
supportsPartialCredit: false,
manualGradingRequired: false
},
// Built but not offered in the product. Kept at the END of the id sequence
// (append-only) so they never shift the ids of types that exist in the DB.
// They have no `question_type` row, so `disabled` keeps them out of pickers,
@@ -127,7 +136,7 @@ export const QUESTION_TYPE_REGISTRY: readonly QuestionTypeMetadata[] = [
key: QUESTION_TYPE_KEY.MATCHING,
typename: 'MATCHING',
label: 'Matching',
id: 14,
id: 15,
autoGradable: true,
supportsPartialCredit: true,
manualGradingRequired: false,
@@ -137,7 +146,7 @@ export const QUESTION_TYPE_REGISTRY: readonly QuestionTypeMetadata[] = [
key: QUESTION_TYPE_KEY.HOTSPOT,
typename: 'HOTSPOT',
label: 'Hotspot',
id: 15,
id: 16,
autoGradable: true,
supportsPartialCredit: true,
manualGradingRequired: false,
@@ -0,0 +1,23 @@
import type { ExerciseQuestionOption } from './exercise-types';
type ThumbsQuestionShape = {
settings?: Record<string, unknown>;
options?: ExerciseQuestionOption[];
};
/** Strips answer-key fields from THUMBS questions (open-ended — no correct answer). */
export function normalizeThumbsQuestion<T extends ThumbsQuestionShape>(question: T): T {
const settings = { ...(question.settings ?? {}) };
delete settings.correctValue;
const options = (question.options ?? []).map((option) => ({
...option,
isCorrect: false
}));
return {
...question,
settings,
options
};
}
@@ -13,12 +13,12 @@ function normalizeBooleanText(value: unknown): string {
export function getTrueFalseOptionValue(option: TrueFalseAnswerOption): boolean | undefined {
const normalizedValue = normalizeBooleanText(option.value);
if (normalizedValue === 'true' || normalizedValue === '1') return true;
if (normalizedValue === 'false' || normalizedValue === '0') return false;
if (normalizedValue === 'true' || normalizedValue === '1' || normalizedValue === 'yes') return true;
if (normalizedValue === 'false' || normalizedValue === '0' || normalizedValue === 'no') return false;
const normalizedLabel = normalizeBooleanText(option.label);
if (normalizedLabel === 'true' || normalizedLabel === '1') return true;
if (normalizedLabel === 'false' || normalizedLabel === '0') return false;
if (normalizedLabel === 'true' || normalizedLabel === '1' || normalizedLabel === 'yes') return true;
if (normalizedLabel === 'false' || normalizedLabel === '0' || normalizedLabel === 'no') return false;
return undefined;
}
@@ -122,10 +122,18 @@ const EXPECTED_REGISTRY = [
supportsPartialCredit: false,
manualGradingRequired: true
},
{
key: QUESTION_TYPE_KEY.THUMBS,
typename: 'THUMBS',
id: 14,
autoGradable: false,
supportsPartialCredit: false,
manualGradingRequired: false
},
{
key: QUESTION_TYPE_KEY.MATCHING,
typename: 'MATCHING',
id: 14,
id: 15,
autoGradable: true,
supportsPartialCredit: true,
manualGradingRequired: false,
@@ -134,7 +142,7 @@ const EXPECTED_REGISTRY = [
{
key: QUESTION_TYPE_KEY.HOTSPOT,
typename: 'HOTSPOT',
id: 15,
id: 16,
autoGradable: true,
supportsPartialCredit: true,
manualGradingRequired: false,
@@ -143,7 +151,7 @@ const EXPECTED_REGISTRY = [
] as const;
// Canonical `question_type` rows in the database (the source of truth ids are
// resolved against). Ids 1-13 are the live rows in insertion order; MATCHING and
// resolved against). Ids 1-14 are the live rows in insertion order; MATCHING and
// HOTSPOT are intentionally absent (built-but-disabled). This list guards against
// the registry id ever drifting from the DB again.
const CANONICAL_DB_QUESTION_TYPES: ReadonlyArray<{ id: number; typename: string }> = [
@@ -159,7 +167,8 @@ const CANONICAL_DB_QUESTION_TYPES: ReadonlyArray<{ id: number; typename: string
{ id: 10, typename: 'LINK' },
{ id: 11, typename: 'WORD_BANK' },
{ id: 12, typename: 'STAR' },
{ id: 13, typename: 'VIDEO_RECORDING' }
{ id: 13, typename: 'VIDEO_RECORDING' },
{ id: 14, typename: 'THUMBS' }
];
describe('question type registry', () => {
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
QUESTION_TYPE_KEY,
fromApiPayload,
isAutoGradableQuestionType,
scoreAnswerForQuestion,
toApiPayload,
type ExerciseQuestionModel
} from '../src';
function buildThumbsQuestion(): ExerciseQuestionModel {
return {
id: 1,
title: 'Was this lesson helpful?',
questionType: QUESTION_TYPE_KEY.THUMBS,
points: 5,
settings: {},
options: [
{ id: 1, label: 'Yes', value: 'true', isCorrect: false },
{ id: 2, label: 'No', value: 'false', isCorrect: false }
]
};
}
describe('thumbs codec and scoring', () => {
it('round-trips boolean answers through the API codec', () => {
const question = buildThumbsQuestion();
const payload = toApiPayload({ type: 'THUMBS', value: true }, 1);
expect(payload).toEqual({ questionId: 1, answer: 'true' });
expect(fromApiPayload(QUESTION_TYPE_KEY.THUMBS, payload, question)).toEqual({
type: 'THUMBS',
value: true
});
expect(fromApiPayload(QUESTION_TYPE_KEY.THUMBS, { questionId: 1, answer: 'yes' }, question)).toEqual({
type: 'THUMBS',
value: true
});
expect(fromApiPayload(QUESTION_TYPE_KEY.THUMBS, { questionId: 1, answer: 'no' }, question)).toEqual({
type: 'THUMBS',
value: false
});
});
it('is not auto-graded and always scores zero', () => {
const question = buildThumbsQuestion();
expect(isAutoGradableQuestionType(QUESTION_TYPE_KEY.THUMBS)).toBe(false);
expect(scoreAnswerForQuestion(question, { type: 'THUMBS', value: true })).toBe(0);
expect(scoreAnswerForQuestion(question, { type: 'THUMBS', value: false })).toBe(0);
expect(scoreAnswerForQuestion(question, { type: 'TRUE_FALSE', value: true })).toBe(0);
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { normalizeThumbsQuestion } from '../src';
describe('thumbs question helpers', () => {
it('normalizes answer-key fields out of thumbs questions', () => {
expect(
normalizeThumbsQuestion({
settings: { correctValue: true },
options: [
{ id: 1, label: 'Yes', value: 'true', isCorrect: true },
{ id: 2, label: 'No', value: 'false', isCorrect: false }
]
})
).toEqual({
settings: {},
options: [
{ id: 1, label: 'Yes', value: 'true', isCorrect: false },
{ id: 2, label: 'No', value: 'false', isCorrect: false }
]
});
});
});
@@ -31,4 +31,17 @@ describe('true/false answer key helpers', () => {
{ label: 'False', value: 'false', isCorrect: true }
]);
});
it('recognizes yes/no option labels used by thumbs questions', () => {
const options = [
{ label: 'Yes', value: 'yes', isCorrect: false },
{ label: 'No', value: 'no', isCorrect: true }
];
expect(resolveTrueFalseCorrectValue({}, options)).toBe(false);
expect(syncTrueFalseOptions(options, true)).toEqual([
{ label: 'Yes', value: 'yes', isCorrect: true },
{ label: 'No', value: 'no', isCorrect: false }
]);
});
});
@@ -16,6 +16,7 @@
SHORT_ANSWER_FIXTURE,
STAR_FIXTURE,
TEXTAREA_FIXTURE,
THUMBS_FIXTURE,
TRUE_FALSE_FIXTURE,
VIDEO_RECORDING_FIXTURE,
WORD_BANK_FIXTURE
@@ -353,6 +354,16 @@
{/snippet}
</Story>
<Story name="Thumbs">
{#snippet template()}
<QuestionTypeModes
question={THUMBS_FIXTURE.question}
answer={THUMBS_FIXTURE.answer}
wrongAnswer={THUMBS_FIXTURE.wrongAnswer}
/>
{/snippet}
</Story>
<Story name="TrueFalse">
{#snippet template()}
<QuestionTypeModes
@@ -95,6 +95,22 @@ export const TEXTAREA_FIXTURE: QuestionStoryFixture = {
answer: '<p>Specific feedback helps students improve on the next attempt quickly.</p>'
};
export const THUMBS_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-thumbs',
key: 'q-thumbs',
title: 'Thumbs Up or Down: Would you recommend ClassroomIO to a colleague? [THUMBS]',
questionType: 'THUMBS',
options: [
{ id: 5001, label: 'Yes', value: 'true', isCorrect: false },
{ id: 5002, label: 'No', value: 'false', isCorrect: false }
],
settings: {}
},
answer: { type: 'THUMBS', value: true },
wrongAnswer: { type: 'THUMBS', value: false }
};
export const TRUE_FALSE_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-true-false',
@@ -49,6 +49,11 @@ export const QUESTION_LABELS = {
'true_false.false_label': 'False',
'true_false.edit.correct_answer_label': 'Correct answer',
'true_false.preview.correct_value_label': 'Correct value',
'thumbs.yes_label': 'Yes',
'thumbs.no_label': 'No',
'thumbs.edit.positive_option_label': 'Thumbs up label',
'thumbs.edit.negative_option_label': 'Thumbs down label',
'thumbs.preview.helper': 'Open-ended — students choose thumbs up or down.',
'short_answer.edit.instructions_placeholder': 'Optional instructions',
'short_answer.take.placeholder': 'Type your answer',
'short_answer.preview.helper': 'Free-text answer question.',
@@ -50,6 +50,8 @@
};
case 'TRUE_FALSE':
return { type: 'TRUE_FALSE', value: index % 3 !== 0 };
case 'THUMBS':
return { type: 'THUMBS', value: index % 3 !== 0 };
case 'TEXTAREA':
return {
type: 'TEXTAREA',
+1 -1
View File
@@ -2,7 +2,7 @@
import { type VariantProps, tv } from 'tailwind-variants';
export const alertVariants = tv({
base: 'ui:relative ui:grid ui:w-full ui:grid-cols-[0_1fr] ui:items-start ui:gap-y-0.5 ui:rounded-lg ui:border ui:px-4 ui:py-3 ui:text-sm ui:has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] ui:has-[>svg]:gap-x-3 ui:[&>svg]:size-4 ui:[&>svg]:translate-y-0.5 ui:[&>svg]:text-current',
base: 'ui:relative ui:grid ui:w-full ui:grid-cols-[0_1fr] ui:items-start ui:gap-y-0.5 ui:rounded-lg ui:border ui:px-4 ui:py-3 ui:text-sm ui:has-[>svg]:grid-cols-[calc(var(--ui-spacing)*4)_1fr] ui:has-[>svg]:gap-x-3 ui:[&>svg]:size-4 ui:[&>svg]:translate-y-0.5 ui:[&>svg]:text-current',
variants: {
variant: {
default: 'ui:bg-card ui:text-card-foreground',
@@ -72,10 +72,10 @@
position: absolute;
aspect-ratio: 1;
width: calc(var(--size) * 1px);
animation-delay: var(--delay);
background: linear-gradient(to left, var(--color-from), var(--color-to), transparent);
offset-anchor: calc(var(--anchor) * 1%) 50%;
offset-path: rect(0 auto auto 0 round calc(var(--size) * 1px));
animation: border-beam calc(var(--duration) * 1s) infinite linear;
animation-delay: var(--delay);
}
}
@@ -10,6 +10,7 @@ export { default as SectionNavigationSidebar } from './section-navigation-sideba
export { default as SectionOverview } from './section-overview.svelte';
export * from './renderer-contract';
export { default as ThumbsEdit } from './renderers/thumbs/edit.svelte';
export { default as TrueFalseEdit } from './renderers/true-false/edit.svelte';
export { default as ShortAnswerEdit } from './renderers/short-answer/edit.svelte';
export { default as NumericEdit } from './renderers/numeric/edit.svelte';
@@ -23,6 +24,7 @@ export { default as OrderingEdit } from './renderers/ordering/edit.svelte';
export { default as HotspotEdit } from './renderers/hotspot/edit.svelte';
export { default as VideoRecordingEdit } from './renderers/video-recording/edit.svelte';
export { default as ThumbsTake } from './renderers/thumbs/take.svelte';
export { default as TrueFalseTake } from './renderers/true-false/take.svelte';
export { default as ShortAnswerTake } from './renderers/short-answer/take.svelte';
export { default as NumericTake } from './renderers/numeric/take.svelte';
@@ -36,6 +38,7 @@ export { default as OrderingTake } from './renderers/ordering/take.svelte';
export { default as HotspotTake } from './renderers/hotspot/take.svelte';
export { default as VideoRecordingTake } from './renderers/video-recording/take.svelte';
export { default as ThumbsPreview } from './renderers/thumbs/preview.svelte';
export { default as TrueFalsePreview } from './renderers/true-false/preview.svelte';
export { default as ShortAnswerPreview } from './renderers/short-answer/preview.svelte';
export { default as NumericPreview } from './renderers/numeric/preview.svelte';
@@ -50,6 +53,7 @@ export { default as OrderingDisplay } from './renderers/ordering/ordering-displa
export { default as HotspotPreview } from './renderers/hotspot/preview.svelte';
export { default as VideoRecordingPreview } from './renderers/video-recording/preview.svelte';
export { default as ThumbsSubmission } from './renderers/thumbs/submission.svelte';
export { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte';
export { default as ShortAnswerSubmission } from './renderers/short-answer/submission.svelte';
export { default as NumericSubmission } from './renderers/numeric/submission.svelte';
@@ -63,3 +67,5 @@ export { default as OrderingSubmission } from './renderers/ordering/submission.s
export { default as HotspotSubmission } from './renderers/hotspot/submission.svelte';
export { default as LinkSubmission } from './renderers/link/submission.svelte';
export { default as VideoRecordingSubmission } from './renderers/video-recording/submission.svelte';
export { default as ThumbsReview } from './renderers/thumbs/review.svelte';
@@ -31,6 +31,8 @@
questionNumber?: number;
/** When false, the badge uses muted styling (e.g. question lists). Defaults to true. */
questionNumberActive?: boolean;
/** Shown beneath the title input in edit mode when validation fails. */
titleError?: string | null;
}
let {
@@ -40,7 +42,8 @@
showContainer = true,
questionTypeSelect,
questionNumber,
questionNumberActive = true
questionNumberActive = true,
titleError = null
}: Props = $props();
const Renderer = $derived(getExerciseQuestionRenderer(contract.question.questionType, contract.mode));
@@ -280,8 +283,12 @@
value={contract.question.title}
placeholder={label('question.edit.title_placeholder')}
disabled={contract.disabled}
aria-invalid={titleError ? true : undefined}
onchange={(event) => patchQuestion({ title: event.currentTarget.value })}
/>
{#if titleError}
<p class="ui:text-destructive ui:text-xs">{titleError}</p>
{/if}
</div>
<DropdownMenu.Root>
@@ -22,6 +22,7 @@ import { default as WordBankEdit } from './renderers/word-bank/edit.svelte';
import { default as ShortAnswerEdit } from './renderers/short-answer/edit.svelte';
import { default as StarEdit } from './renderers/star/edit.svelte';
import { default as TextareaEdit } from './renderers/textarea/edit.svelte';
import { default as ThumbsEdit } from './renderers/thumbs/edit.svelte';
import { default as TrueFalseEdit } from './renderers/true-false/edit.svelte';
import { default as VideoRecordingEdit } from './renderers/video-recording/edit.svelte';
@@ -38,6 +39,7 @@ import { default as WordBankTake } from './renderers/word-bank/take.svelte';
import { default as ShortAnswerTake } from './renderers/short-answer/take.svelte';
import { default as StarTake } from './renderers/star/take.svelte';
import { default as TextareaTake } from './renderers/textarea/take.svelte';
import { default as ThumbsTake } from './renderers/thumbs/take.svelte';
import { default as TrueFalseTake } from './renderers/true-false/take.svelte';
import { default as VideoRecordingTake } from './renderers/video-recording/take.svelte';
@@ -54,6 +56,7 @@ import { default as WordBankPreview } from './renderers/word-bank/preview.svelte
import { default as ShortAnswerPreview } from './renderers/short-answer/preview.svelte';
import { default as StarPreview } from './renderers/star/preview.svelte';
import { default as TextareaPreview } from './renderers/textarea/preview.svelte';
import { default as ThumbsPreview } from './renderers/thumbs/preview.svelte';
import { default as TrueFalsePreview } from './renderers/true-false/preview.svelte';
import { default as VideoRecordingPreview } from './renderers/video-recording/preview.svelte';
@@ -70,6 +73,7 @@ import { default as WordBankSubmission } from './renderers/word-bank/submission.
import { default as ShortAnswerSubmission } from './renderers/short-answer/submission.svelte';
import { default as StarSubmission } from './renderers/star/submission.svelte';
import { default as TextareaSubmission } from './renderers/textarea/submission.svelte';
import { default as ThumbsSubmission } from './renderers/thumbs/submission.svelte';
import { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte';
import { default as VideoRecordingSubmission } from './renderers/video-recording/submission.svelte';
@@ -80,6 +84,7 @@ import { default as ShortAnswerReview } from './renderers/short-answer/review.sv
import { default as FillBlankReview } from './renderers/fill-blank/review.svelte';
import { default as WordBankReview } from './renderers/word-bank/review.svelte';
import { default as OrderingReview } from './renderers/ordering/review.svelte';
import { default as ThumbsReview } from './renderers/thumbs/review.svelte';
import { default as TrueFalseReview } from './renderers/true-false/review.svelte';
type SharedRendererComponent = Component<ExerciseQuestionRendererProps>;
@@ -112,6 +117,13 @@ export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry<Share
submission: TrueFalseSubmission,
review: TrueFalseReview
},
[QUESTION_TYPE_KEY.THUMBS]: {
edit: ThumbsEdit,
take: ThumbsTake,
preview: ThumbsPreview,
submission: ThumbsSubmission,
review: ThumbsReview
},
[QUESTION_TYPE_KEY.SHORT_ANSWER]: {
edit: ShortAnswerEdit,
take: ShortAnswerTake,
@@ -0,0 +1,63 @@
<script lang="ts">
import {
getExerciseQuestionLabel,
type ExerciseQuestionModel,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import { Input } from '../../../../base/input';
import { ensureThumbsOptions, getThumbsOptionLabel } from './thumbs-options';
let { question, disabled = false, labels, onQuestionChange = () => {} }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const defaultYesLabel = $derived(label('thumbs.yes_label', 'Yes'));
const defaultNoLabel = $derived(label('thumbs.no_label', 'No'));
const yesLabel = $derived(getThumbsOptionLabel(question.options, true, defaultYesLabel, defaultNoLabel));
const noLabel = $derived(getThumbsOptionLabel(question.options, false, defaultYesLabel, defaultNoLabel));
function patchQuestion(partial: Partial<ExerciseQuestionModel>) {
onQuestionChange({ ...question, ...partial });
}
function handleOptionLabelChange(value: boolean, nextLabel: string) {
const options = ensureThumbsOptions(question.options, defaultYesLabel, defaultNoLabel).map((option) => {
const optionValue =
String(option.value ?? '')
.trim()
.toLowerCase() === 'true';
if (optionValue !== value) return option;
return { ...option, label: nextLabel };
});
patchQuestion({ options });
}
</script>
<div class="ui:grid ui:gap-3 ui:sm:grid-cols-2">
<div class="ui:space-y-1.5">
<label class="ui:text-sm ui:font-medium" for={`${question.id}-thumbs-yes-label`}>
{label('thumbs.edit.positive_option_label')}
</label>
<Input
id={`${question.id}-thumbs-yes-label`}
value={yesLabel}
{disabled}
oninput={(event) => handleOptionLabelChange(true, event.currentTarget.value)}
/>
</div>
<div class="ui:space-y-1.5">
<label class="ui:text-sm ui:font-medium" for={`${question.id}-thumbs-no-label`}>
{label('thumbs.edit.negative_option_label')}
</label>
<Input
id={`${question.id}-thumbs-no-label`}
value={noLabel}
{disabled}
oninput={(event) => handleOptionLabelChange(false, event.currentTarget.value)}
/>
</div>
</div>
@@ -0,0 +1,11 @@
<script lang="ts">
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
let { labels }: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
</script>
<div class="ui:space-y-1">
<p class="ui:text-muted-foreground ui:text-sm">{label('thumbs.preview.helper')}</p>
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import ThumbsReviewToggle from './thumbs-review-toggle.svelte';
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
const studentValue = $derived.by(() => {
if (answer?.type !== 'THUMBS') return '';
return answer.value ? 'true' : 'false';
});
</script>
<ThumbsReviewToggle {labels} options={question.options} {studentValue} />
@@ -0,0 +1,93 @@
<script lang="ts">
import * as Chart from '../../../../base/chart';
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils';
let {
question,
submissions = [],
labels
}: Pick<ExerciseQuestionRendererProps, 'question' | 'submissions' | 'labels'> = $props();
function resolveBooleanOptionLabel(value: boolean): string {
const wanted = String(value);
const option = (question.options || []).find((item) => {
const optionValue = String(item.value || '')
.trim()
.toLowerCase();
return optionValue === wanted;
});
if (!option) return value ? 'Yes' : 'No';
return normalizeLabel(option.label || option.value, value ? 'Yes' : 'No');
}
const chartConfig = $derived({
responses: {
label: getSubmissionLabel(labels, 'submission.chart.responses', 'Responses'),
color: 'var(--chart-1)'
}
} satisfies Chart.ChartConfig);
const chartData = $derived.by(() => {
let yesCount = 0;
let noCount = 0;
let noAnswerCount = 0;
for (const submission of submissions || []) {
const answer = getAnswerForQuestion(submission, question);
if (!answer || answer.type !== 'THUMBS') {
noAnswerCount += 1;
continue;
}
if (answer.value) {
yesCount += 1;
} else {
noCount += 1;
}
}
const rows: Array<{ key: string; label: string; responses: number }> = [];
if (yesCount > 0) {
rows.push({
key: 'yes',
label: resolveBooleanOptionLabel(true),
responses: yesCount
});
}
if (noCount > 0) {
rows.push({
key: 'no',
label: resolveBooleanOptionLabel(false),
responses: noCount
});
}
if (noAnswerCount > 0) {
rows.push({
key: 'no-answer',
label: getSubmissionLabel(labels, 'submission.common.no_answer', 'No answer'),
responses: noAnswerCount
});
}
return withChartColors(rows);
});
</script>
{#if chartData.length > 0}
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full ui:flex-col ui:items-center">
<Chart.PieChart data={chartData} key="key" label="label" value="responses" c="color" />
<Chart.ChartLegend items={chartData.map((d) => ({ label: d.label, color: d.color, value: d.responses }))} />
</Chart.Container>
{:else}
<p class="ui:text-muted-foreground ui:text-sm">
{getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
</p>
{/if}
@@ -0,0 +1,47 @@
<script lang="ts">
import ThumbsDownIcon from '@lucide/svelte/icons/thumbs-down';
import ThumbsUpIcon from '@lucide/svelte/icons/thumbs-up';
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
import * as ToggleGroup from '../../../../base/toggle-group';
import { getThumbsOptionLabel } from './thumbs-options';
let {
question,
answer = null,
disabled = false,
labels,
onAnswerChange = () => {}
}: ExerciseQuestionRendererProps = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const defaultYesLabel = $derived(label('thumbs.yes_label', 'Yes'));
const defaultNoLabel = $derived(label('thumbs.no_label', 'No'));
const yesLabel = $derived(getThumbsOptionLabel(question.options, true, defaultYesLabel, defaultNoLabel));
const noLabel = $derived(getThumbsOptionLabel(question.options, false, defaultYesLabel, defaultNoLabel));
const selected = $derived.by(() => {
const val = answer?.type === 'THUMBS' ? answer.value : undefined;
if (typeof val === 'boolean') return val ? 'true' : 'false';
return '';
});
function update(value: string) {
if (value !== 'true' && value !== 'false') return;
onAnswerChange({ type: 'THUMBS', value: value === 'true' });
}
</script>
<div class="ui:space-y-2">
<ToggleGroup.Root type="single" value={selected} {disabled} variant="outline" onValueChange={update}>
<ToggleGroup.Item value="true" class="ui:min-w-28 ui:gap-2 ui:px-6">
<ThumbsUpIcon aria-hidden="true" class="custom ui:size-4" />
{yesLabel}
</ToggleGroup.Item>
<ToggleGroup.Item value="false" class="ui:min-w-28 ui:gap-2 ui:px-6">
<ThumbsDownIcon aria-hidden="true" class="custom ui:size-4" />
{noLabel}
</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
@@ -0,0 +1,56 @@
import type { ExerciseQuestionOption } from '@cio/question-types';
export function findThumbsOption(
options: ExerciseQuestionOption[] | undefined,
value: boolean
): ExerciseQuestionOption | undefined {
const wanted = value ? 'true' : 'false';
return (options ?? []).find((item) => {
if (item.deletedAt) return false;
return (
String(item.value ?? '')
.trim()
.toLowerCase() === wanted
);
});
}
export function getThumbsOptionLabel(
options: ExerciseQuestionOption[] | undefined,
value: boolean,
fallbackYes: string,
fallbackNo: string
): string {
const option = findThumbsOption(options, value);
const fallback = value ? fallbackYes : fallbackNo;
const label = option?.label?.trim();
return label || fallback;
}
export function ensureThumbsOptions(
existing: ExerciseQuestionOption[] | undefined,
defaultYesLabel: string,
defaultNoLabel: string
): ExerciseQuestionOption[] {
const timestamp = Date.now();
const yesOption = findThumbsOption(existing, true);
const noOption = findThumbsOption(existing, false);
return [
{
...(yesOption ?? { id: `${timestamp}-yes` }),
label: yesOption?.label?.trim() || defaultYesLabel,
value: 'true',
isCorrect: false
},
{
...(noOption ?? { id: `${timestamp}-no` }),
label: noOption?.label?.trim() || defaultNoLabel,
value: 'false',
isCorrect: false
}
];
}
@@ -0,0 +1,40 @@
<script lang="ts">
import ThumbsDownIcon from '@lucide/svelte/icons/thumbs-down';
import ThumbsUpIcon from '@lucide/svelte/icons/thumbs-up';
import {
getExerciseQuestionLabel,
type ExerciseQuestionLabels,
type ExerciseQuestionOption
} from '@cio/question-types';
import * as ToggleGroup from '../../../../base/toggle-group';
import { getThumbsOptionLabel } from './thumbs-options';
interface Props {
labels: ExerciseQuestionLabels | undefined;
options: ExerciseQuestionOption[] | undefined;
studentValue: string;
}
let { labels, options, studentValue }: Props = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const defaultYesLabel = $derived(label('thumbs.yes_label', 'Yes'));
const defaultNoLabel = $derived(label('thumbs.no_label', 'No'));
const yesLabel = $derived(getThumbsOptionLabel(options, true, defaultYesLabel, defaultNoLabel));
const noLabel = $derived(getThumbsOptionLabel(options, false, defaultYesLabel, defaultNoLabel));
</script>
<div class="ui:space-y-2">
<ToggleGroup.Root type="single" value={studentValue} disabled={true} variant="outline">
<ToggleGroup.Item value="true" class="ui:min-w-28 ui:gap-2 ui:px-6">
<ThumbsUpIcon aria-hidden="true" class="custom ui:size-4" />
{yesLabel}
</ToggleGroup.Item>
<ToggleGroup.Item value="false" class="ui:min-w-28 ui:gap-2 ui:px-6">
<ThumbsDownIcon aria-hidden="true" class="custom ui:size-4" />
{noLabel}
</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
@@ -48,6 +48,11 @@ export const QUESTION_TYPE_PICKER_EXERCISE_LABELS: ExerciseQuestionLabels = {
'true_false.false_label': 'False',
'true_false.edit.correct_answer_label': 'Correct answer',
'true_false.preview.correct_value_label': 'Correct value',
'thumbs.yes_label': 'Yes',
'thumbs.no_label': 'No',
'thumbs.edit.positive_option_label': 'Thumbs up label',
'thumbs.edit.negative_option_label': 'Thumbs down label',
'thumbs.preview.helper': 'Open-ended — students choose thumbs up or down.',
'short_answer.edit.instructions_placeholder': 'Optional instructions',
'short_answer.take.placeholder': 'Type your answer',
'short_answer.preview.helper': 'Free-text answer question.',
@@ -152,6 +152,22 @@ const QUESTION_TYPE_PICKER_ENTRIES: QuestionTypePickerEntry[] = [
}
}
},
{
key: QUESTION_TYPE_KEY.THUMBS,
displayLabel: 'Thumbs up/down',
description: 'Quick yes/no with thumbs icons — open-ended poll with customizable labels, no correct answer.',
demoQuestion: {
id: 'picker-thumbs',
key: 'picker-thumbs',
title: 'Would you recommend this lesson to a colleague who is new to the topic?',
questionType: 'THUMBS',
options: [
{ id: 14001, label: 'Yes', value: 'true', isCorrect: false },
{ id: 14002, label: 'No', value: 'false', isCorrect: false }
],
settings: {}
}
},
{
key: QUESTION_TYPE_KEY.SHORT_ANSWER,
displayLabel: 'Short answer',
@@ -9,6 +9,7 @@
import Link2Icon from '@lucide/svelte/icons/link-2';
import SquareCheckIcon from '@lucide/svelte/icons/square-check';
import StarIcon from '@lucide/svelte/icons/star';
import ThumbsUpIcon from '@lucide/svelte/icons/thumbs-up';
import UploadIcon from '@lucide/svelte/icons/upload';
import VideoIcon from '@lucide/svelte/icons/video';
import { QuestionRenderer } from '../exercise-question';
@@ -33,6 +34,8 @@
return Link2Icon;
case QUESTION_TYPE_KEY.STAR:
return StarIcon;
case QUESTION_TYPE_KEY.THUMBS:
return ThumbsUpIcon;
case QUESTION_TYPE_KEY.VIDEO_RECORDING:
return VideoIcon;
default:
+1
View File
@@ -56,6 +56,7 @@
@source inline('ui:z-menu-elevated{,!}');
:root {
--spacing: var(--ui-spacing);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@@ -25,7 +25,8 @@ const EXERCISE_QUESTION_TYPE_ID_LITERALS = [
z.literal(QUESTION_TYPE.LINK),
z.literal(QUESTION_TYPE.WORD_BANK),
z.literal(QUESTION_TYPE.STAR),
z.literal(QUESTION_TYPE.VIDEO_RECORDING)
z.literal(QUESTION_TYPE.VIDEO_RECORDING),
z.literal(QUESTION_TYPE.THUMBS)
] as const;
export const ZExerciseQuestionTypeId = z.union(EXERCISE_QUESTION_TYPE_ID_LITERALS);
@@ -36,6 +37,24 @@ type QuestionRuleInput = {
settings?: Record<string, unknown>;
};
function requireAtLeastTwoOptions(message: string) {
return (question: QuestionRuleInput): string | null => {
const options = question.options || [];
if (options.length > 0 && options.length < 2) {
return message;
}
return null;
};
}
function requireMarkedCorrectOption(question: QuestionRuleInput): string | null {
const options = question.options || [];
if (options.length > 0 && !options.some((opt) => opt.isCorrect === true)) {
return 'Please mark an option as the correct answer';
}
return null;
}
const QUESTION_VALIDATION_RULES: Record<number, Array<(question: QuestionRuleInput) => string | null>> = {
[QUESTION_TYPE.RADIO]: [
(question) => {
@@ -74,21 +93,10 @@ const QUESTION_VALIDATION_RULES: Record<number, Array<(question: QuestionRuleInp
}
],
[QUESTION_TYPE.TRUE_FALSE]: [
(question) => {
const options = question.options || [];
if (options.length > 0 && options.length < 2) {
return 'True/False questions need both True and False options';
}
return null;
},
(question) => {
const options = question.options || [];
if (options.length > 0 && !options.some((opt) => opt.isCorrect === true)) {
return 'Please mark an option as the correct answer';
}
return null;
}
requireAtLeastTwoOptions('True/False questions need both True and False options'),
requireMarkedCorrectOption
],
[QUESTION_TYPE.THUMBS]: [requireAtLeastTwoOptions('Thumbs up/down questions need both Yes and No options')],
[QUESTION_TYPE.TEXTAREA]: [],
[QUESTION_TYPE.WORD_BANK]: [
(question) => {
+52 -47
View File
@@ -624,7 +624,7 @@ importers:
version: 0.4.9([email protected](@astrojs/[email protected])(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])(@vercel/[email protected](@aws-sdk/[email protected])([email protected]([email protected])([email protected])))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@shikijs/twoslash':
specifier: 4.3.1
version: 4.3.1(typescript@5.9.3)
version: 4.3.1(typescript@6.0.3)
'@tailwindcss/typography':
specifier: 0.5.19
version: 0.5.19([email protected])
@@ -636,7 +636,7 @@ importers:
version: 7.0.9(@astrojs/[email protected])(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])(@vercel/[email protected](@aws-sdk/[email protected])([email protected]([email protected])([email protected])))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
blume:
specifier: 1.1.2
version: 1.1.2(4833f745f9fd8f626b4a7791f185a353)
version: 1.1.2(864af55737aed5e342af6da019f32ece)
mermaid:
specifier: ^11.16.0
version: 11.16.0
@@ -1256,6 +1256,9 @@ importers:
packages/mcp:
dependencies:
'@cio/question-types':
specifier: workspace:*
version: link:../question-types
'@cio/utils':
specifier: workspace:*
version: link:../utils
@@ -1348,7 +1351,7 @@ importers:
version: 10.1.10(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected])([email protected]([email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@storybook/sveltekit':
specifier: ^8.4.2
version: 8.6.15(@babel/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected]([email protected])([email protected](@types/[email protected])(typescript@5.9.3)))([email protected])([email protected])([email protected]([email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
version: 8.6.15(@babel/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected]([email protected])([email protected](@types/[email protected])(typescript@6.0.3)))([email protected])([email protected])([email protected]([email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@storybook/test':
specifier: ^8.4.2
version: 8.6.14([email protected]([email protected])([email protected])([email protected]))
@@ -1387,7 +1390,7 @@ importers:
version: 4.21.0
typescript-svelte-plugin:
specifier: ^0.3.50
version: 0.3.50([email protected])(typescript@5.9.3)
version: 0.3.50([email protected])(typescript@6.0.3)
vite:
specifier: ^7.1.4
version: 7.3.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
@@ -16049,10 +16052,10 @@ snapshots:
is-docker: 4.0.0
package-manager-detector: 1.7.0
'@astrojs/[email protected](@aws-sdk/[email protected])(@sveltejs/[email protected](@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@astrojs/[email protected])(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])(@vercel/[email protected](@aws-sdk/[email protected])([email protected]([email protected])([email protected])))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected](typescript@5.9.3))([email protected]([email protected])([email protected]))':
'@astrojs/[email protected](@aws-sdk/[email protected])(@sveltejs/[email protected](@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@astrojs/[email protected])(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])(@vercel/[email protected](@aws-sdk/[email protected])([email protected]([email protected])([email protected])))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected](typescript@6.0.3))([email protected]([email protected])([email protected]))':
dependencies:
'@astrojs/internal-helpers': 0.10.1
'@vercel/analytics': 1.6.1(@sveltejs/[email protected](@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected])([email protected](typescript@5.9.3))
'@vercel/analytics': 1.6.1(@sveltejs/[email protected](@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected])([email protected](typescript@6.0.3))
'@vercel/functions': 3.7.5(@aws-sdk/[email protected])([email protected]([email protected])([email protected]))
'@vercel/nft': 1.10.2([email protected])([email protected])
'@vercel/routing-utils': 5.3.3
@@ -20551,15 +20554,6 @@ snapshots:
'@shikijs/core': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/[email protected]([email protected])':
dependencies:
'@shikijs/core': 4.3.1
'@shikijs/types': 4.3.1
twoslash: 0.3.9([email protected])
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@shikijs/[email protected]([email protected])':
dependencies:
'@shikijs/core': 4.3.1
@@ -21219,7 +21213,7 @@ snapshots:
- rollup
- webpack
'@storybook/[email protected](@babel/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected]([email protected])([email protected](@types/[email protected])(typescript@5.9.3)))([email protected])([email protected])([email protected]([email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))':
'@storybook/[email protected](@babel/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected]([email protected])([email protected](@types/[email protected])(typescript@6.0.3)))([email protected])([email protected])([email protected]([email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))':
dependencies:
'@storybook/builder-vite': 8.6.15([email protected]([email protected])([email protected])([email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@storybook/svelte': 8.6.15([email protected]([email protected])([email protected])([email protected]))([email protected])
@@ -21227,7 +21221,7 @@ snapshots:
magic-string: 0.30.21
storybook: 8.6.15([email protected])([email protected])([email protected])
svelte: 5.46.0
svelte-preprocess: 5.1.4(@babel/[email protected])([email protected]([email protected])([email protected](@types/[email protected])(typescript@5.9.3)))([email protected])([email protected])([email protected])([email protected])
svelte-preprocess: 5.1.4(@babel/[email protected])([email protected]([email protected])([email protected](@types/[email protected])(typescript@6.0.3)))([email protected])([email protected])([email protected])([email protected])
svelte2tsx: 0.7.46([email protected])([email protected])
sveltedoc-parser: 4.2.1
ts-dedent: 2.2.0
@@ -21268,12 +21262,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@storybook/[email protected](@babel/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected]([email protected])([email protected](@types/[email protected])(typescript@5.9.3)))([email protected])([email protected])([email protected]([email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))':
'@storybook/[email protected](@babel/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected]([email protected])([email protected](@types/[email protected])(typescript@6.0.3)))([email protected])([email protected])([email protected]([email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))':
dependencies:
'@storybook/addon-actions': 8.6.15([email protected]([email protected])([email protected])([email protected]))
'@storybook/builder-vite': 8.6.15([email protected]([email protected])([email protected])([email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@storybook/svelte': 8.6.15([email protected]([email protected])([email protected])([email protected]))([email protected])
'@storybook/svelte-vite': 8.6.15(@babel/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected]([email protected])([email protected](@types/[email protected])(typescript@5.9.3)))([email protected])([email protected])([email protected]([email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@storybook/svelte-vite': 8.6.15(@babel/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected]([email protected])([email protected](@types/[email protected])(typescript@6.0.3)))([email protected])([email protected])([email protected]([email protected])([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
storybook: 8.6.15([email protected])([email protected])([email protected])
svelte: 5.46.0
vite: 7.3.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
@@ -23135,13 +23129,6 @@ snapshots:
'@typescript-eslint/types': 8.50.1
eslint-visitor-keys: 4.2.1
'@typescript/[email protected]([email protected])':
dependencies:
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript/[email protected]([email protected])':
dependencies:
debug: 4.4.3
@@ -23156,19 +23143,19 @@ snapshots:
d3-selection: 3.0.0
d3-transition: 3.0.1([email protected])
'@vercel/[email protected](@sveltejs/[email protected](@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected])([email protected](typescript@5.9.3))':
'@vercel/[email protected](@sveltejs/[email protected](@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected])([email protected](typescript@6.0.3))':
optionalDependencies:
'@sveltejs/kit': 2.49.2(@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
react: 19.2.3
svelte: 5.46.0
vue: 3.5.26(typescript@5.9.3)
vue: 3.5.26(typescript@6.0.3)
'@vercel/[email protected](@sveltejs/[email protected](@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected])([email protected](typescript@5.9.3))':
'@vercel/[email protected](@sveltejs/[email protected](@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected])([email protected](typescript@6.0.3))':
optionalDependencies:
'@sveltejs/kit': 2.49.2(@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
react: 19.2.3
svelte: 5.46.0
vue: 3.5.26(typescript@5.9.3)
vue: 3.5.26(typescript@6.0.3)
'@vercel/[email protected]':
dependencies:
@@ -24089,14 +24076,14 @@ snapshots:
[email protected]: {}
[email protected](4833f745f9fd8f626b4a7791f185a353):
[email protected](864af55737aed5e342af6da019f32ece):
dependencies:
'@astrojs/check': 0.9.9([email protected])([email protected])
'@astrojs/markdown-satteri': 0.3.4
'@astrojs/mdx': 7.0.3(@astrojs/[email protected])([email protected](@astrojs/[email protected])(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])(@vercel/[email protected](@aws-sdk/[email protected])([email protected]([email protected])([email protected])))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@astrojs/node': 11.0.2([email protected](@astrojs/[email protected])(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])(@vercel/[email protected](@aws-sdk/[email protected])([email protected]([email protected])([email protected])))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@astrojs/react': 6.0.1(@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])([email protected])
'@astrojs/vercel': 11.0.3(@aws-sdk/[email protected])(@sveltejs/[email protected](@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@astrojs/[email protected])(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])(@vercel/[email protected](@aws-sdk/[email protected])([email protected]([email protected])([email protected])))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected](typescript@5.9.3))([email protected]([email protected])([email protected]))
'@astrojs/vercel': 11.0.3(@aws-sdk/[email protected])(@sveltejs/[email protected](@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@astrojs/[email protected])(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])(@vercel/[email protected](@aws-sdk/[email protected])([email protected]([email protected])([email protected])))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected](typescript@6.0.3))([email protected]([email protected])([email protected]))
'@clack/prompts': 1.7.0
'@iconify-json/lucide': 1.2.117
'@iconify/types': 2.0.0
@@ -24111,7 +24098,7 @@ snapshots:
'@shikijs/twoslash': 4.3.1([email protected])
'@tailwindcss/typography': 0.5.20([email protected])
'@tailwindcss/vite': 4.1.18([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@vercel/analytics': 2.0.1(@sveltejs/[email protected](@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected])([email protected](typescript@5.9.3))
'@vercel/analytics': 2.0.1(@sveltejs/[email protected](@opentelemetry/[email protected])(@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])))([email protected])([email protected])([email protected](typescript@6.0.3))
ai: 5.0.212([email protected])
astro: 7.0.9(@astrojs/[email protected])(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])(@vercel/[email protected](@aws-sdk/[email protected])([email protected]([email protected])([email protected])))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
babel-plugin-react-compiler: 1.0.0
@@ -29174,13 +29161,13 @@ snapshots:
postcss: 8.5.6
ts-node: 10.9.2(@types/[email protected])([email protected])
[email protected]([email protected])([email protected](@types/[email protected])(typescript@5.9.3)):
[email protected]([email protected])([email protected](@types/[email protected])(typescript@6.0.3)):
dependencies:
lilconfig: 3.1.3
yaml: 2.8.2
optionalDependencies:
postcss: 8.5.19
ts-node: 10.9.2(@types/[email protected])(typescript@5.9.3)
ts-node: 10.9.2(@types/[email protected])(typescript@6.0.3)
optional: true
[email protected]([email protected])([email protected](@types/[email protected])([email protected])):
@@ -30658,7 +30645,7 @@ snapshots:
svelte: 5.46.0
tslib: 2.8.1
[email protected](@babel/[email protected])([email protected]([email protected])([email protected](@types/[email protected])(typescript@5.9.3)))([email protected])([email protected])([email protected])([email protected]):
[email protected](@babel/[email protected])([email protected]([email protected])([email protected](@types/[email protected])(typescript@6.0.3)))([email protected])([email protected])([email protected])([email protected]):
dependencies:
'@types/pug': 2.0.10
detect-indent: 6.1.0
@@ -30669,7 +30656,7 @@ snapshots:
optionalDependencies:
'@babel/core': 7.29.0
postcss: 8.5.19
postcss-load-config: 4.0.2([email protected])([email protected](@types/[email protected])(typescript@5.9.3))
postcss-load-config: 4.0.2([email protected])([email protected](@types/[email protected])(typescript@6.0.3))
sass: 1.97.1
typescript: 5.9.3
@@ -30745,6 +30732,13 @@ snapshots:
svelte: 5.46.0
typescript: 5.9.3
[email protected]([email protected])([email protected]):
dependencies:
dedent-js: 1.0.1
scule: 1.3.0
svelte: 5.46.0
typescript: 6.0.3
[email protected]:
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -31110,6 +31104,25 @@ snapshots:
yn: 3.1.1
optional: true
[email protected](@types/[email protected])([email protected]):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.12
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 24.10.4
acorn: 8.15.0
acorn-walk: 8.3.4
arg: 4.1.3
create-require: 1.1.1
diff: 4.0.2
make-error: 1.3.6
typescript: 6.0.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
optional: true
[email protected]:
dependencies:
chokidar: 3.6.0
@@ -31162,14 +31175,6 @@ snapshots:
[email protected]: {}
[email protected]([email protected]):
dependencies:
'@typescript/vfs': 1.6.4([email protected])
twoslash-protocol: 0.3.9
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
[email protected]([email protected]):
dependencies:
'@typescript/vfs': 1.6.4([email protected])
@@ -31238,10 +31243,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected]([email protected])(typescript@5.9.3):
[email protected]([email protected])(typescript@6.0.3):
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
svelte2tsx: 0.7.46([email protected])(typescript@5.9.3)
svelte2tsx: 0.7.46([email protected])(typescript@6.0.3)
transitivePeerDependencies:
- svelte
- typescript