Skip to content

feat(marketplace): hands-on preview in a shared sandbox course - #8529

Open
LWS49 wants to merge 8 commits into
masterfrom
lws49/feat-marketplace-pr8a-preview-sandbox
Open

feat(marketplace): hands-on preview in a shared sandbox course#8529
LWS49 wants to merge 8 commits into
masterfrom
lws49/feat-marketplace-pr8a-preview-sandbox

Conversation

@LWS49

@LWS49 LWS49 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the hands-on half of marketplace preview. Until now a teacher evaluating a listing could only read its questions; there was no way to answer one, submit it, or grade and publish it before committing to a duplicate. "Try it hands-on" on a listing preview page now opens the listing's served snapshot as an ordinary submission inside a single shared sandbox course on the dedicated preview instance, where the previewer can rehearse the student-and-grader loop end to end.

Everything that makes that safe keys off the preview flag on the sandbox course, never off an instance id: content is frozen, the sidebar and breadcrumbs are de-linked, a persistent banner says nothing here is real, graded-submission emails and external-LMS task completion are suppressed, and each previewer can reach only their own submission - not the sandbox's other submissions, its gradebook, or its roster. A "Reset submission" button in the banner is the explicit way to start over.

This is the first of two PRs split out of #8527. It stops at a working preview flow; #8527's remaining pieces (auto-marking refresh, the preview publish toast, the not-found page, and the attempt TTL reaper) follow in pr8b.

Design decisions

  • A preview rehearses the listing's current version snapshot, not a fresh per-preview copy. That snapshot already lives in the container course and is the same row a duplicate hands an adopter, so the preview is faithful by construction and cannot drift from what gets adopted. Duplicating again per preview is precisely what would reintroduce that drift, so the launch service only enrols the previewer and builds the attempt URL.
  • One sandbox course shared by every previewer, with previewers enrolled as manager - the lowest stock role that can attempt, grade and publish, since there is no grade-only role. Sharing one course means a manager's ordinary reads would otherwise reach every other previewer's submission, so the preview ability revokes submission management, the submissions list, the gradebook and the roster wholesale, then carves back exactly the previewer's own submission by creator_id.
  • The sandbox lock is an allow-list, not a subtractive one. A previewer on the preview instance may reach only what a controller explicitly claims via preview_sandbox_accessible?, mirroring how publicly_accessible? marks out the unauthenticated surface. A subtractive list can never be complete - it has to name every component, every custom verb and every collection action, and each new one arrives switched on.
  • Self-service reset is its own verb, :reset_own_preview_submission, rather than a creator-scoped carve-out of :delete_submission, which CanCan cannot express alongside a blanket cannot on the same action. The endpoint never accepts a submission id from the client - the row is always looked up by (assessment, current user) - and it is update, not destroy, so the submission keeps its id and the previewer watches it come back blank instead of being redirected away.

Regression prevention

Backend covers: launch authorization, including a published listing with no snapshot being denied rather than crashing; enrolment and attempt-URL construction on the preview host, and its idempotency per (listing, previewer); the reset endpoint accepting only the caller's own submission, only inside a preview course, and clearing answers back to genuinely blank with no past-answer or test-result residue; cross-previewer denial of another previewer's submission, plus denial of the submissions list, gradebook and roster inside the sandbox; and graded-submission email and external-LMS completion suppression.

Frontend covers: the launch button's new-tab flow, popup-blocked fallback and failure toast; the reset button being absent where the URL carries no assessment id, and re-hydrating the submission page in place when it does; the sandbox banner and the isPreview gate that mounts it; and breadcrumbs rendering inert.

Manually verified: launch into the sandbox on the preview host and the popup-blocked fallback; banner, inert breadcrumbs and unlinked sidebar; reset clearing answers in place with the button absent off assessment pages; a second previewer blocked from the first's submission, the submissions list, the gradebook and the roster; no graded email or external-LMS push from the sandbox; and a published listing with no snapshot returning 403.

Nothing changes for existing courses: every behaviour here is gated on the sandbox course's preview flag. The sidebar partial's link-or-not argument now carries that flag alongside the read check.

@LWS49
LWS49 force-pushed the lws49/feat-marketplace-pr7c-versioning-admin branch from 7524d9d to ee1f875 Compare August 1, 2026 05:31
@LWS49
LWS49 force-pushed the lws49/feat-marketplace-pr8a-preview-sandbox branch from 96c3763 to e0c59f1 Compare August 1, 2026 05:31
@LWS49
LWS49 force-pushed the lws49/feat-marketplace-pr7c-versioning-admin branch 6 times, most recently from 9676d6b to 95710d0 Compare August 2, 2026 11:36
Base automatically changed from lws49/feat-marketplace-pr7c-versioning-admin to lws49/feat-marketplace-pr1-foundation August 2, 2026 14:04
@LWS49
LWS49 force-pushed the lws49/feat-marketplace-pr1-foundation branch 2 times, most recently from bbd43f0 to 6eac0c4 Compare August 2, 2026 16:44
Base automatically changed from lws49/feat-marketplace-pr1-foundation to master August 2, 2026 17:19
LWS49 added 8 commits August 3, 2026 11:26
Adds `PreviewLaunchService` and the `launch_preview` endpoint: a previewer is
enrolled as a manager of the singleton container course and handed the attempt
url for the listing's served snapshot, on the preview host. No copy step — the
snapshot the marketplace already serves is what a duplicate would give an
adopter, so a preview cannot drift from the adopted copy.

