Skip to content

Add description documents to problem sets - #3026

Closed
dqnykamp wants to merge 9 commits into
Doenet:mainfrom
dqnykamp:feat/problem-set-descriptions
Closed

dqnykamp wants to merge 9 commits into
Doenet:mainfrom
dqnykamp:feat/problem-set-descriptions

Conversation

@dqnykamp

@dqnykamp dqnykamp commented Aug 15, 2026 •

Copy link
Copy Markdown
Member

Dependency: bumped to @doenet/assignment-viewer@0.1.0-alpha-18 (Doenet/assignment-viewer#49), which is published. That release fixes the item indexing descriptions depend on: it separates the document sequence from the scored-item sequence, so doenetStates, itemAttemptNumbers, and the reported item_updated are all keyed by scored item — which is what this app stores per-item state by. The full suite has been re-run against the published package.

Before deploying

Two manual steps, both of which will bite on prod if skipped. Full procedure, with the pre-flight results already gathered from prod, is in MANUAL_MIGRATION_PLAN.md.

1. Apply the migration out-of-band, before the image. ADD COLUMN on content cannot use instant DDL — its two FULLTEXT indexes force a full ALGORITHM=COPY rebuild, measured at 7 min 3 s on dev3 against a 5-minute deploy timeout. The deploy kills it mid-rebuild, and because MySQL DDL is not transactional the column commits while Prisma's bookkeeping does not, leaving _prisma_migrations unfinished. Every subsequent deploy then fails with P3009 — including a rollback to main — until someone runs prisma migrate resolve by hand. This already happened on dev3 (2026-08-15 02:30 UTC). Prod's content is larger, so expect longer, with writes blocked for the duration. Apply the migration on its own first, mark it applied, then deploy the image. Tracked in #3027.

2. Check for repeats on live assignments. SELECT COUNT(*) FROM content WHERE repeatInProblemSet > 1 — a problem set assigned before this deploy with a repeated document gains items on the next student load, and its gradebook columns shift with them. Reviewed on prod 2026-08-17: of 15, six are soft-deleted and three sit in assigned problem sets, all experimental — no reset needed. Re-check before deploying if time has passed. See the behavior-change note below.

Descriptions

A problem set had no way to include an introduction, a section header, or shared setup text — every child document was a scored, numbered problem.

The viewer already had the concept: SingleDocSource.isDescription marks a document as unscored and unnumbered, and it was implemented for question numbering, credit, shuffle anchoring, and the per-item attempt button. Nothing here could set it — both compilers hardcoded isDescription: false.

This adds:

  • content.isDescription column + migration (one line, matching 20250804195804_problem_set_repeat_doc)
  • the flag threaded through returnContentSelect → processContent → both compilers
  • isDescription on updateContentSettings, with the same three gates as repeatInProblemSet: the all-undefined no-op, the singleDoc whose parent is a sequence check, and the assigned-content guard. It is on the forbidden list when assigned, because changing it renumbers the items and existing attempts' stored itemNumbers would no longer line up.
  • getItemNames skips descriptions, so they take no gradebook column and no itemNumber in stored item state
  • both compilers honor isDescription and repeatInProblemSet only for a document whose parent is a problem set, and never repeat a description. The viewer throws outright on a select whose items include a description ("The case where a select contains a description is not implemented"), and both states are reachable from stored data: moveContent carries the flag when a description is dragged into a question bank, and the Repeat and Description controls could otherwise both be set on one document. The editor also hides the Repeat control on a description.
  • a Description checkbox on each document card in the problem set editor, disabled (but still showing its state) once the content is read-only, and a distinct icon (MdNotes, slate) so descriptions read as structurally different from the problems around them — shape and colour, not colour alone

Both the Description and Repeat controls now explain themselves. The text rides on aria-label, not only the tooltip, so it is announced on focus rather than being hover-only; the tooltips are controlled because Chakra binds its focus handlers to the Checkbox's label wrapper while focus lands on the inner input, so an uncontrolled tooltip never opens for a keyboard user. Both share one HoverFocusTooltip wrapper in Card.tsx that owns that behaviour and the overflow placement.

Bugs this uncovered

repeatInProblemSet was silently dropped almost everywhere. It was selected only when a caller passed includeRepeatInProblemSet: true, and most did not — including createContentRevision, so it vanished from stored revision sources and their cids. Only the compound-editor preview ever saw it. Both problem-set document settings are now selected unconditionally and the flag is deleted. isDescription never went behind it: dropping it would shift item numbering between preview and assignment, which is exactly the corruption this PR exists to avoid.

A repeat escaped the problem set. Both settings live on the document row and travel with it — moveContent and copyContent carry the whole row — so a document repeated in a problem set keeps repeatInProblemSet > 1 after being moved into a question bank. The server compiler's repeat branch was not scoped to a problem set, and once the field is selected unconditionally that compiles a select inside the bank, producing more items than the bank's numToSelect accounts for and than getItemNames labels. Both compilers now apply the repeat only to a direct child of a sequence, exactly as they do isDescription, and the editor offers the Repeat control only there (it always did the update call, which the server then rejected). The Repeat control is also disabled on read-only content, matching the Description checkbox — it was previously left editable on an assigned problem set, where every change failed with Cannot change assigned content.

A repeat could outlive its variants. The repeat is clamped to numVariants when it is set, but numVariants moves independently afterwards — editing the source down to fewer variants, or revertToRevision, which writes numVariants straight to the row. A stale repeat = 3 on a now-2-variant document asked the viewer for three distinct variants of two, and the editor shows the Repeat control only for multi-variant documents, so a document that fell to one variant could not be corrected at all. Both compilers now cap the repeat at numVariants at compile time, and getItemNames uses the same cap so the gradebook columns still match. The rule lives once, in packages/shared/src/logic/problemSetItems.ts as repeatCountInProblemSet — it also folds in the description rule, and both compilers and getItemNames are its only callers, so the item count derived for the gradebook cannot drift from the compiled activity.

Item names did not account for repeats. getItemNames produces the gradebook column labels indexed by itemNumber, so it must line up one-to-one with the items of the compiled activity. It expanded a question bank into one name per selection but gave a repeated document a single name — harmless while repeats were being dropped, wrong the moment they compile. It now expands a repeat the same way (Problem (1), Problem (2)), skips descriptions, and shares one repeatedItemNames helper across all three cases instead of three copies of the same loop. getAssignmentResponseStudent selects repeatInProblemSet, isDescription, and numVariants on its children so its own call site gets the same answer.

Students never saw repeats at all, regardless of data selection, because the client compiler had no repeat branch. It now wraps a repeated document in the same synthetic select the server builds, and the server now emits title on a document as the client already did — so every path compiles a problem set identically.

The title gap was closed on the server side deliberately: the server-compiled source only feeds revision cids, so no student's sourceHash changes. Removing title from the client instead would have invalidated saved state for every problem set.

Behavior change: problem sets with repeat > 1 now compile to more items than before, so existing attempts on such content will hash-mismatch and start fresh. repeat = 1 — the default, and the overwhelming majority — compiles byte-identically and is unaffected.

This reaches already-assigned problem sets too: assignments compile their source live, so a problem set assigned before this deploy with a repeated document gains items on the next student load, and its gradebook columns shift with them. The repeat is locked once assigned, so this only affects content whose repeat was set before it was assigned.

Dead code removed

activityJson was declared on QuestionBank and ProblemSet and read by four loaders, but the API never populated it — every one of those loaders always took the compileActivityFromContent fallback. Git history shows why: it began as a client-side local, and a later PR added the "stored revision source" fallback shape and the type field without ever implementing the server half.

Deleted the field and collapsed the four fallbacks. Kept the loader result key of the same name (that is the real compiled source) and RawViewer's by-cid branch, which parses a genuinely stored revision source and whose isActivitySource guard is load-bearing.

Editor crash fixed

CompoundActivityEditor shares one fetcher with its popups, and CopyContentAndReportFinish treats any 200 on it as its own copy result, reading data.newContentIds. A settings save landing there set newContentIds to undefined, which passes the === null guard and crashed the render on .length.

The copy modal is mounted whenever addTo !== null, which "Items from Explore" sets before navigating — so clicking a document's Description checkbox during that window crashed the editor. Found on dev3, where the navigation is slow enough to leave the window open; it does not reproduce locally.

Both settings updates now use their own fetcher, so their responses never reach the popups, and the copy modal accepts only a response actually carrying an array newContentIds — a guard that covers all six of its call sites. MoveCopyContent shared the same assumption and the same latent crash — reporting the action finished on any 200, and crashing on the copy path reading data.newContentIds.length. It could not use the same guard, because moveContent returns no payload and so a move's success is indistinguishable from any other 200. It now tracks whether it started the submission: a ref armed immediately before each submit, consumed in the effect, cleared whenever the modal opens.

Tests

  • npm test -w @doenet-tools/api — 387 pass. New: isDescription update rules including that false is a real value rather than a no-op; a description compiling into the activity without being a scored item; a regression test that repeatInProblemSet survives into the compiled activity from a plain getContent call (verified adversarially — with the select removed it fails with expected 'singleDoc' to deeply equal 'select'); a description with a repeat compiling to a plain document rather than a select; a repeat capped at numVariants after the document is edited down (in the compiled source and in the item names); a repeated document moved into a question bank compiling to a plain document; and an assignment-level test that the item names of a problem set of description + repeated problem + question bank come out as ["Problem (1)", "Problem (2)", "Bank"].
  • New apps/app/src/utils/activity.cy.tsx — 9 tests covering the client compiler: title and description flag, isDescription defaulting, the repeat wrapper's exact shape, that repeat = 1 still compiles to a bare singleDoc so existing source hashes are preserved, that a description is not repeated, that isDescription and repeatInProblemSet are both ignored outside a problem set, and that the repeat is capped at (and dropped below) numVariants. The server-side assertions mirror these and each suite's comments point at the other, so the twin compilers cannot drift silently.
  • New apps/app/src/widgets/Card.cy.tsx — 9 tests for the two problem-set controls. Description: absent unless the document is in a problem set, reports check and uncheck to the callback, disabled but still showing its state when there is no updater (assigned content), and the description icon's accessible name plus an axe pass. Repeat: absent outside a problem set, absent on a description, disabled but still showing its value without an updater, and reporting a new value to the callback.
  • New packages/shared/src/logic/problemSetItems.test.ts — 5 tests pinning repeatCountInProblemSet directly: the default of one, the plain repeat, the numVariants cap in both directions, the floor of one, and a description never repeating.

Each fix was checked adversarially: reverting the inProblemSet gate, the numVariants cap, the description rule, or the item-name expansion each makes exactly the intended test (and only that test) fail.

Verified end to end

Against a running dev stack, a problem set of description + one problem + one problem repeated 3× rendered:

Problem 1 | Problem 2 | Problem 3 | Problem 4   ::iframes=5

Five documents — the description (unnumbered) plus four numbered problems, with numbering continuing across the repeat. Before this, it was three documents and the repeat was dropped.

Not covered

The assigned-student persistence round trip (answer, reload, confirm the answer restores into the right problem) and the gradebook column count were not exercised against a real assignment. The viewer's own tests pin the numbering and reporting these depend on.

dqnykamp and others added 6 commits August 14, 2026 20:01
A problem set had no way to include an introduction, a section header,
or shared setup text: every child document was a scored, numbered
problem.

The viewer already had the concept — `SingleDocSource.isDescription`
marks a document as unscored and unnumbered — but nothing here could set
it, since both activity compilers hardcoded `isDescription: false`.

Add an `isDescription` column, thread it through both compilers and the
`updateContentSettings` mutation, and surface a toggle on each document
card in the problem set editor. `getItemNames` skips descriptions, so
they take no gradebook column and no `itemNumber` in stored item state.
Changing the flag is rejected once the content is assigned, since it
renumbers the items.

Also fix two things this uncovered.

`repeatInProblemSet` was selected only behind an opt-in flag that most
callers left off, so it silently vanished from every compiled activity
except the compound-editor preview — including stored revisions and
their cids. Both problem-set document settings are now selected
unconditionally, and the flag is gone; `isDescription` never had it,
since dropping it would shift item numbering between preview and
assignment.

The client compiler had no repeat branch at all, so students never saw
repeats regardless of how the data was selected. It now wraps a repeated
document in the same synthetic `select` the server builds, and the
server now emits `title` on a document as the client already did, so
every path compiles a problem set identically.

Delete the dead `activityJson` field. It was declared on `QuestionBank`
and `ProblemSet` and read by four loaders, but the API never populated
it — those loaders always took the `compileActivityFromContent`
fallback. The loader result key of the same name, and RawViewer's
by-cid branch that parses a genuinely stored revision source, are
unchanged.

Requires @doenet/assignment-viewer 0.1.0-alpha-18, which fixes the item
indexing that descriptions depend on. Do not merge before that is
published and the dependency bumped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2
`getItemNames` labels the gradebook columns indexed by `itemNumber`, so it
must line up one-to-one with the items of the compiled activity. It expanded
a question bank into one name per selection but gave a repeated document a
single name — harmless while repeats were being dropped from compiled
activities, wrong now that they survive. Expand repeats the same way, and
share one helper across the three cases. `getAssignmentResponseStudent` now
selects `repeatInProblemSet` on its children so its call site agrees.

Also: treat `repeatInProblemSet: 0` as a value to clamp rather than as
"absent", extract the shared hover/focus tooltip in `Card.tsx`, and add
component tests for the Description control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2
The assignment viewer throws on a select whose items include a
description, and two reachable states produced one: a document with both
the Description checkbox and Repeat set, and a description moved into a
question bank, which keeps the flag.

Both compilers now honor isDescription only when the document's parent is
a problem set, and never wrap a description in the repeat select. The
editor hides the Repeat control on a description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2
Both problem-set document settings live on the document row and travel with
it when the document is moved or copied, so the compilers cannot assume they
are absent outside a problem set: a repeated document moved into a question
bank compiled to a select inside the bank, producing more items than the
bank's numToSelect or the gradebook's columns account for. Apply the repeat
only to a direct child of a sequence, as isDescription already is, and offer
the Repeat control only there.

numVariants also moves independently of the repeat that was clamped against
it, via a source edit or revertToRevision, and the editor hides the repeat
control once a document has a single variant. Cap the repeat at numVariants
at compile time, sharing repeatCountInProblemSet between the compiler and
getItemNames so the gradebook columns keep matching the items.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2
…-only

Move `repeatCountInProblemSet` into `packages/shared` so the two activity
compilers and `getItemNames` derive the item count from one definition
instead of twinned copies, and pin it with unit tests.

Disable the Repeat control on read-only content, as the Description
checkbox already is: the server rejects either change once the content is
assigned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2
That release separates the document sequence from the scored item
sequence, so `doenetStates`, `itemAttemptNumbers`, and the reported
`item_updated` are keyed by scored item — the numbering this app stores
per-item state by. Descriptions depend on it: without it, item state for
a problem set containing one is written to the wrong item.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2
@dqnykamp

Copy link
Copy Markdown
Member Author

/deploy-dev

@github-actions

Copy link
Copy Markdown

❌ dev3 deploy of refs/pull/3026/merge failed (backend: failure, frontend: success, site: success). See the run log.

1 similar comment
@github-actions

Copy link
Copy Markdown

❌ dev3 deploy of refs/pull/3026/merge failed (backend: failure, frontend: success, site: success). See the run log.

@dqnykamp

Copy link
Copy Markdown
Member Author

/deploy-dev

@github-actions

Copy link
Copy Markdown

✅ Deployed refs/pull/3026/merge to dev3 → https://dev3.doenet.org
(first page load wakes it if the lights are off).

dev3 is shared: the next push to main overwrites this. To restore main:
gh workflow run dev-deploy.yml --ref main -f deploy_ref=main

dqnykamp and others added 2 commits August 17, 2026 16:39
`CompoundActivityEditor` shares one fetcher with its popups, and
`CopyContentAndReportFinish` treats any 200 on it as its own copy
result, reading `data.newContentIds`. A settings save landing there set
`newContentIds` to `undefined`, which passes the `=== null` guard and
crashes the render on `.length`.

The copy modal is mounted whenever `addTo !== null`, which "Items from
Explore" sets before navigating — so clicking a document's Description
checkbox during that window crashed the editor. Only reproducible where
the navigation is slow enough to leave the window open.

The Description checkbox is on every document card, which made this easy
to hit; `updateRepeatInProblemSet` shared the same exposure but needs a
multi-variant document to appear.

Route both settings updates through their own fetcher so their responses
never reach the popups, and have the copy modal accept only a response
that actually carries an array `newContentIds` — that guard covers all
six of its call sites.

`MoveCopyContent` has the same assumption and the same latent crash. It
is no longer reachable from a settings save, and is left alone here:
distinguishing its own response needs the Move action's shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2
`MoveCopyContent` treated any 200 on the shared fetcher as its own
result: it reported the action finished, and on the copy path read
`data.newContentIds.length`, crashing when the response was another
submission's. Same shape as the crash in `CopyContentAndReportFinish`.

That modal could recognize its own response by the presence of
`newContentIds`, but `moveContent` returns no payload, so a move's
success is indistinguishable from any other 200. Track who started the
submission instead: arm a ref immediately before each submit, consume it
in the effect, and clear it whenever the modal opens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2
@dqnykamp

Copy link
Copy Markdown
Member Author

/deploy-dev

@github-actions

Copy link
Copy Markdown

✅ Deployed refs/pull/3026/merge to dev3 → https://dev3.doenet.org
(first page load wakes it if the lights are off).

dev3 is shared: the next push to main overwrites this. To restore main:
gh workflow run dev-deploy.yml --ref main -f deploy_ref=main

`ADD COLUMN` on `content` is a full table rebuild — its two FULLTEXT
indexes rule out instant DDL — measured at 7 min 3 s on dev3 against a
5-minute deploy timeout. Letting the deploy run it kills the migration
partway, and because MySQL DDL is not transactional that leaves Prisma's
bookkeeping unfinished and blocks every later deploy, rollback included.

Records the procedure for applying it out-of-band instead: the
pre-flight checks and their results on prod, the SQL, the
`_prisma_migrations` row with a checksum verified against the one Prisma
wrote on dev3, the verification gate, and how to back out.

Also records the review of documents with `repeatInProblemSet > 1`,
which this PR makes take effect for students for the first time: of 15,
six are soft-deleted and three sit in assigned problem sets, all
experimental, so no reset is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3bUTUym3xhuXLegDyTNZ2
@dqnykamp
dqnykamp requested a review from cqnykamp August 18, 2026 13:21
@cqnykamp

Copy link
Copy Markdown
Contributor

Nice. The AI-written PR body is a bit hard to follow, but I think I got the idea.

There are really 3 separate changes here, and I would split them into separate PRs.

  1. Repeat bug fixes — the five bugs under "Bugs this uncovered"
  2. Descriptions — the actual feature
  3. activityJson dead code removal — standalone; touches four loaders and two API files with no dependency on either of the above

This is especially true since Descriptions has to be deployed strangely.

I'm closing this PR, but feel free to create new PRs for each part.

@cqnykamp

Copy link
Copy Markdown
Contributor

Split into seven PRs, with backend and frontend separated per expand-migrate-contract. Each is safe to deploy on its own.

Open now (independent of each other, each shows only its own diff):

PR Scope
#3030 Remove the dead activityJson field
#3031 Fetcher crash fixes — bugs on main today
#3032 Repeat fixes — API, getItemNames, server compiler
#3033 content.isDescription column + migration runbook only

Held until their parent merges, branches pushed to cqnykamp:

Branch Needs
split/repeat-fixes-frontend #3032 (imports the shared repeatCountInProblemSet)
split/descriptions-backend #3033 (needs the column)
split/descriptions-frontend both of the above

Deploy order and the two manual steps: the pre-flight SELECT COUNT(*) FROM content WHERE repeatInProblemSet > 1 belongs to #3032, and the out-of-band migration belongs to #3033. Splitting them means those two risks — student attempts resetting, and a P3009 that wedges rollback — get separate go/no-go decisions instead of one, and neither drags the other back on a revert.

The split is content-identical to this branch. The only deltas: two mock payloads in the popup tests now use repeatInProblemSet instead of isDescription, since the fetcher fixes land before the description feature exists, and the runbook's PR references were reworded.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants