From b7e3fb42addad26c2d6ec014d9f109055d72e338 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 24 Jul 2026 00:05:02 +0800 Subject: [PATCH 1/9] feat(marketplace): preview-context singleton for API routing --- .../__test__/previewAttemptContext.test.ts | 24 +++++++++++++++++++ .../Assessment/previewAttemptContext.ts | 22 +++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 client/app/api/course/Assessment/__test__/previewAttemptContext.test.ts create mode 100644 client/app/api/course/Assessment/previewAttemptContext.ts diff --git a/client/app/api/course/Assessment/__test__/previewAttemptContext.test.ts b/client/app/api/course/Assessment/__test__/previewAttemptContext.test.ts new file mode 100644 index 0000000000..3648d4f6e6 --- /dev/null +++ b/client/app/api/course/Assessment/__test__/previewAttemptContext.test.ts @@ -0,0 +1,24 @@ +import { + clearActivePreview, + getActivePreview, + setActivePreview, +} from '../previewAttemptContext'; + +describe('previewAttemptContext', () => { + afterEach(() => clearActivePreview()); + + it('is null by default', () => { + expect(getActivePreview()).toBeNull(); + }); + + it('returns the id after setActivePreview', () => { + setActivePreview(42); + expect(getActivePreview()).toBe(42); + }); + + it('returns null after clearActivePreview', () => { + setActivePreview(42); + clearActivePreview(); + expect(getActivePreview()).toBeNull(); + }); +}); diff --git a/client/app/api/course/Assessment/previewAttemptContext.ts b/client/app/api/course/Assessment/previewAttemptContext.ts new file mode 100644 index 0000000000..5b8fa194c6 --- /dev/null +++ b/client/app/api/course/Assessment/previewAttemptContext.ts @@ -0,0 +1,22 @@ +/** + * Module-level marker for "the current page is a marketplace preview attempt". + * + * The reused submission API (`SubmissionsAPI`, `ScribingsAPI`) builds its URLs from + * the browser path. A preview URL (`/courses/:c/marketplace/attempt/:id`) has no + * `/assessments/:aid` segment, so the API must be told to route to the shallow + * `/marketplace/attempt/...` endpoints instead. This singleton is that signal. + * + * It is a plain module variable (not React context) because the API layer runs + * outside React and cannot read context. + */ +let activePreviewAttemptId: number | null = null; + +export const setActivePreview = (attemptId: number): void => { + activePreviewAttemptId = attemptId; +}; + +export const clearActivePreview = (): void => { + activePreviewAttemptId = null; +}; + +export const getActivePreview = (): number | null => activePreviewAttemptId; From c497f0e79d9fc968dc4dcdf87a29b6e3f310b947 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 24 Jul 2026 00:06:46 +0800 Subject: [PATCH 2/9] feat(marketplace): route reused submission API to preview endpoints --- .../Assessment/Submission/Answer/Scribing.js | 8 ++ .../app/api/course/Assessment/Submissions.js | 14 ++ .../__test__/previewRouting.test.ts | 136 ++++++++++++++++++ client/app/api/course/Marketplace.ts | 6 + 4 files changed, 164 insertions(+) create mode 100644 client/app/api/course/Assessment/__test__/previewRouting.test.ts diff --git a/client/app/api/course/Assessment/Submission/Answer/Scribing.js b/client/app/api/course/Assessment/Submission/Answer/Scribing.js index 7f96493a60..8f61cefd72 100644 --- a/client/app/api/course/Assessment/Submission/Answer/Scribing.js +++ b/client/app/api/course/Assessment/Submission/Answer/Scribing.js @@ -1,3 +1,5 @@ +import { getActivePreview } from '../../previewAttemptContext'; + import BaseAssessmentAPI from '../../Base'; export default class ScribingsAPI extends BaseAssessmentAPI { @@ -12,6 +14,12 @@ export default class ScribingsAPI extends BaseAssessmentAPI { } get #urlPrefix() { + // See Submissions.js#urlPrefix. In preview the path is keyed on the attempt id + // (the submission base-record id), which the browser path does not expose, so it + // comes from the singleton instead of `this.submissionId` (null on preview URLs). + if (getActivePreview() !== null || this.assessmentId === null) { + return `/courses/${this.courseId}/marketplace/attempt/${getActivePreview()}/answers`; + } return `/courses/${this.courseId}/assessments/${this.assessmentId}/submissions/${this.submissionId}/answers`; } } diff --git a/client/app/api/course/Assessment/Submissions.js b/client/app/api/course/Assessment/Submissions.js index 20c2bc7033..403ad77df0 100644 --- a/client/app/api/course/Assessment/Submissions.js +++ b/client/app/api/course/Assessment/Submissions.js @@ -1,3 +1,5 @@ +import { getActivePreview } from './previewAttemptContext'; + import BaseAssessmentAPI from './Base'; export default class SubmissionsAPI extends BaseAssessmentAPI { @@ -122,6 +124,10 @@ export default class SubmissionsAPI extends BaseAssessmentAPI { return this.client.post(`${this.#urlPrefix}/${submissionId}/auto_grade`); } + reset(submissionId) { + return this.client.post(`${this.#urlPrefix}/${submissionId}/reset`); + } + reevaluateAnswer(submissionId, params) { return this.client.post( `${this.#urlPrefix}/${submissionId}/reevaluate_answer`, @@ -198,6 +204,14 @@ export default class SubmissionsAPI extends BaseAssessmentAPI { } get #urlPrefix() { + // Preview routing triggers on EITHER the explicit context OR the absence of an + // assessment id in the URL. The only reuse of this API on a non-/assessments/:id + // URL is the marketplace preview page, so `this.assessmentId === null` is a robust + // fallback that survives a stray poller firing after the singleton was cleared: + // it routes to /marketplace/attempt/... rather than ever emitting /assessments/null/... + if (getActivePreview() !== null || this.assessmentId === null) { + return `/courses/${this.courseId}/marketplace/attempt`; + } return `/courses/${this.courseId}/assessments/${this.assessmentId}/submissions`; } diff --git a/client/app/api/course/Assessment/__test__/previewRouting.test.ts b/client/app/api/course/Assessment/__test__/previewRouting.test.ts new file mode 100644 index 0000000000..f8a600e056 --- /dev/null +++ b/client/app/api/course/Assessment/__test__/previewRouting.test.ts @@ -0,0 +1,136 @@ +import { createMockAdapter } from 'mocks/axiosMock'; + +import CourseAPI from 'api/course'; + +import { clearActivePreview, setActivePreview } from '../previewAttemptContext'; + +// The submission API reads the assessment id from window.location. Preview URLs have +// none, so the seam must fall back to preview routing even without the singleton. +const setPath = (path: string): void => { + window.history.pushState({}, '', path); +}; + +const submissionsMock = createMockAdapter( + CourseAPI.assessment.submissions.client, +); +const scribingMock = createMockAdapter( + CourseAPI.assessment.answer.scribing.client, +); +const marketplaceMock = createMockAdapter(CourseAPI.marketplace.client); + +beforeEach(() => { + submissionsMock.reset(); + scribingMock.reset(); + marketplaceMock.reset(); + clearActivePreview(); +}); +afterEach(() => clearActivePreview()); + +describe('submission API preview routing', () => { + it('routes edit to /assessments/:aid/submissions when NOT in preview', async () => { + setPath(`/courses/${global.courseId}/assessments/9/submissions/5/edit`); + submissionsMock + .onGet(`/courses/${global.courseId}/assessments/9/submissions/5/edit`) + .reply(200, {}); + + await CourseAPI.assessment.submissions.edit(5); + + expect(submissionsMock.history.get[0].url).toBe( + `/courses/${global.courseId}/assessments/9/submissions/5/edit`, + ); + }); + + it('routes edit to /marketplace/attempt when the singleton is set', async () => { + setPath(`/courses/${global.courseId}/marketplace/attempt/5/edit`); + setActivePreview(5); + submissionsMock + .onGet(`/courses/${global.courseId}/marketplace/attempt/5/edit`) + .reply(200, {}); + + await CourseAPI.assessment.submissions.edit(5); + + expect(submissionsMock.history.get[0].url).toBe( + `/courses/${global.courseId}/marketplace/attempt/5/edit`, + ); + }); + + it('routes to /marketplace/attempt when assessment id is absent even if the singleton was cleared (stray poller)', async () => { + setPath(`/courses/${global.courseId}/marketplace/attempt/5/edit`); + clearActivePreview(); + submissionsMock + .onGet( + `/courses/${global.courseId}/marketplace/attempt/fetch_live_feedback_status`, + ) + .reply(200, {}); + + await CourseAPI.assessment.submissions.fetchLiveFeedbackStatus('t-1'); + + // The critical assertion: it must NOT be /assessments/null/... + expect(submissionsMock.history.get[0].url).toBe( + `/courses/${global.courseId}/marketplace/attempt/fetch_live_feedback_status`, + ); + expect(submissionsMock.history.get[0].url).not.toContain('assessments/null'); + }); + + it('routes reset to /marketplace/attempt/:id/reset', async () => { + setPath(`/courses/${global.courseId}/marketplace/attempt/5/edit`); + setActivePreview(5); + submissionsMock + .onPost(`/courses/${global.courseId}/marketplace/attempt/5/reset`) + .reply(200, {}); + + await CourseAPI.assessment.submissions.reset(5); + + expect(submissionsMock.history.post[0].url).toBe( + `/courses/${global.courseId}/marketplace/attempt/5/reset`, + ); + }); +}); + +describe('scribing API preview routing', () => { + it('routes scribble update to /assessments/:aid/submissions/:sid/answers when NOT in preview', async () => { + setPath(`/courses/${global.courseId}/assessments/9/submissions/5/edit`); + scribingMock + .onPost( + `/courses/${global.courseId}/assessments/9/submissions/5/answers/7/scribing/scribbles`, + ) + .reply(200, {}); + + await CourseAPI.assessment.answer.scribing.update(7, {}); + + expect(scribingMock.history.post[0].url).toBe( + `/courses/${global.courseId}/assessments/9/submissions/5/answers/7/scribing/scribbles`, + ); + }); + + it('routes scribble update to /marketplace/attempt/:attemptId/answers when in preview', async () => { + setPath(`/courses/${global.courseId}/marketplace/attempt/5/edit`); + setActivePreview(5); + scribingMock + .onPost( + `/courses/${global.courseId}/marketplace/attempt/5/answers/7/scribing/scribbles`, + ) + .reply(200, {}); + + await CourseAPI.assessment.answer.scribing.update(7, {}); + + expect(scribingMock.history.post[0].url).toBe( + `/courses/${global.courseId}/marketplace/attempt/5/answers/7/scribing/scribbles`, + ); + }); +}); + +describe('marketplace createAttempt', () => { + it('posts to listings/:listingId/attempt and returns id + assessmentId', async () => { + marketplaceMock + .onPost(`/courses/${global.courseId}/marketplace/listings/7/attempt`) + .reply(200, { id: 55, assessmentId: 9 }); + + const response = await CourseAPI.marketplace.createAttempt(7); + + expect(marketplaceMock.history.post[0].url).toBe( + `/courses/${global.courseId}/marketplace/listings/7/attempt`, + ); + expect(response.data).toEqual({ id: 55, assessmentId: 9 }); + }); +}); diff --git a/client/app/api/course/Marketplace.ts b/client/app/api/course/Marketplace.ts index 8f1f4bf7ea..74d09d6fdc 100644 --- a/client/app/api/course/Marketplace.ts +++ b/client/app/api/course/Marketplace.ts @@ -50,4 +50,10 @@ export default class MarketplaceAPI extends BaseCourseAPI { `${this.#urlPrefix}/listings/${listingId}/questions/${questionId}`, ); } + + createAttempt( + listingId: number, + ): Promise> { + return this.client.post(`${this.#urlPrefix}/listings/${listingId}/attempt`); + } } From 979ebd58a30ef0b060c4afa6f04780f33665514a Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 24 Jul 2026 00:09:19 +0800 Subject: [PATCH 3/9] feat(marketplace): preview scope wrapper, context, and banner --- .../PreviewAttemptBanner.tsx | 65 +++++++++++++++++ .../PreviewAttemptContext.tsx | 18 +++++ .../__test__/PreviewAttemptBanner.test.tsx | 69 +++++++++++++++++++ .../assessment/submission/translations.ts | 17 +++++ .../PreviewAttempt/__test__/index.test.tsx | 33 +++++++++ .../pages/PreviewAttempt/index.tsx | 35 ++++++++++ 6 files changed, 237 insertions(+) create mode 100644 client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptBanner.tsx create mode 100644 client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptContext.tsx create mode 100644 client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/__test__/PreviewAttemptBanner.test.tsx create mode 100644 client/app/bundles/course/marketplace/pages/PreviewAttempt/__test__/index.test.tsx create mode 100644 client/app/bundles/course/marketplace/pages/PreviewAttempt/index.tsx diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptBanner.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptBanner.tsx new file mode 100644 index 0000000000..ceef7cdf98 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptBanner.tsx @@ -0,0 +1,65 @@ +import { useContext } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { RestartAlt, Visibility } from '@mui/icons-material'; +import { Alert, Button } from '@mui/material'; + +import CourseAPI from 'api/course'; +import { useCourseContext } from 'course/container/CourseLoader'; +import { setNotification } from 'lib/actions'; +import { useAppDispatch } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { fetchSubmission } from '../../actions'; +import translations from '../../translations'; + +import PreviewAttemptContext from './PreviewAttemptContext'; + +const PreviewAttemptBanner = (): JSX.Element | null => { + const { isPreview, attemptId, listingId } = useContext(PreviewAttemptContext); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const { courseUrl } = useCourseContext(); + const { t } = useTranslation(); + + if (!isPreview || attemptId === undefined) return null; + + const backTo = listingId + ? `${courseUrl}/marketplace/listings/${listingId}` + : `${courseUrl}/marketplace`; + + const handleReset = async (): Promise => { + await CourseAPI.assessment.submissions.reset(attemptId); + dispatch(setNotification(translations.resetPreviewSuccess)); + dispatch(fetchSubmission(attemptId)); + }; + + return ( + + + + + } + icon={} + severity="info" + > + {t(translations.previewBanner)} + + ); +}; + +export default PreviewAttemptBanner; diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptContext.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptContext.tsx new file mode 100644 index 0000000000..7ddd5f2102 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptContext.tsx @@ -0,0 +1,18 @@ +import { createContext } from 'react'; + +export interface PreviewAttemptContextValue { + isPreview: boolean; + attemptId?: number; + listingId?: number; +} + +/** + * Tells the reused submission edit page it is rendering a marketplace preview attempt. + * Default `isPreview: false` so the shared page renders normally for real submissions + * (no provider on the real submission route). + */ +const PreviewAttemptContext = createContext({ + isPreview: false, +}); + +export default PreviewAttemptContext; diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/__test__/PreviewAttemptBanner.test.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/__test__/PreviewAttemptBanner.test.tsx new file mode 100644 index 0000000000..5f7d4aa3ca --- /dev/null +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/__test__/PreviewAttemptBanner.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import PreviewAttemptBanner from '../PreviewAttemptBanner'; +import PreviewAttemptContext from '../PreviewAttemptContext'; + +const mockDispatch = jest.fn(); +jest.mock('lib/hooks/store', () => ({ + ...jest.requireActual('lib/hooks/store'), + useAppDispatch: () => mockDispatch, +})); + +jest.mock('course/container/CourseLoader', () => ({ + useCourseContext: () => ({ courseUrl: `/courses/${global.courseId}` }), +})); + +beforeEach(() => mockDispatch.mockClear()); + +// TestApp always mounts a Toastify container + an I18n LoadingIndicator (removed once +// messages load), so the render container is never truly empty. The meaningful check +// is that the banner emits no Alert in the default (non-preview) context. Wait for the +// loading indicator to clear first, so this isn't vacuously true during async load. +it('renders no banner under the default (non-preview) context', async () => { + render(); + await waitFor(() => + expect(screen.queryByTestId('CircularProgress')).not.toBeInTheDocument(), + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); + +it('renders the preview banner with Reset and Exit when isPreview', async () => { + render( + + + , + ); + + expect( + await screen.findByText(/you are previewing this assessment/i), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /reset/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /exit/i })).toBeInTheDocument(); +}); + +it('Reset calls the reset API and re-fetches the attempt', async () => { + const resetSpy = jest + .spyOn(CourseAPI.assessment.submissions, 'reset') + .mockResolvedValue({ data: {} } as never); + + render( + + + , + ); + + (await screen.findByRole('button', { name: /reset/i })).click(); + + await waitFor(() => expect(resetSpy).toHaveBeenCalledWith(5)); + // fetchSubmission is a thunk (a function); assert a function was dispatched after reset. + await waitFor(() => + expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function)), + ); + resetSpy.mockRestore(); +}); diff --git a/client/app/bundles/course/assessment/submission/translations.ts b/client/app/bundles/course/assessment/submission/translations.ts index fd82e5b2af..3e3ff564cb 100644 --- a/client/app/bundles/course/assessment/submission/translations.ts +++ b/client/app/bundles/course/assessment/submission/translations.ts @@ -10,6 +10,23 @@ const translations = defineMessages({ id: 'course.assessment.submission.submissionsHeader', defaultMessage: 'Submissions: {assessment}', }, + previewBanner: { + id: 'course.assessment.submission.previewBanner', + defaultMessage: + 'You are previewing this assessment. This is a throwaway attempt — nothing here is recorded, graded for credit, or visible to students.', + }, + resetPreview: { + id: 'course.assessment.submission.resetPreview', + defaultMessage: 'Reset', + }, + exitPreview: { + id: 'course.assessment.submission.exitPreview', + defaultMessage: 'Exit preview', + }, + resetPreviewSuccess: { + id: 'course.assessment.submission.resetPreviewSuccess', + defaultMessage: 'Preview attempt reset.', + }, studentView: { id: 'course.assessment.submission.studentView', defaultMessage: 'Student View', diff --git a/client/app/bundles/course/marketplace/pages/PreviewAttempt/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/PreviewAttempt/__test__/index.test.tsx new file mode 100644 index 0000000000..e473c53476 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/PreviewAttempt/__test__/index.test.tsx @@ -0,0 +1,33 @@ +import { getActivePreview } from 'api/course/Assessment/previewAttemptContext'; +import { render, screen } from 'test-utils'; + +import PreviewAttemptScope from '../index'; + +// Assert the singleton is set during render (before effects). A child that reads +// getActivePreview() during its own render must see the value already set, proving +// the wrapper does not defer it to a useEffect. +const Probe = (): JSX.Element =>
preview id: {String(getActivePreview())}
; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ submissionId: '5' }), + useSearchParams: () => [new URLSearchParams('fromListing=7'), jest.fn()], + Outlet: () => , +})); + +// TestApp's I18nProvider loads locale messages via a dynamic import(), so the tree +// mounts asynchronously — the first assertion must be an async findBy/waitFor. +it('sets the preview singleton during render, before its child renders', async () => { + render(); + // The Probe reads getActivePreview() during ITS OWN render; seeing '5' proves the + // wrapper set the singleton before the child rendered (i.e. in render, not an effect). + expect(await screen.findByText('preview id: 5')).toBeInTheDocument(); +}); + +it('clears the singleton on unmount', async () => { + const { unmount } = render(); + await screen.findByText('preview id: 5'); + expect(getActivePreview()).toBe(5); + unmount(); + expect(getActivePreview()).toBeNull(); +}); diff --git a/client/app/bundles/course/marketplace/pages/PreviewAttempt/index.tsx b/client/app/bundles/course/marketplace/pages/PreviewAttempt/index.tsx new file mode 100644 index 0000000000..808b3794e7 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/PreviewAttempt/index.tsx @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import { Outlet, useParams, useSearchParams } from 'react-router-dom'; + +import { + clearActivePreview, + setActivePreview, +} from 'api/course/Assessment/previewAttemptContext'; +import PreviewAttemptContext from 'course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptContext'; + +const PreviewAttemptScope = (): JSX.Element => { + const { submissionId } = useParams(); + const attemptId = Number(submissionId); + const [params] = useSearchParams(); + const listingParam = params.get('fromListing'); + const listingId = listingParam ? Number(listingParam) : undefined; + + // Set the singleton SYNCHRONOUSLY in the render body — not in a useEffect. The child + // SubmissionEditIndex dispatches fetchSubmission in componentDidMount, which fires + // BEFORE this parent's effects run. A parent renders before its children, so setting + // it here guarantees the API seam sees the preview context when the child builds its + // first request URL. Idempotent, so re-running on every render is harmless. + setActivePreview(attemptId); + + useEffect(() => () => clearActivePreview(), []); + + return ( + + + + ); +}; + +export default PreviewAttemptScope; From ea5e2d3a2e359eeac7d895028fda1e28260250ac Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 24 Jul 2026 00:10:14 +0800 Subject: [PATCH 4/9] feat(marketplace): mount preview banner and register preview attempt route --- .../pages/SubmissionEditIndex/index.jsx | 2 ++ client/app/routers/course/marketplace.tsx | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx index 1ec7413496..8fea0652e1 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx @@ -40,6 +40,7 @@ import { import translations from '../../translations'; import BlockedSubmission from './BlockedSubmission'; +import PreviewAttemptBanner from './PreviewAttemptBanner'; import SubmissionEmptyForm from './SubmissionEmptyForm'; import SubmissionForm from './SubmissionForm'; import TimeLimitBanner from './TimeLimitBanner'; @@ -173,6 +174,7 @@ class VisibleSubmissionEditIndex extends Component { return ( + {this.renderTimeLimitBanner()} {this.renderAssessment()} {isBlockedInStudentView ? ( diff --git a/client/app/routers/course/marketplace.tsx b/client/app/routers/course/marketplace.tsx index a3c54571dc..acb27fb671 100644 --- a/client/app/routers/course/marketplace.tsx +++ b/client/app/routers/course/marketplace.tsx @@ -48,6 +48,31 @@ const marketplaceRouter: Translated = () => ({ }, ], }, + { + path: 'attempt/:submissionId', + lazy: async () => ({ + Component: (await import('course/marketplace/pages/PreviewAttempt')) + .default, + }), + children: [ + { + path: 'edit', + lazy: async (): Promise> => { + const SubmissionEditIndex = ( + await import( + /* webpackChunkName: 'SubmissionEditIndex' */ + 'course/assessment/submission/pages/SubmissionEditIndex' + ) + ).default; + + return { + Component: SubmissionEditIndex, + handle: SubmissionEditIndex.handle, + }; + }, + }, + ], + }, ], }); From 04229f6dd519e0c3fab1a256208a490950f8fa46 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 24 Jul 2026 00:11:50 +0800 Subject: [PATCH 5/9] feat(marketplace): attempt entrance on the listing preview page --- .../bundles/course/marketplace/operations.ts | 7 +++ .../ListingPreview/__test__/index.test.tsx | 60 +++++++++++++++++++ .../pages/ListingPreview/index.tsx | 52 ++++++++++++---- .../course/marketplace/translations.ts | 8 +++ 4 files changed, 116 insertions(+), 11 deletions(-) diff --git a/client/app/bundles/course/marketplace/operations.ts b/client/app/bundles/course/marketplace/operations.ts index ff0303ce4a..e8293bb3ca 100644 --- a/client/app/bundles/course/marketplace/operations.ts +++ b/client/app/bundles/course/marketplace/operations.ts @@ -50,3 +50,10 @@ export const fetchQuestion = async ( ); return response.data as QuestionPreviewData; }; + +export const createAttempt = async ( + listingId: number, +): Promise<{ id: number; assessmentId: number }> => { + const response = await CourseAPI.marketplace.createAttempt(listingId); + return response.data; +}; diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx index c687b47d63..40adccb047 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx @@ -208,3 +208,63 @@ it('marks the page title as a preview', async () => { // for the real assessment it mirrors. expect(screen.getByText('Preview')).toBeVisible(); }); + +it('creates a preview attempt and navigates to the preview edit page', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: '', + typeCounts: {}, + questions: [], + }); + mock + .onPost(`/courses/${global.courseId}/marketplace/listings/7/attempt`) + .reply(200, { id: 55, assessmentId: 9 }); + + render(, { at: [url] }); + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + + fireEvent.click(screen.getByRole('button', { name: 'Attempt' })); + + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith( + `/courses/${global.courseId}/marketplace/attempt/55/edit?fromListing=7`, + ), + ); +}); + +it('shows a notification and does not navigate when the attempt create conflicts (409)', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: '', + typeCounts: {}, + questions: [], + }); + mock + .onPost(`/courses/${global.courseId}/marketplace/listings/7/attempt`) + .reply(409, { + errors: ['You already have a submission for this assessment.'], + }); + + render(, { at: [url] }); + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + + fireEvent.click(screen.getByRole('button', { name: 'Attempt' })); + + // Wait for the create POST to actually round-trip (and 409), THEN assert no + // navigation followed — otherwise "not navigated" is trivially true before the + // request even fires. + await waitFor(() => + expect( + mock.history.post.filter((r) => + r.url?.endsWith('/marketplace/listings/7/attempt'), + ), + ).toHaveLength(1), + ); + expect(mockNavigate).not.toHaveBeenCalled(); +}); diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx index 5e6c9a04ab..78ffa8f42c 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx @@ -1,20 +1,22 @@ import { useState } from 'react'; -import { useParams, useSearchParams } from 'react-router-dom'; -import { ContentCopy } from '@mui/icons-material'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { ContentCopy, PlayArrow } from '@mui/icons-material'; import { Button, Chip, Paper } from '@mui/material'; // Reuse the assessment show page's "Questions" heading so wording + locales stay identical. import assessmentTranslations from 'course/assessment/translations'; import { useCourseContext } from 'course/container/CourseLoader'; +import { setNotification } from 'lib/actions'; import DescriptionCard from 'lib/components/core/DescriptionCard'; import Page from 'lib/components/core/layouts/Page'; import Subsection from 'lib/components/core/layouts/Subsection'; import Preload from 'lib/components/wrappers/Preload'; +import { useAppDispatch } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import DuplicateConfirmation from '../../components/DuplicateConfirmation'; import { withFromTab } from '../../fromTab'; -import { fetchListing } from '../../operations'; +import { createAttempt, fetchListing } from '../../operations'; import translations from '../../translations'; import { ListingPreviewData } from '../../types'; @@ -32,6 +34,23 @@ const ListingPreview = (): JSX.Element => { const destinationTabId = parseInt(fromTab ?? '', 10) || null; const [duplicating, setDuplicating] = useState(false); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const [attempting, setAttempting] = useState(false); + + const handleAttempt = async (): Promise => { + setAttempting(true); + try { + const { id } = await createAttempt(Number(listingId)); + navigate( + `${courseUrl}/marketplace/attempt/${id}/edit?fromListing=${listingId}`, + ); + } catch { + dispatch(setNotification(translations.attemptFailed)); + setAttempting(false); + } + }; + return ( } @@ -40,14 +59,25 @@ const ListingPreview = (): JSX.Element => { {(listing): JSX.Element => ( setDuplicating(true)} - startIcon={} - variant="contained" - > - {t(translations.duplicateAssessment)} - + <> + + + } backTo={withFromTab(`${courseUrl}/marketplace`, fromTab)} className="space-y-5" diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts index 3818d2785c..77490f8457 100644 --- a/client/app/bundles/course/marketplace/translations.ts +++ b/client/app/bundles/course/marketplace/translations.ts @@ -73,6 +73,14 @@ export default defineMessages({ id: 'course.marketplace.duplicateAssessment', defaultMessage: 'Duplicate Assessment', }, + attemptAssessment: { + id: 'course.marketplace.attemptAssessment', + defaultMessage: 'Attempt', + }, + attemptFailed: { + id: 'course.marketplace.attemptFailed', + defaultMessage: 'Could not start a preview attempt.', + }, viewDetails: { id: 'course.marketplace.viewDetails', defaultMessage: 'View question details', From 65fbfee92db3f7cb4c6af888b040d7f2b1716bf0 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 24 Jul 2026 00:14:04 +0800 Subject: [PATCH 6/9] chore(marketplace): sort imports in preview-routed API classes --- client/app/api/course/Assessment/Submission/Answer/Scribing.js | 3 +-- client/app/api/course/Assessment/Submissions.js | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/client/app/api/course/Assessment/Submission/Answer/Scribing.js b/client/app/api/course/Assessment/Submission/Answer/Scribing.js index 8f61cefd72..bd402acc84 100644 --- a/client/app/api/course/Assessment/Submission/Answer/Scribing.js +++ b/client/app/api/course/Assessment/Submission/Answer/Scribing.js @@ -1,6 +1,5 @@ -import { getActivePreview } from '../../previewAttemptContext'; - import BaseAssessmentAPI from '../../Base'; +import { getActivePreview } from '../../previewAttemptContext'; export default class ScribingsAPI extends BaseAssessmentAPI { /** diff --git a/client/app/api/course/Assessment/Submissions.js b/client/app/api/course/Assessment/Submissions.js index 403ad77df0..369e6b4bf2 100644 --- a/client/app/api/course/Assessment/Submissions.js +++ b/client/app/api/course/Assessment/Submissions.js @@ -1,6 +1,5 @@ -import { getActivePreview } from './previewAttemptContext'; - import BaseAssessmentAPI from './Base'; +import { getActivePreview } from './previewAttemptContext'; export default class SubmissionsAPI extends BaseAssessmentAPI { index() { From d6d67a3f6ac9295906891562ec25cbf652784e90 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 24 Jul 2026 00:20:01 +0800 Subject: [PATCH 7/9] fix(marketplace): forward course outlet context through preview scope A bare nulls the outlet context, so the reused SubmissionEditIndex banner would read undefined from useCourseContext() on the preview page (and throw when destructuring). Forward CourseContainer's context and read it defensively in the banner. --- .../PreviewAttemptBanner.tsx | 6 +++++- .../PreviewAttempt/__test__/index.test.tsx | 19 +++++++++++++++++- .../pages/PreviewAttempt/index.tsx | 20 ++++++++++++++----- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptBanner.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptBanner.tsx index ceef7cdf98..b2b48211b6 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptBanner.tsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptBanner.tsx @@ -18,11 +18,15 @@ const PreviewAttemptBanner = (): JSX.Element | null => { const { isPreview, attemptId, listingId } = useContext(PreviewAttemptContext); const dispatch = useAppDispatch(); const navigate = useNavigate(); - const { courseUrl } = useCourseContext(); + // This banner mounts on the shared submission edit page too. Read the course context + // defensively (optional chaining, not destructuring) so it never throws there — the + // component early-returns for real submissions before courseUrl is ever used. + const courseContext = useCourseContext(); const { t } = useTranslation(); if (!isPreview || attemptId === undefined) return null; + const courseUrl = courseContext?.courseUrl; const backTo = listingId ? `${courseUrl}/marketplace/listings/${listingId}` : `${courseUrl}/marketplace`; diff --git a/client/app/bundles/course/marketplace/pages/PreviewAttempt/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/PreviewAttempt/__test__/index.test.tsx index e473c53476..edd59bf417 100644 --- a/client/app/bundles/course/marketplace/pages/PreviewAttempt/__test__/index.test.tsx +++ b/client/app/bundles/course/marketplace/pages/PreviewAttempt/__test__/index.test.tsx @@ -12,7 +12,17 @@ jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useParams: () => ({ submissionId: '5' }), useSearchParams: () => [new URLSearchParams('fromListing=7'), jest.fn()], - Outlet: () => , + // Stand in for CourseContainer's outlet context so we can assert the wrapper + // forwards it (useCourseContext() delegates to useOutletContext()). + useOutletContext: () => ({ courseUrl: '/courses/1' }), + // Render both the probe (for the singleton timing check) and the context the + // wrapper forwarded via (for the forwarding check). + Outlet: ({ context }: { context?: { courseUrl?: string } }): JSX.Element => ( + <> + +
forwarded: {context?.courseUrl ?? 'none'}
+ + ), })); // TestApp's I18nProvider loads locale messages via a dynamic import(), so the tree @@ -24,6 +34,13 @@ it('sets the preview singleton during render, before its child renders', async ( expect(await screen.findByText('preview id: 5')).toBeInTheDocument(); }); +it('forwards the course outlet context down to the child route', async () => { + render(); + // A bare would null the context; this proves CourseLayoutData is forwarded + // so the reused SubmissionEditIndex banner can read courseUrl on the preview page. + expect(await screen.findByText('forwarded: /courses/1')).toBeInTheDocument(); +}); + it('clears the singleton on unmount', async () => { const { unmount } = render(); await screen.findByText('preview id: 5'); diff --git a/client/app/bundles/course/marketplace/pages/PreviewAttempt/index.tsx b/client/app/bundles/course/marketplace/pages/PreviewAttempt/index.tsx index 808b3794e7..c33f97c110 100644 --- a/client/app/bundles/course/marketplace/pages/PreviewAttempt/index.tsx +++ b/client/app/bundles/course/marketplace/pages/PreviewAttempt/index.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { Outlet, useParams, useSearchParams } from 'react-router-dom'; import { @@ -6,6 +6,7 @@ import { setActivePreview, } from 'api/course/Assessment/previewAttemptContext'; import PreviewAttemptContext from 'course/assessment/submission/pages/SubmissionEditIndex/PreviewAttemptContext'; +import { useCourseContext } from 'course/container/CourseLoader'; const PreviewAttemptScope = (): JSX.Element => { const { submissionId } = useParams(); @@ -14,6 +15,12 @@ const PreviewAttemptScope = (): JSX.Element => { const listingParam = params.get('fromListing'); const listingId = listingParam ? Number(listingParam) : undefined; + // Forward CourseContainer's outlet context (CourseLayoutData, incl. courseUrl) down + // to the reused SubmissionEditIndex. A bare would set the outlet context to + // undefined, so the child's useCourseContext() — used by the preview banner — would + // otherwise read undefined on the preview page. + const courseContext = useCourseContext(); + // Set the singleton SYNCHRONOUSLY in the render body — not in a useEffect. The child // SubmissionEditIndex dispatches fetchSubmission in componentDidMount, which fires // BEFORE this parent's effects run. A parent renders before its children, so setting @@ -23,11 +30,14 @@ const PreviewAttemptScope = (): JSX.Element => { useEffect(() => () => clearActivePreview(), []); + const previewContext = useMemo( + () => ({ isPreview: true, attemptId, listingId }), + [attemptId, listingId], + ); + return ( - - + + ); }; From c9b4657862cb0fdce14539430f5398d9f01b4bd8 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 24 Jul 2026 10:42:05 +0800 Subject: [PATCH 8/9] feat(marketplace): attempt entrance on the marketplace index rows Add a play (attempt) icon button between the preview eye and duplicate buttons in each listing row. Extract the create-attempt + navigate + 409-notify flow into a shared useStartPreviewAttempt hook reused by the listing preview page. --- .../pages/ListingPreview/index.tsx | 27 +++-------- .../MarketplaceIndex/MarketplaceTable.tsx | 12 +++++ .../__test__/MarketplaceTable.test.tsx | 32 +++++++++++++ .../pages/MarketplaceIndex/index.tsx | 3 ++ .../marketplace/useStartPreviewAttempt.ts | 45 +++++++++++++++++++ 5 files changed, 98 insertions(+), 21 deletions(-) create mode 100644 client/app/bundles/course/marketplace/useStartPreviewAttempt.ts diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx index 78ffa8f42c..ec2cb3605c 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx @@ -1,24 +1,23 @@ import { useState } from 'react'; -import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import { ContentCopy, PlayArrow } from '@mui/icons-material'; import { Button, Chip, Paper } from '@mui/material'; // Reuse the assessment show page's "Questions" heading so wording + locales stay identical. import assessmentTranslations from 'course/assessment/translations'; import { useCourseContext } from 'course/container/CourseLoader'; -import { setNotification } from 'lib/actions'; import DescriptionCard from 'lib/components/core/DescriptionCard'; import Page from 'lib/components/core/layouts/Page'; import Subsection from 'lib/components/core/layouts/Subsection'; import Preload from 'lib/components/wrappers/Preload'; -import { useAppDispatch } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import DuplicateConfirmation from '../../components/DuplicateConfirmation'; import { withFromTab } from '../../fromTab'; -import { createAttempt, fetchListing } from '../../operations'; +import { fetchListing } from '../../operations'; import translations from '../../translations'; import { ListingPreviewData } from '../../types'; +import useStartPreviewAttempt from '../../useStartPreviewAttempt'; import PreviewAssessmentDetails from './PreviewAssessmentDetails'; import PreviewQuestionCard from './PreviewQuestionCard'; @@ -34,22 +33,8 @@ const ListingPreview = (): JSX.Element => { const destinationTabId = parseInt(fromTab ?? '', 10) || null; const [duplicating, setDuplicating] = useState(false); - const navigate = useNavigate(); - const dispatch = useAppDispatch(); - const [attempting, setAttempting] = useState(false); - - const handleAttempt = async (): Promise => { - setAttempting(true); - try { - const { id } = await createAttempt(Number(listingId)); - navigate( - `${courseUrl}/marketplace/attempt/${id}/edit?fromListing=${listingId}`, - ); - } catch { - dispatch(setNotification(translations.attemptFailed)); - setAttempting(false); - } - }; + const { starting: attempting, start: startAttempt } = + useStartPreviewAttempt(); return ( {