Also adds `Listing.serving_assessment?`, which the sandbox lock uses to tell a
handed-out snapshot from the rest of the container, and moves the container's
description into locales so the sandbox explainer can be written as HTML.
A rehearsal in the sandbox must not reach the outside world. Guarded at the
three writers rather than their call sites, so a fourth cannot reintroduce it:

- no graded-submission email (the previewer grades and publishes their own
  work, so it would land in their own inbox for a fake grade),
- no task completion pushed to the previewer's real external LMS,
- no `pending_staff_reply` flag, which in a container with no staff only ever
  surfaces as a nag on the Pending tab and the Comments badge.
Both crumb handles on any assessment page fetch `show`, and so does the page
itself. One endpoint serving both means a breadcrumb costs the page's ~50
queries to render two strings — and made the marketplace sandbox's crumb
allowance a licence to read the whole authoring surface.

`show` now renders `crumb` for a `?crumb=true` request, skipping the two
`before_action`s the full payload needs. Split on the request rather than on
the viewer, so the payload stays the same for everyone.
A previewer is enrolled as a `manager` of the shared container course — the
lowest role that can attempt, grade and publish. On the ordinary abilities that
is also a licence to read and edit the whole course, and a subtractive
allow-list can never be complete: it has to name every component, every custom
verb and every collection action, and each new one arrives switched on.

`ApplicationPreviewSandboxConcern` inverts it. On the preview instance a
non-administrator reaches only what a controller explicitly claims via
`preview_sandbox_accessible?`, mirroring how `publicly_accessible?` marks out
the unauthenticated surface. A component added tomorrow is denied without
anyone remembering to deny it.

Alongside it, `restrict_preview_course_reads` revokes the manager grants that
would otherwise reach another previewer's submission, the aggregate gradebook
and the roster of everyone who has ever previewed here. The lock is
per-VIEWER: a system administrator curates the container from inside it, so
the sandbox stays navigable for them.
The sandbox exists to rehearse the one assessment the previewer opened, and
the lock denies everything else — so leaving live links around only offers
them a 403. Sidebar items and breadcrumbs render as inert text instead.

Home is withheld separately: it is the one sidebar entry that does not come
from the `sidebar` payload, so the jbuilder's de-link never reached it.

Gated on `isPreviewRestricted`, not `isPreview`: a system administrator
curates the container from inside it and keeps working links. The same flag
also keeps them off the sidebar's member identity, which they only hold there
because launching a preview enrolled them.
`unique_assessment_id_and_creator_id` allows one submission per (assessment,
previewer), and launching a preview resumes it rather than resetting it — so
without this, starting over means waiting for the TTL.

`Submission#reset_preview!` clears every answer, not just the current ones: a
previewer who finalises and unsubmits accumulates Past Answers, which is
exactly the trace a reset is meant to erase. It does not reuse `unsubmit`'s
`recreate_current_answers`, which preserves in-progress drafts, copies the old
content forward as `last_attempt`, and keeps the old rows as history — all
correct for a real course, all wrong here.

The endpoint never accepts a submission id from the client: the row is always
looked up by (assessment, creator), which makes resetting someone else's
attempt structurally impossible before the ability check even runs.
Opens the sandbox in a new tab from a listing's preview page, behind a
confirmation that says what will happen: a separate sandbox, possibly a
sign-in redirect, and no effect on the viewer's own course.

`navigateTo` exists as a seam, not an abstraction: jsdom seals the whole
navigation surface, so a component that assigns `location.href` inline has no
assertable behaviour and its test passes whether or not the branch still
exists.
Says what the sandbox is on the page a previewer is actually landed on, and
hosts the Reset submission button there.

Mounted by `CourseContainer` because the flag lives on the layout payload, the
only data available on every course page — but it narrows itself to submission
pages: the promise it makes is about work a previewer produces, and the action
it hosts has nothing to act on elsewhere.

Gated on `isPreview`, not `isPreviewRestricted`: "nothing here is real" is a
fact about the course, equally worth saying to an administrator, who also
needs the Reset action. Only the LOCK is per-viewer.
@LWS49
LWS49 force-pushed the lws49/feat-marketplace-pr8a-preview-sandbox branch from e0c59f1 to 381bf19 Compare August 3, 2026 05:08
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.

1 participant