Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import AiTutorVersionFileChip from '@cdo/apps/aiComponentLibrary/aiTutorVersionF
import Lab2Registry from '@cdo/apps/lab2/Lab2Registry';
import {isViewingAiTutorVersionFileUpdates} from '@cdo/apps/lab2/redux/lab2ReduxSelectors';
import {ProjectFile} from '@cdo/apps/lab2/types';
import {DialogType, useDialogControl} from '@cdo/apps/lab2/views/dialogs';
import {getAuthenticityToken} from '@cdo/apps/util/AuthenticityTokenStore';
import {useAppSelector, useAppDispatch} from '@cdo/apps/util/reduxHooks';
import getRejectNotification from '@cdo/apps/weblab2/helpers/getRejectNotification';
Expand Down Expand Up @@ -40,6 +41,48 @@ const AiTutorVersionActions: React.FC<AiTutorVersionActionsProps> = ({
);

const dispatch = useAppDispatch();
const dialogControl = useDialogControl();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: maybe as a follow-up down the line, we could extract the navigation guard effects in a hook.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good thought, I like it!


const handleReject = useCallback(async () => {
await dispatch(rejectAiTutorVersion(files));
}, [dispatch, files]);

// Presents a confirmation dialog if the user attempts to navigate to another lab2 level with unsaved AI Tutor changes.
// If the user confirms they want to navigate away, we log a "reject" event and reject the proposed changes.
useEffect(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a comment here and the 'Block in-app level navigation...'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks! Added one to the useEffect below as well.

Lab2Registry.getInstance().setLevelNavigationConfirmation(async () => {
if (!dialogControl) {
return true;
}

const {type} = await dialogControl.showDialog({
type: DialogType.GenericDialog,
title: 'Please review AI Tutor changes',
message:
"AI Tutor has made changes that you haven't accepted or rejected. If you exit this level, those changes will be lost. Are you sure you want to continue?",
icon: {iconName: 'triangle-exclamation', iconStyle: 'solid'},
showCloseButton: false,
buttons: {
confirm: {
text: 'Stay on this level',
},
cancel: {
text: 'Continue anyway',
},
},
});

if (type !== 'cancel') {
return false;
}

await handleReject();
return true;
});
return () => {
Lab2Registry.getInstance().setLevelNavigationConfirmation(undefined);
};
}, [dialogControl, handleReject]);

// Warn the user if they attempt to reload the page before accepting or
// rejecting the proposed updates.
Expand All @@ -55,6 +98,7 @@ const AiTutorVersionActions: React.FC<AiTutorVersionActionsProps> = ({
};
}, []);

