diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 7c65a31ce5..8437b0ab33 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -407,7 +407,13 @@ export const createDefaultWorkspace = (workspaceLocation: WorkspaceLocation): Wo debuggerContext: {} as DebuggerContext, lastDebuggerResult: undefined, files: {}, - updateUserRoleCallback: () => {} + updateUserRoleCallback: () => {}, + versionHistory: { + versions: [], + isLoading: false, + isHistoryPanelOpen: false + }, + saveStatus: 'idle' }); const defaultFileName = 'program.js'; diff --git a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx index 513b737821..00587f0372 100644 --- a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx +++ b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx @@ -53,6 +53,9 @@ import { ControlBarQuestionViewButton } from '../controlBar/ControlBarQuestionVi import { ControlBarResetButton } from '../controlBar/ControlBarResetButton'; import { ControlBarRunButton } from '../controlBar/ControlBarRunButton'; import { ControlButtonSaveButton } from '../controlBar/ControlBarSaveButton'; +import { ControlBarSaveStatusIndicator } from '../controlBar/ControlBarSaveStatusIndicator'; +import { ControlBarVersionHistoryButton } from '../controlBar/ControlBarVersionHistoryButton'; +import { VersionHistoryPanel } from '../controlBar/VersionHistoryPanel'; import ControlButton from '../ControlButton'; import { convertEditorTabStateToProps, @@ -127,7 +130,9 @@ const AssessmentWorkspace: React.FC = props => { output, replValue, currentAssessment: storedAssessmentId, - currentQuestion: storedQuestionId + currentQuestion: storedQuestionId, + versionHistory, + saveStatus } = useTypedSelector(store => store.workspaces[workspaceLocation]); const dispatch = useDispatch(); @@ -148,7 +153,11 @@ const AssessmentWorkspace: React.FC = props => { handleCheckLastModifiedAt, handleUpdateHasUnsavedChanges, handleEnableTokenCounter, - handleDisableTokenCounter + handleDisableTokenCounter, + handleFetchVersionHistory, + handleToggleHistoryPanel, + handleRestoreVersion, + handleNameVersion } = useMemo(() => { return { handleTeamOverviewFetch: (assessmentId: number) => @@ -185,7 +194,15 @@ const AssessmentWorkspace: React.FC = props => { handleEnableTokenCounter: () => dispatch(WorkspaceActions.enableTokenCounter(workspaceLocation)), handleDisableTokenCounter: () => - dispatch(WorkspaceActions.disableTokenCounter(workspaceLocation)) + dispatch(WorkspaceActions.disableTokenCounter(workspaceLocation)), + handleFetchVersionHistory: () => + dispatch(WorkspaceActions.fetchVersionHistory(workspaceLocation)), + handleToggleHistoryPanel: () => + dispatch(WorkspaceActions.toggleHistoryPanel(workspaceLocation)), + handleRestoreVersion: (versionId: string) => + dispatch(WorkspaceActions.restoreVersion(workspaceLocation, versionId)), + handleNameVersion: (versionId: string, name: string) => + dispatch(WorkspaceActions.nameVersion(workspaceLocation, versionId, name)) }; }, [dispatch]); @@ -410,6 +427,7 @@ const AssessmentWorkspace: React.FC = props => { ); handleClearContext(question.library, true); handleUpdateHasUnsavedChanges(false); + handleFetchVersionHistory(); const chapter = question.library.chapter; const questionType = question.type; @@ -778,25 +796,34 @@ const AssessmentWorkspace: React.FC = props => { /> ); - // Define the function to check if the Save button should be disabled - const shouldDisableSaveButton = (): boolean | undefined => { - const isIndividualAssessment: boolean = assessmentOverview?.maxTeamSize === 1; - if (isIndividualAssessment) { - return false; - } - return !teamFormationOverview; - }; + const isTeamAssessment: boolean = assessmentOverview?.maxTeamSize !== 1; const saveButton = - props.canSave && question.type === QuestionTypes.programming ? ( + isTeamAssessment && question.type === QuestionTypes.programming ? ( ) : null; + const versionHistoryButton = + question.type !== QuestionTypes.mcq ? ( + { + handleFetchVersionHistory(); + handleToggleHistoryPanel(); + }} + key="version_history" + /> + ) : null; + + const saveStatusIndicator = + question.type !== QuestionTypes.mcq && !isTeamAssessment ? ( + + ) : null; + const chapterSelect = ( {}} @@ -811,8 +838,15 @@ const AssessmentWorkspace: React.FC = props => { return { editorButtons: !isMobileBreakpoint || isVscode - ? [runButton, saveButton, resetButton, chapterSelect] - : [saveButton, resetButton], + ? [ + runButton, + saveButton, + resetButton, + versionHistoryButton, + saveStatusIndicator, + chapterSelect + ] + : [resetButton], flowButtons: [previousButton, questionView, nextButton] }; }; @@ -943,7 +977,7 @@ It is safe to close this window.`} > - + {isVscode || !isMobileBreakpoint ? ( ) : ( diff --git a/src/commons/assessmentWorkspace/__tests__/__snapshots__/AssessmentWorkspace.test.tsx.snap b/src/commons/assessmentWorkspace/__tests__/__snapshots__/AssessmentWorkspace.test.tsx.snap index cd72128996..2dd4d9bb6a 100644 --- a/src/commons/assessmentWorkspace/__tests__/__snapshots__/AssessmentWorkspace.test.tsx.snap +++ b/src/commons/assessmentWorkspace/__tests__/__snapshots__/AssessmentWorkspace.test.tsx.snap @@ -121,6 +121,32 @@ exports[`AssessmentWorkspace > AssessmentWorkspace page with ContestVoting quest Reset +
AssessmentWorkspace page with ContestVoting quest aria-haspopup="false" aria-label="Cursor at row 1" autocapitalize="off" - autocomplete="off" autocorrect="off" class="ace_text-input" role="textbox" @@ -318,11 +343,10 @@ exports[`AssessmentWorkspace > AssessmentWorkspace page with ContestVoting quest
AssessmentWorkspace page with ContestVoting quest aria-haspopup="false" aria-label="Cursor at row 1" autocapitalize="off" - autocomplete="off" autocorrect="off" class="ace_text-input" role="textbox" @@ -860,11 +883,10 @@ exports[`AssessmentWorkspace > AssessmentWorkspace page with ContestVoting quest
AssessmentWorkspace page with MCQ question render aria-haspopup="false" aria-label="Cursor at row 1" autocapitalize="off" - autocomplete="off" autocorrect="off" class="ace_text-input" role="textbox" @@ -1644,11 +1665,10 @@ exports[`AssessmentWorkspace > AssessmentWorkspace page with MCQ question render
AssessmentWorkspace page with overdue assessment + + + + Save + + +
AssessmentWorkspace page with overdue assessment aria-haspopup="false" aria-label="Cursor at row 1" autocapitalize="off" - autocomplete="off" autocorrect="off" class="ace_text-input" role="textbox" @@ -1990,11 +2065,10 @@ exports[`AssessmentWorkspace > AssessmentWorkspace page with overdue assessment
AssessmentWorkspace page with overdue assessment aria-haspopup="false" aria-label="Cursor at row 1" autocapitalize="off" - autocomplete="off" autocorrect="off" class="ace_text-input" role="textbox" @@ -2475,11 +2548,10 @@ exports[`AssessmentWorkspace > AssessmentWorkspace page with overdue assessment
AssessmentWorkspace page with programming questio - - + +
AssessmentWorkspace page with programming questio aria-haspopup="false" aria-label="Cursor at row 1" autocapitalize="off" - autocomplete="off" autocorrect="off" class="ace_text-input" role="textbox" @@ -2851,11 +2944,10 @@ exports[`AssessmentWorkspace > AssessmentWorkspace page with programming questio
AssessmentWorkspace page with programming questio aria-haspopup="false" aria-label="Cursor at row 1" autocapitalize="off" - autocomplete="off" autocorrect="off" class="ace_text-input" role="textbox" @@ -3336,11 +3427,10 @@ exports[`AssessmentWorkspace > AssessmentWorkspace page with programming questio
AssessmentWorkspace renders Grading tab correctly - - + +
AssessmentWorkspace renders Grading tab correctly aria-haspopup="false" aria-label="Cursor at row 1" autocapitalize="off" - autocomplete="off" autocorrect="off" class="ace_text-input" role="textbox" @@ -3712,11 +3823,10 @@ exports[`AssessmentWorkspace > AssessmentWorkspace renders Grading tab correctly
AssessmentWorkspace renders Grading tab correctly aria-haspopup="false" aria-label="Cursor at row 1" autocapitalize="off" - autocomplete="off" autocorrect="off" class="ace_text-input" role="textbox" @@ -4413,11 +4522,10 @@ exports[`AssessmentWorkspace > AssessmentWorkspace renders Grading tab correctly
, StatusConfig> = { + saving: { + label: 'Saving', + icon: , + intent: Intent.NONE + }, + saved: { + label: 'Saved', + icon: IconNames.TICK, + intent: Intent.SUCCESS + }, + saveFailed: { + label: 'Saving failed', + icon: IconNames.WARNING_SIGN, + intent: Intent.DANGER + } +}; + +export const ControlBarSaveStatusIndicator: React.FC = ({ saveStatus }) => { + if (saveStatus === 'idle') { + return null; + } + + const config = statusConfig[saveStatus]; + + return ( + + {config.label} + + ); +}; diff --git a/src/commons/controlBar/ControlBarVersionHistoryButton.tsx b/src/commons/controlBar/ControlBarVersionHistoryButton.tsx new file mode 100644 index 0000000000..d0dd3bdbbf --- /dev/null +++ b/src/commons/controlBar/ControlBarVersionHistoryButton.tsx @@ -0,0 +1,12 @@ +import { IconNames } from '@blueprintjs/icons'; +import React from 'react'; + +import ControlButton from '../ControlButton'; + +type Props = { + onClick?(): any; +}; + +export const ControlBarVersionHistoryButton: React.FC = ({ onClick }) => { + return ; +}; diff --git a/src/commons/controlBar/VersionHistoryPanel.tsx b/src/commons/controlBar/VersionHistoryPanel.tsx new file mode 100644 index 0000000000..347bf0dcba --- /dev/null +++ b/src/commons/controlBar/VersionHistoryPanel.tsx @@ -0,0 +1,142 @@ +import { + Button, + Classes, + Drawer, + EditableText, + Intent, + NonIdealState, + Spinner, + SpinnerSize +} from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import classNames from 'classnames'; +import React, { useCallback, useEffect, useState } from 'react'; + +import type { CodeVersion } from '../workspace/WorkspaceTypes'; + +type Props = { + versions: CodeVersion[]; + isOpen: boolean; + isLoading: boolean; + onClose: () => void; + onRestore: (versionId: string) => void; + onRename: (versionId: string, name: string) => void; +}; + +const formatTimestamp = (timestamp: number | null | undefined): string => { + if (timestamp == null) return 'Unknown date'; + const date = new Date(timestamp); + if (isNaN(date.getTime())) return 'Unknown date'; + return date.toLocaleString(); +}; + +export const VersionHistoryPanel: React.FC = ({ + versions, + isOpen, + isLoading, + onClose, + onRestore, + onRename +}) => { + const [selectedVersionId, setSelectedVersionId] = useState(null); + + useEffect(() => { + if (!isOpen || versions.length === 0) { + setSelectedVersionId(null); + return; + } + const sorted = [...versions].sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); + const stillValid = sorted.some(v => v.id === selectedVersionId); + if (!stillValid) { + setSelectedVersionId(sorted[0].id); + } + }, [versions, isOpen]); + + const handleRestore = useCallback( + (versionId: string) => { + onRestore(versionId); + onClose(); + }, + [onRestore, onClose] + ); + + const renderVersionItem = (version: CodeVersion) => ( +
setSelectedVersionId(version.id)} + > +
+ onRename(version.id, value)} + selectAllOnFocus + /> + {formatTimestamp(version.timestamp)} +
+
+ ); + + const renderPreviewPane = (version: CodeVersion | undefined) => { + if (!version) { + return ( + + ); + } + return ( +
+
+ + {version.name || formatTimestamp(version.timestamp)} + +
+
{version.code}
+
+ ); + }; + + const sortedVersions = [...versions].sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); + const selectedVersion = versions.find(v => v.id === selectedVersionId); + + const content = isLoading ? ( + } + /> + ) : versions.length === 0 ? ( + + ) : ( +
+
{sortedVersions.map(renderVersionItem)}
+
{renderPreviewPane(selectedVersion)}
+
+ ); + + return ( + + {content} + + ); +}; diff --git a/src/commons/mocks/BackendMocks.ts b/src/commons/mocks/BackendMocks.ts index cba44a0afb..36a6d9b033 100644 --- a/src/commons/mocks/BackendMocks.ts +++ b/src/commons/mocks/BackendMocks.ts @@ -137,7 +137,6 @@ export function* mockBackendSaga(): SagaIterator { questions: newQuestions }; yield put(actions.updateAssessment(newAssessment)); - yield call(showSuccessMessage, 'Saved!', 1000); return yield put(actions.updateHasUnsavedChanges('assessment' as WorkspaceLocation, false)); } ); diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index 9e45231bf3..8a33995e79 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -277,11 +277,10 @@ const newBackendSagaOne = combineSagaHandlers({ const resp: Response | null = yield call(postAnswer, questionId, answer, tokens); if (!resp || !resp.ok) { + yield put(WorkspaceActions.updateSaveStatus('assessment', 'saveFailed')); return yield handleResponseError(resp); } - yield call(showSuccessMessage, 'Saved!', 1000); - // Now, update the answer for the question in the assessment in the store const assessmentId: number = yield select( (state: OverallState) => state.workspaces.assessment.currentAssessment! @@ -301,7 +300,8 @@ const newBackendSagaOne = combineSagaHandlers({ }; yield put(actions.updateAssessment(newAssessment)); - return yield put(actions.updateHasUnsavedChanges('assessment' as WorkspaceLocation, false)); + yield put(actions.updateHasUnsavedChanges('assessment' as WorkspaceLocation, false)); + return yield put(WorkspaceActions.updateSaveStatus('assessment', 'saved')); }, [SessionActions.checkAnswerLastModifiedAt.type]: function* (action) { const tokens: Tokens = yield selectTokens(); diff --git a/src/commons/sagas/MainSaga.ts b/src/commons/sagas/MainSaga.ts index 531159463c..dd81cc83a9 100644 --- a/src/commons/sagas/MainSaga.ts +++ b/src/commons/sagas/MainSaga.ts @@ -17,6 +17,7 @@ import RemoteExecutionSaga from './RemoteExecutionSaga'; import SideContentSaga from './SideContentSaga'; import StoriesSaga from './StoriesSaga'; import WorkspaceSaga from './WorkspaceSaga'; +import { watchAutoSave, watchSavingStatus } from './WorkspaceSaga/helpers/versionHistory'; export default function* MainSaga(): SagaIterator { yield all([ @@ -33,6 +34,8 @@ export default function* MainSaga(): SagaIterator { fork(SideContentSaga), fork(FeatureFlagSaga), fork(LanguageDirectorySaga), - fork(PluginDirectorySaga) + fork(PluginDirectorySaga), + fork(watchAutoSave), + fork(watchSavingStatus) ]); } diff --git a/src/commons/sagas/RequestsSaga.ts b/src/commons/sagas/RequestsSaga.ts index 3f0bf79aeb..aefaa190d7 100644 --- a/src/commons/sagas/RequestsSaga.ts +++ b/src/commons/sagas/RequestsSaga.ts @@ -1850,3 +1850,57 @@ export const courseIdWithoutPrefix: () => string = () => { throw new Error(`No course selected`); } }; + +/** + * GET /courses/:courseId/assessments/question/:questionId/version/history + * Fetch version history for a workspace + */ +export const getVersionHistory = async ( + questionId: number, + tokens: Tokens +): Promise => { + const resp = await request( + `${courseId()}/assessments/question/${questionId}/version/history`, + 'GET', + { + accessToken: tokens.accessToken, + errorMessage: 'Could not fetch version history.', + refreshToken: tokens.refreshToken + } + ); + if (!resp || !resp.ok) { + return null; + } + const versions = await resp.json(); + return versions.map((v: any) => ({ + id: String(v.id), + name: v.name, + code: v.version?.code, + timestamp: new Date(v.inserted_at + 'Z').getTime() + })); +}; + +/** + * PUT courses/:course_id/assessments/question/:questionid/version/:versionid/name + * Update the name of a version + */ +export const updateVersionName = async ( + questionId: number, + versionId: string, + name: string, + tokens: Tokens +): Promise => { + const resp = await request( + `${courseId()}/assessments/question/${questionId}/version/${versionId}/name`, + 'PUT', + { + accessToken: tokens.accessToken, + body: { + name + }, + errorMessage: 'Could not update version name.', + refreshToken: tokens.refreshToken + } + ); + return resp; +}; diff --git a/src/commons/sagas/WorkspaceSaga/helpers/versionHistory.ts b/src/commons/sagas/WorkspaceSaga/helpers/versionHistory.ts new file mode 100644 index 0000000000..602530d0e0 --- /dev/null +++ b/src/commons/sagas/WorkspaceSaga/helpers/versionHistory.ts @@ -0,0 +1,292 @@ +import { call, debounce, put, select, take, takeEvery } from 'redux-saga/effects'; + +import SessionActions from '../../../application/actions/SessionActions'; +import type { OverallState } from '../../../application/ApplicationTypes'; +import type { Tokens } from '../../../application/types/SessionTypes'; +import { showWarningMessage } from '../../../utils/notifications/NotificationsHelper'; +import WorkspaceActions from '../../../workspace/WorkspaceActions'; +import type { WorkspaceLocation } from '../../../workspace/WorkspaceTypes'; +import { getVersionHistory, updateVersionName } from '../../RequestsSaga'; + +/** + * Helper to get the current question ID for assessment or grading workspace. + */ +function* getCurrentQuestionId(workspaceLocation: WorkspaceLocation) { + const questionId: number | undefined = yield select((state: OverallState) => { + const workspace = state.workspaces[workspaceLocation]; + + if (!('currentQuestion' in workspace)) { + return undefined; + } + + const questionIndex = workspace.currentQuestion; + if (questionIndex === undefined) { + return undefined; + } + + if (workspaceLocation === 'assessment') { + const assessmentId = state.workspaces.assessment.currentAssessment; + if (assessmentId === undefined) { + return undefined; + } + return state.session.assessments[assessmentId]?.questions[questionIndex]?.id; + } + + if (workspaceLocation === 'grading') { + const submissionId = state.workspaces.grading.currentSubmission; + if (submissionId === undefined) { + return undefined; + } + return state.session.gradings[submissionId]?.answers[questionIndex]?.question?.id; + } + + return undefined; + }); + + return questionId; +} + +/** + * Saga to handle fetching version history + */ +export function* fetchVersionHistorySaga( + action: ReturnType +) { + const { workspaceLocation } = action.payload; + + // Get the current question ID + const questionId: number | undefined = yield call(getCurrentQuestionId, workspaceLocation); + + if (questionId === undefined) { + // Set loading to false with empty versions + yield call(showWarningMessage, 'Version history is only available for questions'); + yield put(WorkspaceActions.receiveVersionHistory(workspaceLocation, [])); + return; + } + + // get authentication tokens from state + const tokens: Tokens = yield select((state: OverallState) => ({ + accessToken: state.session.accessToken, + refreshToken: state.session.refreshToken + })); + + // call API + const versions: any[] | null = yield call(getVersionHistory, questionId, tokens); + + if (versions) { + // action to store versions in state + yield put(WorkspaceActions.receiveVersionHistory(workspaceLocation, versions)); + } else { + // call with empty versions array to set loading to false and display warning + yield put(WorkspaceActions.receiveVersionHistory(workspaceLocation, [])); + yield call(showWarningMessage, 'Failed to load version history'); + } +} + +/** + * Saga to handle naming a version + */ +export function* nameVersionSaga(action: ReturnType) { + const { workspaceLocation, versionId, name } = action.payload; + + // Get the current question ID + const questionId: number | undefined = yield call(getCurrentQuestionId, workspaceLocation); + + if (questionId === undefined) { + yield call(showWarningMessage, 'Error renaming version: No question ID found'); + return; + } + + // Get authentication tokens + const tokens: Tokens = yield select((state: OverallState) => ({ + accessToken: state.session.accessToken, + refreshToken: state.session.refreshToken + })); + + // Call the API to update the name + const resp: Response | null = yield call(updateVersionName, questionId, versionId, name, tokens); + + if (!resp || !resp.ok) { + yield call(showWarningMessage, 'Failed to rename version'); + // Refetch to revert the optimistic update + yield call(fetchVersionHistorySaga, { + payload: { workspaceLocation }, + type: WorkspaceActions.fetchVersionHistory.type + }); + } +} + +/** + * Saga to handle restoring a version + */ +export function* restoreVersionSaga( + action: ReturnType +): any { + const { workspaceLocation, versionId } = action.payload; + + if (workspaceLocation !== 'assessment') { + return; + } + + // Find the restored version's code and name from state + const restoredVersion: + | { code: string; name: string | null | undefined; timestamp: number } + | undefined = yield select((state: OverallState) => { + const version = state.workspaces[workspaceLocation].versionHistory.versions.find( + v => v.id === versionId + ); + if (!version) return undefined; + return { code: version.code, name: version.name, timestamp: version.timestamp }; + }); + + if (restoredVersion === undefined) { + return; + } + + const { name: restoredVersionName, timestamp: restoredVersionTimestamp } = restoredVersion; + + // Get active editor tab index to update the editor + const activeEditorTabIndex: number | null = yield select( + (state: OverallState) => state.workspaces[workspaceLocation].activeEditorTabIndex + ); + + if (activeEditorTabIndex === null) { + return; + } + + // Check if this is a team assessment + const isTeamAssessment: boolean = yield select((state: OverallState) => { + const assessmentId = state.workspaces.assessment.currentAssessment; + if (assessmentId === undefined) return false; + const overview = state.session.assessmentOverviews?.find(o => o.id === assessmentId); + return overview ? overview.maxTeamSize !== 1 : false; + }); + + if (isTeamAssessment) { + // For team assessments, dont submit + yield put(WorkspaceActions.updateHasUnsavedChanges(workspaceLocation, true)); + return; + } + + // Auto-save the restored code + yield call(performAutoSave, workspaceLocation); + + // Name the restored version as "(name)-restored" + const newestVersion: { id: string; timestamp: number } | undefined = yield select( + (state: OverallState) => { + const versions = state.workspaces[workspaceLocation].versionHistory.versions; + if (versions.length === 0) return undefined; + return versions.reduce((latest, v) => (v.timestamp > latest.timestamp ? v : latest)); + } + ); + + if (newestVersion) { + const restoredLabel = + restoredVersionName || new Date(restoredVersionTimestamp).toLocaleString(); + const restoredName = `${restoredLabel}-restored`; + + // Optimistically update the name in state + yield put(WorkspaceActions.nameVersion(workspaceLocation, newestVersion.id, restoredName)); + } +} + +/** + * Performs auto-save by submitting the answer to the backend. + * The backend handles saving as a version on submission. + */ +function* performAutoSave(workspaceLocation: WorkspaceLocation): any { + // Only assessment workspaces auto-save + if (workspaceLocation !== 'assessment') { + return; + } + + // Skip auto-save for team assessments + const isTeamAssessment: boolean = yield select((state: OverallState) => { + const assessmentId = state.workspaces.assessment.currentAssessment; + if (assessmentId === undefined) return false; + const overview = state.session.assessmentOverviews?.find(o => o.id === assessmentId); + return overview ? overview.maxTeamSize !== 1 : false; + }); + + if (isTeamAssessment) { + return; + } + + // Get the current code from the active editor tab + const { editorTabs, activeEditorTabIndex } = yield select( + (state: OverallState) => state.workspaces[workspaceLocation] + ); + + if (activeEditorTabIndex === null) { + return; + } + + const code = editorTabs[activeEditorTabIndex].value; + + const questionId: number | undefined = yield call(getCurrentQuestionId, workspaceLocation); + + if (questionId === undefined) { + return; + } + + // Skip if code is unchanged from the latest saved version + const latestVersion: string | undefined = yield select((state: OverallState) => { + const versions = state.workspaces[workspaceLocation].versionHistory.versions; + if (versions.length === 0) return undefined; + return versions.reduce((latest, v) => (v.timestamp > latest.timestamp ? v : latest)).code; + }); + + if (code === latestVersion) { + yield put(WorkspaceActions.updateSaveStatus(workspaceLocation, 'saved')); + return; + } + + // Submit the answer; the backend handles saving as a version. + yield put(SessionActions.submitAnswer(questionId, code)); + + // Wait for submit to complete before refreshing + yield take( + (action: any) => + action.type === WorkspaceActions.updateSaveStatus.type && + (action.payload.saveStatus === 'saved' || action.payload.saveStatus === 'saveFailed') + ); + + // Refresh version history + yield call(fetchVersionHistorySaga, { + payload: { workspaceLocation }, + type: WorkspaceActions.fetchVersionHistory.type + }); +} + +/** + * Watcher saga that debounces auto-save calls per workspace + * Waits 3 seconds after the last edit before saving + */ +export function* watchAutoSave() { + yield debounce( + 3000, + WorkspaceActions.updateEditorValue.type, + function* (action: ReturnType) { + const { workspaceLocation } = action.payload; + yield* performAutoSave(workspaceLocation); + } + ); +} + +/** + * Watcher saga that immediately sets save status to 'saving' when the user types. + * This ensures the indicator shows "Saving" during the debounce wait period. + */ +export function* watchSavingStatus() { + yield takeEvery( + WorkspaceActions.updateEditorValue.type, + function* (action: ReturnType) { + const { workspaceLocation } = action.payload; + // Only set saving status for workspaces that actually auto-save + if (workspaceLocation === 'grading') { + return; + } + yield put(WorkspaceActions.updateSaveStatus(workspaceLocation, 'saving')); + } + ); +} diff --git a/src/commons/sagas/WorkspaceSaga/index.ts b/src/commons/sagas/WorkspaceSaga/index.ts index 2649642402..d0e3b021c4 100644 --- a/src/commons/sagas/WorkspaceSaga/index.ts +++ b/src/commons/sagas/WorkspaceSaga/index.ts @@ -37,6 +37,11 @@ import { selectWorkspace } from '../SafeEffects'; import { evalCodeSaga } from './helpers/evalCode'; import { evalEditorSaga } from './helpers/evalEditor'; import { runTestCase } from './helpers/runTestCase'; +import { + fetchVersionHistorySaga, + nameVersionSaga, + restoreVersionSaga +} from './helpers/versionHistory'; const WorkspaceSaga = combineSagaHandlers({ [WorkspaceActions.addHtmlConsoleError.type]: function* (action) { @@ -477,6 +482,15 @@ const WorkspaceSaga = combineSagaHandlers({ } } } + }, + [WorkspaceActions.fetchVersionHistory.type]: function* (action) { + yield* fetchVersionHistorySaga(action); + }, + [WorkspaceActions.nameVersion.type]: function* (action) { + yield* nameVersionSaga(action); + }, + [WorkspaceActions.restoreVersion.type]: function* (action) { + yield* restoreVersionSaga(action); } }); diff --git a/src/commons/sagas/__tests__/BackendSaga.test.ts b/src/commons/sagas/__tests__/BackendSaga.test.ts index 0e75ee8f07..58de97f68a 100644 --- a/src/commons/sagas/__tests__/BackendSaga.test.ts +++ b/src/commons/sagas/__tests__/BackendSaga.test.ts @@ -676,7 +676,7 @@ describe('Test SUBMIT_ANSWER action', () => { ] ]) .not.call.fn(showWarningMessage) - .call(showSuccessMessage, 'Saved!', 1000) + .not.call.fn(showSuccessMessage) .put(SessionActions.updateAssessment(mockNewAssessment)) .put(WorkspaceActions.updateHasUnsavedChanges('assessment' as WorkspaceLocation, false)) .dispatch({ type: SessionActions.submitAnswer.type, payload: mockAnsweredAssessmentQuestion }) @@ -718,7 +718,7 @@ describe('Test SUBMIT_ANSWER action', () => { ] ]) .not.call.fn(showWarningMessage) - .call(showSuccessMessage, 'Saved!', 1000) + .not.call.fn(showSuccessMessage) .put(SessionActions.updateAssessment(mockNewAssessment)) .put(WorkspaceActions.updateHasUnsavedChanges('assessment' as WorkspaceLocation, false)) .dispatch({ type: SessionActions.submitAnswer.type, payload: mockAnsweredAssessmentQuestion }) diff --git a/src/commons/sagas/__tests__/WorkspaceSaga.test.ts b/src/commons/sagas/__tests__/WorkspaceSaga.test.ts index 826740aea0..1296ceb899 100644 --- a/src/commons/sagas/__tests__/WorkspaceSaga.test.ts +++ b/src/commons/sagas/__tests__/WorkspaceSaga.test.ts @@ -16,6 +16,14 @@ import * as matchers from 'redux-saga-test-plan/matchers'; import { showFullJSDisclaimer, showFullTSDisclaimer } from 'src/commons/utils/WarningDialogHelper'; import { vi } from 'vitest'; +// Mock createStore to prevent Immer auto-freeze from freezing defaultWorkspaceManager. +// Importing RequestsSaga triggers store creation via createStore, and due to ES module +// hoisting, rootReducer initializes (freezing shared state) before setAutoFreeze(false) runs. +vi.mock('../../../pages/createStore', () => ({ + store: { getState: () => ({ session: {} }) }, + createStore: vi.fn() +})); + import InterpreterActions from '../../application/actions/InterpreterActions'; import { defaultState, @@ -38,6 +46,7 @@ import { } from '../../utils/notifications/NotificationsHelper'; import WorkspaceActions from '../../workspace/WorkspaceActions'; import type { WorkspaceLocation, WorkspaceState } from '../../workspace/WorkspaceTypes'; +import { getVersionHistory, updateVersionName } from '../RequestsSaga'; import workspaceSaga from '../WorkspaceSaga'; import { evalCodeSaga } from '../WorkspaceSaga/helpers/evalCode'; import { evalEditorSaga } from '../WorkspaceSaga/helpers/evalEditor'; @@ -1365,3 +1374,219 @@ describe('EVAL_EDITOR_AND_TESTCASES', () => { .silentRun(2000); }); }); + +describe('VERSION_HISTORY', () => { + // Helper to build state with proper assessment/grading data so getCurrentQuestionId + // can resolve the actual question ID from the question index. + const mockAssessmentId = 42; + const mockQuestionDbId = 101; // The actual DB question ID + const mockQuestionIndex = 0; // The index in the questions array + const mockSubmissionId = 7; + + function versionHistoryState( + workspaceLocation: WorkspaceLocation, + tokens: { accessToken: string; refreshToken: string } + ) { + const workspacePayload: any = { currentQuestion: mockQuestionIndex }; + if (workspaceLocation === 'assessment') { + workspacePayload.currentAssessment = mockAssessmentId; + } + if (workspaceLocation === 'grading') { + workspacePayload.currentSubmission = mockSubmissionId; + } + + const state = generateDefaultState(workspaceLocation, workspacePayload); + + const sessionOverrides: any = { + ...state.session, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken + }; + + if (workspaceLocation === 'assessment') { + sessionOverrides.assessments = { + [mockAssessmentId]: { + questions: [{ id: mockQuestionDbId }] + } + }; + } + + if (workspaceLocation === 'grading') { + sessionOverrides.gradings = { + [mockSubmissionId]: { + answers: [{ question: { id: mockQuestionDbId } }] + } + }; + } + + return { ...state, session: sessionOverrides }; + } + + describe('FETCH_VERSION_HISTORY', () => { + test('fetches version history successfully', () => { + const workspaceLocation = 'assessment'; + const tokens = { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token' + }; + + const mockVersions = [ + { + id: 'v1', + code: 'const x = 1;', + timestamp: 1234567890, + name: 'Version 1' + }, + { + id: 'v2', + code: 'const x = 2;', + timestamp: 1234567900 + } + ]; + + const state = versionHistoryState(workspaceLocation, tokens); + + return expectSaga(workspaceSaga) + .withState(state) + .provide([[matchers.call.fn(getVersionHistory), mockVersions]]) + .put(WorkspaceActions.receiveVersionHistory(workspaceLocation, mockVersions)) + .dispatch({ + type: WorkspaceActions.fetchVersionHistory.type, + payload: { workspaceLocation } + }) + .silentRun(); + }); + + test('returns empty array when no question ID is available', () => { + const workspaceLocation = 'playground'; + const state = generateDefaultState(workspaceLocation); + + return expectSaga(workspaceSaga) + .withState(state) + .put(WorkspaceActions.receiveVersionHistory(workspaceLocation, [])) + .dispatch({ + type: WorkspaceActions.fetchVersionHistory.type, + payload: { workspaceLocation } + }) + .silentRun(); + }); + + test('shows warning when fetch fails', () => { + const workspaceLocation = 'assessment'; + const tokens = { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token' + }; + + const state = versionHistoryState(workspaceLocation, tokens); + + return expectSaga(workspaceSaga) + .withState(state) + .provide([[matchers.call.fn(getVersionHistory), null]]) + .put(WorkspaceActions.receiveVersionHistory(workspaceLocation, [])) + .call(showWarningMessage, 'Failed to load version history') + .dispatch({ + type: WorkspaceActions.fetchVersionHistory.type, + payload: { workspaceLocation } + }) + .silentRun(); + }); + }); + + describe('NAME_VERSION', () => { + test('names version successfully without refetching', () => { + const workspaceLocation = 'assessment'; + const versionId = 'v1'; + const name = 'Final Version'; + const tokens = { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token' + }; + + const state = versionHistoryState(workspaceLocation, tokens); + + const mockResponse = new Response(null, { status: 200, statusText: 'OK' }); + + return expectSaga(workspaceSaga) + .withState(state) + .provide([[matchers.call.fn(updateVersionName), mockResponse]]) + .not.put.actionType(WorkspaceActions.receiveVersionHistory.type) + .dispatch({ + type: WorkspaceActions.nameVersion.type, + payload: { workspaceLocation, versionId, name } + }) + .silentRun(); + }); + + test('shows warning when no question ID is available', () => { + const workspaceLocation = 'playground'; + const versionId = 'v1'; + const name = 'Final Version'; + + const state = generateDefaultState(workspaceLocation); + + return expectSaga(workspaceSaga) + .withState(state) + .call(showWarningMessage, 'Error renaming version: No question ID found') + .dispatch({ + type: WorkspaceActions.nameVersion.type, + payload: { workspaceLocation, versionId, name } + }) + .silentRun(); + }); + + test('shows warning and refetches when naming fails', () => { + const workspaceLocation = 'assessment'; + const versionId = 'v1'; + const name = 'Final Version'; + const tokens = { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token' + }; + + const state = versionHistoryState(workspaceLocation, tokens); + + const mockFailedResponse = new Response(null, { status: 500, statusText: 'Error' }); + + return expectSaga(workspaceSaga) + .withState(state) + .provide([ + [matchers.call.fn(updateVersionName), mockFailedResponse], + [matchers.call.fn(getVersionHistory), []] + ]) + .call(showWarningMessage, 'Failed to rename version') + .put(WorkspaceActions.receiveVersionHistory(workspaceLocation, [])) + .dispatch({ + type: WorkspaceActions.nameVersion.type, + payload: { workspaceLocation, versionId, name } + }) + .silentRun(); + }); + + test('refetches history when response is null', () => { + const workspaceLocation = 'grading'; + const versionId = 'v1'; + const name = 'Final Version'; + const tokens = { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token' + }; + + const state = versionHistoryState(workspaceLocation, tokens); + + return expectSaga(workspaceSaga) + .withState(state) + .provide([ + [matchers.call.fn(updateVersionName), null], + [matchers.call.fn(getVersionHistory), []] + ]) + .call(showWarningMessage, 'Failed to rename version') + .put(WorkspaceActions.receiveVersionHistory(workspaceLocation, [])) + .dispatch({ + type: WorkspaceActions.nameVersion.type, + payload: { workspaceLocation, versionId, name } + }) + .silentRun(); + }); + }); +}); diff --git a/src/commons/workspace/WorkspaceActions.ts b/src/commons/workspace/WorkspaceActions.ts index 2c5db45703..7e38796626 100644 --- a/src/commons/workspace/WorkspaceActions.ts +++ b/src/commons/workspace/WorkspaceActions.ts @@ -12,7 +12,9 @@ import type { HighlightedLines, Position } from '../editor/EditorTypes'; import { createActions } from '../redux/utils'; import type { UploadResult } from '../sideContent/content/SideContentUpload'; import type { + CodeVersion, EditorTabState, + SaveStatus, SubmissionsTableFilters, WorkspaceLocation, WorkspaceLocationsWithTools, @@ -285,7 +287,26 @@ const newActions = createActions('workspace', { decreaseRequestCounter: 0, setGradingHasLoadedBefore: () => true, updateAllColsSortStates: (sortStates: AllColsSortStates) => ({ sortStates }), - updateGradingColumnVisibility: (filters: GradingColumnVisibility) => ({ filters }) + updateGradingColumnVisibility: (filters: GradingColumnVisibility) => ({ filters }), + fetchVersionHistory: (workspaceLocation: WorkspaceLocation) => ({ workspaceLocation }), + receiveVersionHistory: (workspaceLocation: WorkspaceLocation, versions: CodeVersion[]) => ({ + workspaceLocation, + versions + }), + restoreVersion: (workspaceLocation: WorkspaceLocation, versionId: string) => ({ + workspaceLocation, + versionId + }), + nameVersion: (workspaceLocation: WorkspaceLocation, versionId: string, name: string) => ({ + workspaceLocation, + versionId, + name + }), + toggleHistoryPanel: (workspaceLocation: WorkspaceLocation) => ({ workspaceLocation }), + updateSaveStatus: (workspaceLocation: WorkspaceLocation, saveStatus: SaveStatus) => ({ + workspaceLocation, + saveStatus + }) }); export default newActions; diff --git a/src/commons/workspace/WorkspaceReducer.ts b/src/commons/workspace/WorkspaceReducer.ts index 2238d543c8..10f5020ed0 100644 --- a/src/commons/workspace/WorkspaceReducer.ts +++ b/src/commons/workspace/WorkspaceReducer.ts @@ -396,5 +396,43 @@ const newWorkspaceReducer = createReducer(defaultWorkspaceManager, builder => { .addCase(setUpdateUserRoleCallback, (state, action) => { const workspaceLocation = getWorkspaceLocation(action); state[workspaceLocation].updateUserRoleCallback = action.payload.updateUserRoleCallback; + }) + .addCase(WorkspaceActions.fetchVersionHistory, (state, action) => { + const workspaceLocation = getWorkspaceLocation(action); + state[workspaceLocation].versionHistory.isLoading = true; + }) + .addCase(WorkspaceActions.receiveVersionHistory, (state, action) => { + const workspaceLocation = getWorkspaceLocation(action); + state[workspaceLocation].versionHistory.versions = action.payload.versions; + state[workspaceLocation].versionHistory.isLoading = false; + }) + .addCase(WorkspaceActions.restoreVersion, (state, action) => { + const workspaceLocation = getWorkspaceLocation(action); + const workspace = state[workspaceLocation]; + const version = workspace.versionHistory.versions.find( + v => v.id === action.payload.versionId + ); + if (!version) return; + if (workspace.activeEditorTabIndex !== null) { + workspace.editorTabs[workspace.activeEditorTabIndex].value = version.code; + } + }) + .addCase(WorkspaceActions.toggleHistoryPanel, (state, action) => { + const workspaceLocation = getWorkspaceLocation(action); + state[workspaceLocation].versionHistory.isHistoryPanelOpen = + !state[workspaceLocation].versionHistory.isHistoryPanelOpen; + }) + .addCase(WorkspaceActions.nameVersion, (state, action) => { + const workspaceLocation = getWorkspaceLocation(action); + const version = state[workspaceLocation].versionHistory.versions.find( + v => v.id === action.payload.versionId + ); + if (version) { + version.name = action.payload.name; + } + }) + .addCase(WorkspaceActions.updateSaveStatus, (state, action) => { + const workspaceLocation = getWorkspaceLocation(action); + state[workspaceLocation].saveStatus = action.payload.saveStatus; }); }); diff --git a/src/commons/workspace/WorkspaceTypes.ts b/src/commons/workspace/WorkspaceTypes.ts index 5511dec4d2..f948eecf1d 100644 --- a/src/commons/workspace/WorkspaceTypes.ts +++ b/src/commons/workspace/WorkspaceTypes.ts @@ -18,6 +18,21 @@ export const EVAL_SILENT = 'EVAL_SILENT'; export type WorkspaceLocation = keyof WorkspaceManagerState; export type WorkspaceLocationsWithTools = Extract; +export type CodeVersion = { + readonly id: string; + readonly code: string; + readonly timestamp: number; + readonly name?: string; +}; + +export type VersionHistoryState = { + readonly versions: CodeVersion[]; + readonly isLoading: boolean; + readonly isHistoryPanelOpen: boolean; +}; + +export type SaveStatus = 'idle' | 'saving' | 'saved' | 'saveFailed'; + type AssessmentWorkspaceAttr = { readonly currentAssessment?: number; readonly currentQuestion?: number; @@ -108,6 +123,8 @@ export type WorkspaceState = { readonly lastDebuggerResult: any; readonly files: UploadResult; readonly updateUserRoleCallback: (id: string, newRole: CollabEditingAccess) => void; + readonly versionHistory: VersionHistoryState; + readonly saveStatus: SaveStatus; }; type ReplHistory = { diff --git a/src/commons/workspace/__tests__/WorkspaceActions.test.ts b/src/commons/workspace/__tests__/WorkspaceActions.test.ts index 09a63ecd8a..4bd064268c 100644 --- a/src/commons/workspace/__tests__/WorkspaceActions.test.ts +++ b/src/commons/workspace/__tests__/WorkspaceActions.test.ts @@ -606,3 +606,75 @@ test('toggleUsingSubst generates correct action object', () => { }); // TODO: Add toggleusingcse + +// Version History Actions Tests + +test('fetchVersionHistory generates correct action object', () => { + const action = WorkspaceActions.fetchVersionHistory(gradingWorkspace); + expect(action).toEqual({ + type: WorkspaceActions.fetchVersionHistory.type, + payload: { + workspaceLocation: gradingWorkspace + } + }); +}); + +test('receiveVersionHistory generates correct action object', () => { + const versions = [ + { + id: 'v1', + code: 'const x = 1;', + timestamp: 1234567890, + name: 'Version 1' + }, + { + id: 'v2', + code: 'const x = 2;', + timestamp: 1234567900 + } + ]; + const action = WorkspaceActions.receiveVersionHistory(playgroundWorkspace, versions); + expect(action).toEqual({ + type: WorkspaceActions.receiveVersionHistory.type, + payload: { + workspaceLocation: playgroundWorkspace, + versions + } + }); +}); + +test('restoreVersion generates correct action object', () => { + const versionId = 'v123'; + const action = WorkspaceActions.restoreVersion(assessmentWorkspace, versionId); + expect(action).toEqual({ + type: WorkspaceActions.restoreVersion.type, + payload: { + workspaceLocation: assessmentWorkspace, + versionId + } + }); +}); + +test('nameVersion generates correct action object', () => { + const versionId = 'v123'; + const name = 'Final Version'; + const action = WorkspaceActions.nameVersion(gradingWorkspace, versionId, name); + expect(action).toEqual({ + type: WorkspaceActions.nameVersion.type, + payload: { + workspaceLocation: gradingWorkspace, + versionId, + name + } + }); +}); + +test('toggleHistoryPanel generates correct action object', () => { + const action = WorkspaceActions.toggleHistoryPanel(playgroundWorkspace); + expect(action).toEqual({ + type: WorkspaceActions.toggleHistoryPanel.type, + payload: { + workspaceLocation: playgroundWorkspace + } + }); +}); diff --git a/src/commons/workspace/__tests__/WorkspaceReducer.test.ts b/src/commons/workspace/__tests__/WorkspaceReducer.test.ts index 2b43499e06..378e998026 100644 --- a/src/commons/workspace/__tests__/WorkspaceReducer.test.ts +++ b/src/commons/workspace/__tests__/WorkspaceReducer.test.ts @@ -2542,4 +2542,306 @@ describe('TOGGLE_USING_SUBST', () => { }); }); +// Version History Reducer Tests + +describe('FETCH_VERSION_HISTORY', () => { + test('sets isLoading to true when fetching version history', () => { + const actions = generateActions(l => WorkspaceActions.fetchVersionHistory(l)); + + actions.forEach(action => { + const result = WorkspaceReducer(defaultWorkspaceManager, action); + const location: WorkspaceLocation = action.payload.workspaceLocation; + + expect(result).toEqual({ + ...defaultWorkspaceManager, + [location]: { + ...defaultWorkspaceManager[location], + versionHistory: { + ...defaultWorkspaceManager[location].versionHistory, + isLoading: true + } + } + }); + }); + }); +}); + +describe('RECEIVE_VERSION_HISTORY', () => { + test('updates versions and sets isLoading to false', () => { + const versions = [ + { + id: 'v1', + code: 'const x = 1;', + timestamp: 1234567890, + name: 'Version 1' + }, + { + id: 'v2', + code: 'const x = 2;', + timestamp: 1234567900 + } + ]; + + const actions = generateActions(l => WorkspaceActions.receiveVersionHistory(l, versions)); + + actions.forEach(action => { + const result = WorkspaceReducer(defaultWorkspaceManager, action); + const location: WorkspaceLocation = action.payload.workspaceLocation; + + expect(result).toEqual({ + ...defaultWorkspaceManager, + [location]: { + ...defaultWorkspaceManager[location], + versionHistory: { + ...defaultWorkspaceManager[location].versionHistory, + versions, + isLoading: false + } + } + }); + }); + }); + + test('handles empty versions array', () => { + const versions: any[] = []; + const actions = generateActions(l => WorkspaceActions.receiveVersionHistory(l, versions)); + + actions.forEach(action => { + const result = WorkspaceReducer(defaultWorkspaceManager, action); + const location: WorkspaceLocation = action.payload.workspaceLocation; + + expect(result).toEqual({ + ...defaultWorkspaceManager, + [location]: { + ...defaultWorkspaceManager[location], + versionHistory: { + ...defaultWorkspaceManager[location].versionHistory, + versions: [], + isLoading: false + } + } + }); + }); + }); +}); + +describe('RESTORE_VERSION', () => { + test('restores version code to active editor tab', () => { + const versionId = 'v1'; + const versionCode = 'const restored = true;'; + + // Create a state with version history + const stateWithVersions: WorkspaceManagerState = { + ...defaultWorkspaceManager, + assessment: { + ...defaultWorkspaceManager.assessment, + activeEditorTabIndex: 0, + editorTabs: [ + { + value: 'const old = false;', + highlightedLines: [], + breakpoints: [] + } + ], + versionHistory: { + versions: [ + { + id: versionId, + code: versionCode, + timestamp: 1234567890, + name: 'Test Version' + } + ], + isLoading: false, + isHistoryPanelOpen: false + } + } + }; + + const action = WorkspaceActions.restoreVersion('assessment', versionId); + const result = WorkspaceReducer(stateWithVersions, action); + + expect(result.assessment.editorTabs[0].value).toEqual(versionCode); + }); + + test('does not restore if version not found', () => { + const versionId = 'non-existent'; + const action = WorkspaceActions.restoreVersion('assessment', versionId); + const result = WorkspaceReducer(defaultWorkspaceManager, action); + + // State should remain unchanged + expect(result).toEqual(defaultWorkspaceManager); + }); + + test('does not restore if no active editor tab', () => { + const versionId = 'v1'; + const versionCode = 'const restored = true;'; + + const stateWithNoActiveTab: WorkspaceManagerState = { + ...defaultWorkspaceManager, + assessment: { + ...defaultWorkspaceManager.assessment, + activeEditorTabIndex: null, + versionHistory: { + versions: [ + { + id: versionId, + code: versionCode, + timestamp: 1234567890 + } + ], + isLoading: false, + isHistoryPanelOpen: false + } + } + }; + + const action = WorkspaceActions.restoreVersion('assessment', versionId); + const result = WorkspaceReducer(stateWithNoActiveTab, action); + + // State should remain unchanged - no active tab to restore to + expect(result.assessment.activeEditorTabIndex).toBeNull(); + }); +}); + +describe('TOGGLE_HISTORY_PANEL', () => { + test('toggles isHistoryPanelOpen from false to true', () => { + const actions = generateActions(l => WorkspaceActions.toggleHistoryPanel(l)); + + actions.forEach(action => { + const result = WorkspaceReducer(defaultWorkspaceManager, action); + const location: WorkspaceLocation = action.payload.workspaceLocation; + + expect(result).toEqual({ + ...defaultWorkspaceManager, + [location]: { + ...defaultWorkspaceManager[location], + versionHistory: { + ...defaultWorkspaceManager[location].versionHistory, + isHistoryPanelOpen: true + } + } + }); + }); + }); + + test('toggles isHistoryPanelOpen from true to false', () => { + const stateWithPanelOpen: WorkspaceManagerState = { + ...defaultWorkspaceManager, + assessment: { + ...defaultWorkspaceManager.assessment, + versionHistory: { + ...defaultWorkspaceManager.assessment.versionHistory, + isHistoryPanelOpen: true + } + } + }; + + const action = WorkspaceActions.toggleHistoryPanel('assessment'); + const result = WorkspaceReducer(stateWithPanelOpen, action); + + expect(result.assessment.versionHistory.isHistoryPanelOpen).toBe(false); + }); +}); + +describe('NAME_VERSION', () => { + test('updates version name correctly', () => { + const versionId = 'v1'; + const newName = 'Final Version'; + + const stateWithVersions: WorkspaceManagerState = { + ...defaultWorkspaceManager, + assessment: { + ...defaultWorkspaceManager.assessment, + versionHistory: { + versions: [ + { + id: versionId, + code: 'const x = 1;', + timestamp: 1234567890 + }, + { + id: 'v2', + code: 'const x = 2;', + timestamp: 1234567900 + } + ], + isLoading: false, + isHistoryPanelOpen: false + } + } + }; + + const action = WorkspaceActions.nameVersion('assessment', versionId, newName); + const result = WorkspaceReducer(stateWithVersions, action); + + const updatedVersion = result.assessment.versionHistory.versions.find(v => v.id === versionId); + expect(updatedVersion?.name).toEqual(newName); + + // Other versions should remain unchanged + const otherVersion = result.assessment.versionHistory.versions.find(v => v.id === 'v2'); + expect(otherVersion?.name).toBeUndefined(); + }); + + test('does not update if version not found', () => { + const versionId = 'non-existent'; + const newName = 'Should Not Apply'; + + const stateWithVersions: WorkspaceManagerState = { + ...defaultWorkspaceManager, + assessment: { + ...defaultWorkspaceManager.assessment, + versionHistory: { + versions: [ + { + id: 'v1', + code: 'const x = 1;', + timestamp: 1234567890 + } + ], + isLoading: false, + isHistoryPanelOpen: false + } + } + }; + + const action = WorkspaceActions.nameVersion('assessment', versionId, newName); + const result = WorkspaceReducer(stateWithVersions, action); + + // Version should not have the new name + const version = result.assessment.versionHistory.versions.find(v => v.id === 'v1'); + expect(version?.name).toBeUndefined(); + }); + + test('can clear version name by setting it to empty string', () => { + const versionId = 'v1'; + const newName = ''; + + const stateWithVersions: WorkspaceManagerState = { + ...defaultWorkspaceManager, + assessment: { + ...defaultWorkspaceManager.assessment, + versionHistory: { + versions: [ + { + id: versionId, + code: 'const x = 1;', + timestamp: 1234567890, + name: 'Old Name' + } + ], + isLoading: false, + isHistoryPanelOpen: false + } + } + }; + + const action = WorkspaceActions.nameVersion('assessment', versionId, newName); + const result = WorkspaceReducer(stateWithVersions, action); + + const updatedVersion = result.assessment.versionHistory.versions.find(v => v.id === versionId); + expect(updatedVersion?.name).toEqual(''); + }); +}); + // TODO: Add toggleusingcse diff --git a/src/pages/academy/grading/subcomponents/GradingWorkspace.tsx b/src/pages/academy/grading/subcomponents/GradingWorkspace.tsx index b90be5e818..e9ee742672 100644 --- a/src/pages/academy/grading/subcomponents/GradingWorkspace.tsx +++ b/src/pages/academy/grading/subcomponents/GradingWorkspace.tsx @@ -27,6 +27,8 @@ import { ControlBarNextButton } from '../../../../commons/controlBar/ControlBarN import { ControlBarPreviousButton } from '../../../../commons/controlBar/ControlBarPreviousButton'; import { ControlBarQuestionViewButton } from '../../../../commons/controlBar/ControlBarQuestionViewButton'; import { ControlBarRunButton } from '../../../../commons/controlBar/ControlBarRunButton'; +import { ControlBarVersionHistoryButton } from '../../../../commons/controlBar/ControlBarVersionHistoryButton'; +import { VersionHistoryPanel } from '../../../../commons/controlBar/VersionHistoryPanel'; import { convertEditorTabStateToProps } from '../../../../commons/editor/EditorContainer'; import { Position } from '../../../../commons/editor/EditorTypes'; import Markdown from '../../../../commons/Markdown'; @@ -87,7 +89,8 @@ const GradingWorkspace: React.FC = props => { output, replValue, currentSubmission: storedSubmissionId, - currentQuestion: storedQuestionId + currentQuestion: storedQuestionId, + versionHistory } = useTypedSelector(state => state.workspaces[workspaceLocation]); const dispatch = useDispatch(); @@ -112,7 +115,11 @@ const GradingWorkspace: React.FC = props => { handleRunAllTestcases, handleUpdateCurrentSubmissionId, handleUpdateHasUnsavedChanges, - handlePromptAutocomplete + handlePromptAutocomplete, + handleFetchVersionHistory, + handleToggleHistoryPanel, + handleRestoreVersion, + handleNameVersion } = useMemo(() => { return { handleBrowseHistoryDown: () => @@ -156,7 +163,15 @@ const GradingWorkspace: React.FC = props => { handleUpdateHasUnsavedChanges: (unsavedChanges: boolean) => dispatch(WorkspaceActions.updateHasUnsavedChanges(workspaceLocation, unsavedChanges)), handlePromptAutocomplete: (row: number, col: number, callback: any) => - dispatch(WorkspaceActions.promptAutocomplete(workspaceLocation, row, col, callback)) + dispatch(WorkspaceActions.promptAutocomplete(workspaceLocation, row, col, callback)), + handleFetchVersionHistory: () => + dispatch(WorkspaceActions.fetchVersionHistory(workspaceLocation)), + handleToggleHistoryPanel: () => + dispatch(WorkspaceActions.toggleHistoryPanel(workspaceLocation)), + handleRestoreVersion: (versionId: string) => + dispatch(WorkspaceActions.restoreVersion(workspaceLocation, versionId)), + handleNameVersion: (versionId: string, name: string) => + dispatch(WorkspaceActions.nameVersion(workspaceLocation, versionId, name)) }; }, [dispatch]); @@ -450,8 +465,18 @@ const GradingWorkspace: React.FC = props => { /> ); + const versionHistoryButton = ( + { + handleFetchVersionHistory(); + handleToggleHistoryPanel(); + }} + key="version_history" + /> + ); + return { - editorButtons: [runButton], + editorButtons: [runButton, versionHistoryButton], flowButtons: [previousButton, questionView, nextButton] }; }; @@ -541,6 +566,14 @@ const GradingWorkspace: React.FC = props => { }; return (
+
); diff --git a/src/pages/playground/__tests__/__snapshots__/Playground.test.tsx.snap b/src/pages/playground/__tests__/__snapshots__/Playground.test.tsx.snap index 3502f5df4d..d6857c8c32 100644 --- a/src/pages/playground/__tests__/__snapshots__/Playground.test.tsx.snap +++ b/src/pages/playground/__tests__/__snapshots__/Playground.test.tsx.snap @@ -410,7 +410,6 @@ exports[`Playground tests > Playground renders correctly 1`] = ` aria-haspopup="false" aria-label="Cursor at row 1" autocapitalize="off" - autocomplete="off" autocorrect="off" class="ace_text-input" role="textbox" @@ -496,11 +495,10 @@ exports[`Playground tests > Playground renders correctly 1`] = `
Playground with link renders correctly 1`] = ` aria-haspopup="false" aria-label="Cursor at row 1" autocapitalize="off" - autocomplete="off" autocorrect="off" class="ace_text-input" role="textbox" @@ -2093,11 +2088,10 @@ exports[`Playground tests > Playground with link renders correctly 1`] = `