Skip to content

Assessment Widget Conventions

These rules apply to every assessment widget — quizzes, checks, true/false, multiple choice, and timed-video tests. The goal is consistent learner UX, predictable authoring, and a clean migration path when new variants ship.

The reference implementation is packages/widget-renderers/src/components/assessment/true-or-false-widget.tsx (renderer) and apps/cms/src/components/widgets/assessment/true-or-false-widget.tsx (CMS editor).

1. Core UX rules

  • Reset / "Try Again" is horizontally centered. Post-submit actions row uses flex flex-col items-center gap-3 (single column, centered). Pre-submit row uses flex flex-wrap items-center justify-center gap-3. The Reset button must never be left-aligned beside the score.
  • Every assessment exposes a canTryAgain settingz.boolean().default(true) on the wire schema, surfaced as a <Switch> in the CMS editor, and read by the renderer to hide the Reset button when false. Default true preserves historical behavior for content authored before this rule.
  • Result pill is inline with the question. The Badge with data-testid="widget-<type>-<questionId>-result" lives on the same flex row as the question text — never as a sibling block below the answer controls.
  • Preview surface indicates the correct answer. When surface !== "lms-learner", the renderer must visually mark the correct answer's control with the success styling (see §2). Buttons remain disabled in preview.

2. Coloring

  • Correct = bg-success text-success-foreground hover:bg-success className override (on Badge or Button). There is no success Button or Badge variant — always use the className override.
  • Incorrect = variant="destructive" on Badge or Button (red is a first-class variant).
  • Score chip = <Badge variant="secondary" data-testid="widget-<type>-score">.
  • Selected pre-submit = variant="default". Unselected = variant="outline".

3. Renderer conventions

Files under packages/widget-renderers/src/components/assessment/:

  • Wrap content in <WidgetShell widget={widget} testId="widget-<type>">.
  • Coerce content with asContent / asArray / asBoolean / asNumber / asString from ../shared/utils — never trust raw widget.content.
  • Detect surface via const isLearner = surface === "lms-learner" from useWidgetRenderContext().
  • Show <EmptyState … testId="widget-<type>-empty" /> when no questions are authored.
  • Pull UI primitives from useWidgetUi() and strings from useWidgetT(). Do not import shadcn components directly in renderer packages — they live behind the adapter for host portability.
  • Submit Button is disabled until every question is answered (allAnswered).
  • After submit, every option control is disabled (disabled={!isLearner || submitted}).
  • Each question wrapper carries data-correct={submitted ? (isCorrect ? "true" : "false") : undefined}.
  • Hydration-safe randomize: shuffle inside useEffect(..., [widget.id, randomize, questions.length]), gated on a shuffledOrder state. Never call Math.random() during render. Keep the inline shuffle() helper and the explanatory comment block.

4. CMS editor conventions

Files under apps/cms/src/components/widgets/assessment/:

  • Default-export a component taking WidgetProps.
  • Use useWidgetContent<Content>(widget, contentSchema, onUpdate) — schemas imported from @/lib/validations/widget-content-schemas (re-exports from @open-learning-hub/widget-wire-schemas).
  • Translations via useTranslations("widgetEditor.<type>") plus useTranslations("widgetEditor.common").
  • Every author-facing string lives in all six locales: en, de, es, fr, pt, zh under apps/cms/messages/. Missing locales are a blocker.
  • Boolean settings render as <Switch> inside one settings strip: <div className="flex items-center gap-6 rounded-lg bg-muted p-3">, each switch wrapped in <div className="flex items-center gap-2">.
  • Questions render in <ItemAccordion testIdPrefix="widget-<type>-question">.
  • Add-question CTA = full-width outline Button with <Plus /> icon.
  • Title is rendered via a top <Input> inside <CardHeader> bound to widget.title.

5. Wire schema & registry conventions

  • All booleans declared as z.boolean().default(<sensible default>). The default must preserve prior behavior for already-authored rows.
  • Required assessment booleans: canTryAgain (default true). Add showFeedback (default true) and randomize (default false) when applicable to the widget's mechanics.
  • defaultContent in the registry must explicitly mirror every schema default — don't rely on Zod parsing to fill them in at create-time.
  • The schema map must include every boolean as { type: "boolean", label, default }.

6. Test coverage required

Per assessment widget:

  • Wire schema (apps/cms/src/lib/validations/__tests__/widget.test.ts): one "accepts valid content" case naming every field, plus one default-check per boolean.
  • CMS editor (apps/cms/src/components/widgets/assessment/__tests__/<type>-widget.test.tsx): preview render, edit render, every Switch covered (default-checked + toggle assertion), add/delete-question flow, invalid-content fallback.
  • Renderer (packages/widget-renderers/src/components/assessment/__tests__/<type>-widget.test.tsx): empty state, full learner flow (answer → submit → score → reset), feedback gated by surface, Reset hidden when canTryAgain: false, preview surface highlights the correct answer.

7. data-testid naming

  • Format: widget-<type>-... for widget-scoped controls and widget-<type>-<questionId>-... for per-question controls.
  • Always kebab-case, predictable, and unique within a render.
  • Tests rely on these — do not rename or restructure a testid without updating every test that references it in the same commit.

8. Known gap

  • quick-questions-video carries the canTryAgain schema field for forward-compatibility but does not yet expose an interactive submit/reset/score flow. Until that flow is built, the post-submit conventions (centered actions, inline pill, Reset gating) cannot apply at runtime. When the flow is added, it must conform to this rule from the start.