// Logs a "reject" event if the user navigates away from the page without accepting or rejecting AI Tutor's proposed changes.
useEffect(() => {
const possiblyRejectOnPageHide = async (event: PageTransitionEvent) => {
if (viewingAiTutorVersionFileUpdates) {
Expand Down Expand Up @@ -95,10 +139,6 @@ const AiTutorVersionActions: React.FC<AiTutorVersionActionsProps> = ({
}
}, [dispatch, files, commitDescription, isSaving]);

const handleReject = useCallback(() => {
dispatch(rejectAiTutorVersion(files));
}, [dispatch, files]);

// Scroll to the bottom of the page when switching to accept mode,
// so that the commit description input and save button are visible to the user.
useLayoutEffect(() => {
Expand Down
33 changes: 28 additions & 5 deletions apps/src/code-studio/browserNavigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
// levels without doing page reloads.

import {setCurrentLevelId} from '@cdo/apps/code-studio/progressRedux';
import {levelById} from '@cdo/apps/code-studio/progressReduxSelectors';

import Lab2Registry from '../lab2/Lab2Registry';
import notifyLevelChange from '../lab2/utils/notifyLevelChange';
import {getStore} from '../redux';

Expand All @@ -30,16 +32,37 @@ export function canChangeLevelInPage(currentLevel, newLevel) {
export function setupNavigationHandler(initialLevelId) {
// Store the starting level ID in the browser history stack.
window.history.replaceState({levelId: initialLevelId}, '');
window.addEventListener('popstate', function (event) {
window.addEventListener('popstate', async function (event) {
const levelId = event.state?.levelId;
if (!levelId) {
return;
}
const store = getStore();
const progressStoreState = store.getState().progress;
const previousLevelId = progressStoreState.currentLevelId;
const levelNavigationConfirmation =
Lab2Registry.getInstance().getLevelNavigationConfirmation();
if (levelNavigationConfirmation && !(await levelNavigationConfirmation())) {
// Restore URL history for the current level when navigation is canceled.
if (previousLevelId && progressStoreState.currentLessonId) {
const previousLevel = levelById(
progressStoreState,
progressStoreState.currentLessonId,
previousLevelId
);
if (previousLevel?.path) {
window.history.pushState(
{levelId: previousLevelId},
'',
previousLevel.path + window.location.search
);
}
}
return;
}
// Notify the Lab2 system (that handles changing levels without reload) about the level change.
// The browser history API does not provide access to the state of the page we just came from,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is true, but we're able to reconstruct the "from" page from Redux (see new logic), so I removed this comment.

// so we don't know the previous level ID.
notifyLevelChange(null, levelId);
getStore().dispatch(setCurrentLevelId(levelId));
notifyLevelChange(previousLevelId || null, levelId);
store.dispatch(setCurrentLevelId(levelId));
});
}

Expand Down
5 changes: 5 additions & 0 deletions apps/src/code-studio/progressRedux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,11 @@ export function navigateToLevelId(levelId: string): ProgressThunkAction {
}

const currentLevel = getCurrentLevel(getState());
const levelNavigationConfirmation =
Lab2Registry.getInstance().getLevelNavigationConfirmation();
if (levelNavigationConfirmation && !(await levelNavigationConfirmation())) {
return;
}

if (canChangeLevelInPage(currentLevel, newLevel)) {
// If the requested level is the same as the current level, don't do anything.
Expand Down
14 changes: 13 additions & 1 deletion apps/src/lab2/Lab2Registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {Theme} from '@code-dot-org/component-library/common/contexts';

import LabMetricsReporter from './Lab2MetricsReporter';
import ProjectManager from './projects/ProjectManager';
import {AppName} from './types';
import {AppName, LevelNavigationConfirmation} from './types';
import LifecycleNotifier from './utils/LifecycleNotifier';

export default class Lab2Registry {
Expand All @@ -13,6 +13,7 @@ export default class Lab2Registry {
private lifecycleNotifier: LifecycleNotifier;
private appName: AppName | null;
private theme: Theme | undefined;
private levelNavigationConfirmation: LevelNavigationConfirmation | undefined;

private static _instance: Lab2Registry;

Expand All @@ -22,6 +23,7 @@ export default class Lab2Registry {
this.lifecycleNotifier = new LifecycleNotifier();
this.appName = null;
this.theme = undefined;
this.levelNavigationConfirmation = undefined;
}

public static getInstance(): Lab2Registry {
Expand Down Expand Up @@ -77,4 +79,14 @@ export default class Lab2Registry {
public getTheme() {
return this.theme;
}

public getLevelNavigationConfirmation() {
return this.levelNavigationConfirmation;
}

public setLevelNavigationConfirmation(
confirmation: LevelNavigationConfirmation | undefined
) {
this.levelNavigationConfirmation = confirmation;
}
}
2 changes: 2 additions & 0 deletions apps/src/lab2/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,3 +462,5 @@ export interface LabProps<
}

export type ShareDialogId = 'hoc2024' | 'hoai2025';

export type LevelNavigationConfirmation = () => boolean | Promise<boolean>;
8 changes: 7 additions & 1 deletion apps/src/lab2/views/dialogs/GenericDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ export type GenericDialogProps = GenericDialogTitleProps &
// with divider lines separating the body content from the title and action buttons.
useModal?: boolean;
icon?: FontAwesomeV6IconProps;
/**
* Controls whether the dialog close button (top-right X, as well as escape key close)
* is enabled. Defaults to true.
*/
showCloseButton?: boolean;
};

import moduleStyles from './generic-dialog.module.scss';
Expand Down Expand Up @@ -104,6 +109,7 @@ const GenericDialog: React.FunctionComponent<GenericDialogProps> = ({
getButtonCallback = defaultGetButtonCallback,
useModal = false,
icon,
showCloseButton = true,
}) => {
const dialogControl = useDialogControl();

Expand Down Expand Up @@ -165,7 +171,7 @@ const GenericDialog: React.FunctionComponent<GenericDialogProps> = ({
</MuiButton>
) : undefined
}
onClose={buttons?.cancel ? cancelCallback : undefined}
onClose={showCloseButton && buttons?.cancel ? cancelCallback : undefined}
className={classNames(
moduleStyles.genericDialog,
isDestructive && moduleStyles.destructive,
Expand Down
Loading