+
);
diff --git a/src/commons/editor/UseShareAce.tsx b/src/commons/editor/UseShareAce.tsx
index 8a08ba0aba..61816a7fb9 100644
--- a/src/commons/editor/UseShareAce.tsx
+++ b/src/commons/editor/UseShareAce.tsx
@@ -1,11 +1,21 @@
import '@convergencelabs/ace-collab-ext/dist/css/ace-collab-ext.css';
-import { AceMultiCursorManager } from '@convergencelabs/ace-collab-ext';
+import {
+ AceMultiCursorManager,
+ AceMultiSelectionManager,
+ AceRadarView
+} from '@convergencelabs/ace-collab-ext';
import * as Sentry from '@sentry/browser';
import sharedbAce from '@sourceacademy/sharedb-ace';
-import React, { useMemo } from 'react';
+import type SharedbAceBinding from '@sourceacademy/sharedb-ace/binding';
+import { CollabEditingAccess } from '@sourceacademy/sharedb-ace/types';
+import React from 'react';
+import { useDispatch } from 'react-redux';
+import { getLanguageConfig } from '../application/ApplicationTypes';
+import CollabEditingActions from '../collabEditing/CollabEditingActions';
import { getDocInfoFromSessionId, getSessionUrl } from '../collabEditing/CollabEditingHelper';
+import { parseModeString } from '../utils/AceHelper';
import { useSession } from '../utils/Hooks';
import { showSuccessMessage } from '../utils/notifications/NotificationsHelper';
import { EditorHook } from './Editor';
@@ -17,48 +27,116 @@ import { EditorHook } from './Editor';
// keyBindings allow exporting new hotkeys
// reactAceRef is the underlying reactAce instance for hooking.
+const color = getColor();
+
const useShareAce: EditorHook = (inProps, outProps, keyBindings, reactAceRef) => {
// use a ref to refer to any other props so that we run the effect below
- // *only* when the editorSessionId changes
+ // *only* when the editorSessionId or sessionDetails changes
const propsRef = React.useRef(inProps);
propsRef.current = inProps;
const { editorSessionId, sessionDetails } = inProps;
-
- const { name } = useSession();
-
- const user = useMemo(() => ({ name, color: getColor() }), [name]);
+ const { name, userId } = useSession();
+ const dispatch = useDispatch();
React.useEffect(() => {
if (!editorSessionId || !sessionDetails) {
return;
}
+ const collabEditorAccess = sessionDetails.owner
+ ? CollabEditingAccess.OWNER
+ : sessionDetails.readOnly
+ ? CollabEditingAccess.VIEWER
+ : CollabEditingAccess.EDITOR;
+
+ const user = {
+ name: name || 'Unnamed user',
+ color,
+ role: collabEditorAccess
+ };
+
const editor = reactAceRef.current!.editor;
- const cursorManager = new AceMultiCursorManager(editor.getSession());
+ const session = editor.getSession();
+ // TODO: Hover over the indicator to show the username as well
+ const cursorManager = new AceMultiCursorManager(session);
+ const selectionManager = new AceMultiSelectionManager(session);
+ const radarManager = new AceRadarView('ace-radar-view', editor);
+
+ // @ts-expect-error hotfix to remove all views in radarManager
+ radarManager.removeAllViews = () => {
+ // @ts-expect-error hotfix to remove all views in radarManager
+ for (const id in radarManager._views) {
+ radarManager.removeView(id);
+ }
+ };
+
const ShareAce = new sharedbAce(sessionDetails.docId, {
user,
- cursorManager,
WsUrl: getSessionUrl(editorSessionId, true),
- pluginWsUrl: null,
namespace: 'sa'
});
- ShareAce.on('ready', () => {
- ShareAce.add(editor, cursorManager, ['contents'], []);
+ const updateUsers = (binding: SharedbAceBinding) => {
+ if (binding.connectedUsers === undefined) {
+ return;
+ }
+ propsRef.current.setUsers?.(binding.connectedUsers);
+ const myUserId = Object.keys(ShareAce.usersPresence.localPresences)[0];
+ if (binding.connectedUsers[myUserId].role !== user.role) {
+ // Change in role, update readOnly status in sessionDetails
+ dispatch(
+ CollabEditingActions.setSessionDetails('playground', {
+ readOnly: binding.connectedUsers[myUserId].role === CollabEditingAccess.VIEWER
+ })
+ );
+ }
+ };
+
+ const shareAceReady = () => {
+ if (!sessionDetails) {
+ return;
+ }
+ const binding = ShareAce.add(
+ editor,
+ ['contents'],
+ {
+ cursorManager,
+ selectionManager,
+ radarManager
+ },
+ {
+ languageSelectHandler: (language: string) => {
+ const { chapter, variant } = parseModeString(language);
+ propsRef.current.updateLanguageCallback?.(getLanguageConfig(chapter, variant), null);
+ }
+ }
+ );
propsRef.current.handleSetSharedbConnected!(true);
+ dispatch(
+ CollabEditingActions.setUpdateUserRoleCallback('playground', binding.changeUserRole)
+ );
// Disables editor in a read-only session
editor.setReadOnly(sessionDetails.readOnly);
+ navigator.clipboard.writeText(editorSessionId).then(() => {
+ showSuccessMessage(
+ `You have joined a session as ${sessionDetails.readOnly ? 'a viewer' : 'an editor'}. Copied to clipboard: ${editorSessionId}`
+ );
+ });
+
+ updateUsers(binding);
+ binding.usersPresence.on('receive', () => updateUsers(binding));
+ window.history.pushState({}, document.title, '/playground/' + editorSessionId);
+ };
- showSuccessMessage(
- 'You have joined a session as ' + (sessionDetails.readOnly ? 'a viewer.' : 'an editor.')
- );
- });
- ShareAce.on('error', (path: string, error: any) => {
+ const shareAceError = (path: string, error: any) => {
console.error('ShareAce error', error);
Sentry.captureException(error);
- });
+ };
+
+ ShareAce.on('ready', shareAceReady);
+ ShareAce.on('error', shareAceError);
// WebSocket connection status detection logic
const WS = ShareAce.WS;
@@ -96,13 +174,29 @@ const useShareAce: EditorHook = (inProps, outProps, keyBindings, reactAceRef) =>
}
ShareAce.WS.close();
+ ShareAce.off('ready', shareAceReady);
+ ShareAce.off('error', shareAceError);
+
// Resets editor to normal after leaving the session
editor.setReadOnly(false);
// Removes all cursors
cursorManager.removeAll();
+
+ // Removes all selections
+ selectionManager.removeAll();
+
+ // @ts-expect-error hotfix to remove all views in radarManager
+ radarManager.removeAllViews();
+
+ if (
+ window.location.href.includes('/playground') &&
+ !window.location.href.endsWith('/playground')
+ ) {
+ window.history.pushState({}, document.title, '/playground');
+ }
};
- }, [editorSessionId, sessionDetails, reactAceRef, user]);
+ }, [editorSessionId, sessionDetails, reactAceRef, userId, name, dispatch]);
};
function getColor() {
diff --git a/src/commons/navigationBar/NavigationBar.tsx b/src/commons/navigationBar/NavigationBar.tsx
index 01b0c770f4..5bb3a65e6c 100644
--- a/src/commons/navigationBar/NavigationBar.tsx
+++ b/src/commons/navigationBar/NavigationBar.tsx
@@ -258,7 +258,7 @@ const NavigationBar: React.FC = () => {
const commonNavbarRight = (
- {location.pathname.startsWith('/playground') && }
+
classNames('NavigationBar__link', Classes.BUTTON, Classes.MINIMAL, {
@@ -302,7 +302,7 @@ const NavigationBar: React.FC = () => {
-
+
diff --git a/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap b/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap
index a4985fa33f..b8d8bb6f04 100644
--- a/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap
+++ b/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap
@@ -85,6 +85,7 @@ exports[`NavigationBar Renders "Not logged in" correctly 1`] = `
+
+
+
=>
- action.type === SideContentActions.spawnSideContent.type;
+ action.type === SideContentActions.spawnSideContent.type ||
+ (action as any).payload?.id !== SideContentType.sessionManagement;
+// hotfix check here to allow for blinking during session update
const SideContentSaga = combineSagaHandlers({
[SideContentActions.beginAlertSideContent.type]: function* ({
diff --git a/src/commons/sagas/__tests__/SideContentSaga.ts b/src/commons/sagas/__tests__/SideContentSaga.ts
index d10432ebfc..cf4bb03663 100644
--- a/src/commons/sagas/__tests__/SideContentSaga.ts
+++ b/src/commons/sagas/__tests__/SideContentSaga.ts
@@ -48,7 +48,7 @@ describe('Side Content Alerts for normal side content', () => {
expect(storeState).toMatchObject({
playground: {
dynamicTabs: [mockTab],
- alerts: ['tab2', SideContentType.cseMachine, SideContentType.dataVisualizer]
+ alerts: ['tab2', SideContentType.dataVisualizer]
}
});
});
diff --git a/src/commons/sideContent/SideContentTypes.ts b/src/commons/sideContent/SideContentTypes.ts
index f4598968ca..b45e457392 100644
--- a/src/commons/sideContent/SideContentTypes.ts
+++ b/src/commons/sideContent/SideContentTypes.ts
@@ -26,6 +26,7 @@ export enum SideContentType {
questionOverview = 'question_overview',
remoteExecution = 'remote_execution',
scoreLeaderboard = 'score_leaderboard',
+ sessionManagement = 'session_management',
missionMetadata = 'mission_metadata',
mobileEditor = 'mobile_editor',
mobileEditorRun = 'mobile_editor_run',
diff --git a/src/commons/sideContent/content/SideContentSessionManagement.tsx b/src/commons/sideContent/content/SideContentSessionManagement.tsx
new file mode 100644
index 0000000000..99c1f307ba
--- /dev/null
+++ b/src/commons/sideContent/content/SideContentSessionManagement.tsx
@@ -0,0 +1,210 @@
+import { Classes, HTMLTable, Icon, Switch } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import { CollabEditingAccess, type SharedbAceUser } from '@sourceacademy/sharedb-ace/types';
+import classNames from 'classnames';
+import React, { useEffect, useState } from 'react';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { useDispatch } from 'react-redux';
+import {
+ changeDefaultEditable,
+ getPlaygroundSessionUrl
+} from 'src/commons/collabEditing/CollabEditingHelper';
+import { useTypedSelector } from 'src/commons/utils/Hooks';
+import { showSuccessMessage } from 'src/commons/utils/notifications/NotificationsHelper';
+import classes from 'src/styles/SideContentSessionManagement.module.scss';
+
+import { beginAlertSideContent } from '../SideContentActions';
+import { SideContentLocation, SideContentType } from '../SideContentTypes';
+
+interface AdminViewProps {
+ users: Record;
+ playgroundCode: string;
+ defaultReadOnly: boolean;
+}
+
+function AdminView({ users, playgroundCode }: AdminViewProps) {
+ const [toggleAll, setToggleAll] = useState(true);
+ const [defaultRole, setDefaultRole] = useState(true);
+ const [toggling, setToggling] = useState<{ [key: string]: boolean }>(
+ Object.fromEntries(Object.entries(users).map(([id]) => [id, true]))
+ );
+ const updateUserRoleCallback = useTypedSelector(
+ store => store.workspaces.playground.updateUserRoleCallback
+ );
+
+ const handleToggleAccess = (checked: boolean, id: string) => {
+ if (toggling[id]) return;
+ setToggling(prev => ({ ...prev, [id]: true }));
+
+ try {
+ updateUserRoleCallback(id, checked ? CollabEditingAccess.EDITOR : CollabEditingAccess.VIEWER);
+ } finally {
+ setToggling(prev => ({ ...prev, [id]: false }));
+ }
+ };
+
+ const handleAllToggleAccess = (checked: boolean) => {
+ try {
+ Object.keys(users).forEach(userId => {
+ if (userId !== 'all') {
+ updateUserRoleCallback(
+ userId,
+ checked ? CollabEditingAccess.EDITOR : CollabEditingAccess.VIEWER
+ );
+ }
+ });
+ } finally {
+ setToggleAll(checked);
+ }
+ };
+
+ const handleDefaultToggleAccess = (checked: boolean) => {
+ changeDefaultEditable(playgroundCode, !checked);
+ setDefaultRole(checked);
+ return;
+ };
+
+ return (
+ <>
+
+ Toggle all roles in current session:
+ handleAllToggleAccess(event.target.checked)}
+ className={classNames(classes['switch'], classes['default-switch'])}
+ />
+
+
+
+ Default role on join:
+ handleDefaultToggleAccess(event.target.checked)}
+ className={classNames(classes['switch'], classes['default-switch'])}
+ />
+
+
+
+
+ Name |
+ Role |
+
+
+
+ {Object.entries(users).map(([userId, user], index) => (
+
+
+
+ {user.name}
+ |
+
+ {user.role === CollabEditingAccess.OWNER ? (
+ 'Admin'
+ ) : (
+ handleToggleAccess(event.target.checked, userId)}
+ className={classes['switch']}
+ />
+ )}
+ |
+
+ ))}
+
+
+ >
+ );
+}
+
+type Props = {
+ users: Record;
+ playgroundCode: string;
+ readOnly: boolean;
+ workspaceLocation: SideContentLocation;
+};
+
+const SideContentSessionManagement: React.FC = ({
+ users,
+ playgroundCode,
+ readOnly,
+ workspaceLocation
+}) => {
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ dispatch(beginAlertSideContent(SideContentType.sessionManagement, workspaceLocation));
+ }, [dispatch, workspaceLocation, users]);
+
+ if (Object.values(users).length === 0) return;
+ const myself = Object.values(users)[0];
+
+ return (
+
+
+ This is the session management tab. Add users by sharing the session code. If you are the
+ owner of this session, you can manage users' access levels from the table below.
+
+
+
+ Session code:
+
+ showSuccessMessage('Session url copied: ' + getPlaygroundSessionUrl(playgroundCode))
+ }
+ >
+
+ {getPlaygroundSessionUrl(playgroundCode)}
+
+
+
+
+
+
+ Number of users in the session: {Object.entries(users).length}
+
+
+ {myself.role === CollabEditingAccess.OWNER ? (
+
+ ) : (
+
+
+
+ Name |
+ Role |
+
+
+
+ {Object.values(users).map((user, index) => {
+ return (
+
+
+
+ {user.name}
+ |
+
+ {user.role === CollabEditingAccess.OWNER
+ ? 'Admin'
+ : user.role.charAt(0).toUpperCase() + user.role.slice(1)}
+ |
+
+ );
+ })}
+
+
+ )}
+
+
+ );
+};
+
+export default SideContentSessionManagement;
diff --git a/src/commons/sideContent/content/SideContentSubstVisualizer.tsx b/src/commons/sideContent/content/SideContentSubstVisualizer.tsx
index a6c4cad72d..7de22e6ac1 100644
--- a/src/commons/sideContent/content/SideContentSubstVisualizer.tsx
+++ b/src/commons/sideContent/content/SideContentSubstVisualizer.tsx
@@ -94,7 +94,6 @@ type SubstVisualizerPropsAST = {
};
const SideContentSubstVisualizer: React.FC = props => {
- console.log(props);
const [stepValue, setStepValue] = useState(1);
const lastStepValue = props.content.length;
const hasRunCode = lastStepValue !== 0;
diff --git a/src/commons/utils/AceHelper.ts b/src/commons/utils/AceHelper.ts
index 7e958f124c..e023b7ee38 100644
--- a/src/commons/utils/AceHelper.ts
+++ b/src/commons/utils/AceHelper.ts
@@ -2,6 +2,7 @@ import { HighlightRulesSelector, ModeSelector } from 'js-slang/dist/editors/ace/
import { Chapter, Variant } from 'js-slang/dist/types';
import { HighlightRulesSelector_native } from '../../features/fullJS/fullJSHighlight';
+import { ExternalLibraryName } from '../application/types/ExternalTypes';
import { Documentation } from '../documentation/Documentation';
/**
* This _modifies global state_ and defines a new Ace mode globally, if it does not already exist.
@@ -67,3 +68,62 @@ export const getModeString = (chapter: Chapter, variant: Variant, library: strin
return `source${chapter}${variant}${library}`;
}
};
+
+export const parseModeString = (
+ modeString: string
+): { chapter: Chapter; variant: Variant; library: ExternalLibraryName } => {
+ switch (modeString) {
+ case 'html':
+ return { chapter: Chapter.HTML, variant: Variant.DEFAULT, library: ExternalLibraryName.NONE };
+ case 'typescript':
+ return {
+ chapter: Chapter.FULL_TS,
+ variant: Variant.DEFAULT,
+ library: ExternalLibraryName.NONE
+ };
+ case 'python':
+ return {
+ chapter: Chapter.PYTHON_1,
+ variant: Variant.DEFAULT,
+ library: ExternalLibraryName.NONE
+ };
+ case 'scheme':
+ return {
+ chapter: Chapter.FULL_SCHEME,
+ variant: Variant.EXPLICIT_CONTROL,
+ library: ExternalLibraryName.NONE
+ };
+ case 'java':
+ return {
+ chapter: Chapter.FULL_JAVA,
+ variant: Variant.DEFAULT,
+ library: ExternalLibraryName.NONE
+ };
+ case 'c_cpp':
+ return {
+ chapter: Chapter.FULL_C,
+ variant: Variant.DEFAULT,
+ library: ExternalLibraryName.NONE
+ };
+ default:
+ const matches = modeString.match(/source(-?\d+)([a-z\-]+)([A-Z]+)/);
+ if (!matches) {
+ throw new Error('Invalid modeString');
+ }
+ const [_, chapter, variant, externalLibraryName] = matches;
+ return {
+ chapter:
+ chapter === '1'
+ ? Chapter.SOURCE_1
+ : chapter === '2'
+ ? Chapter.SOURCE_2
+ : chapter === '3'
+ ? Chapter.SOURCE_3
+ : Chapter.SOURCE_4,
+ variant: Variant[variant as keyof typeof Variant] || Variant.DEFAULT,
+ library:
+ ExternalLibraryName[externalLibraryName as keyof typeof ExternalLibraryName] ||
+ ExternalLibraryName.NONE
+ };
+ }
+};
diff --git a/src/commons/utils/Constants.ts b/src/commons/utils/Constants.ts
index e5cc2c8c22..0ee626e184 100644
--- a/src/commons/utils/Constants.ts
+++ b/src/commons/utils/Constants.ts
@@ -42,6 +42,7 @@ const sicpBackendUrl =
process.env.REACT_APP_SICPJS_BACKEND_URL || 'https://sicp.sourceacademy.org/';
const javaPackagesUrl = 'https://source-academy.github.io/modules/java/java-packages/src/';
const workspaceSettingsLocalStorageKey = 'workspace-settings';
+const collabSessionIdLocalStorageKey = 'playground-session-id';
// For achievements feature (CA - Continual Assessment)
// TODO: remove dependency of the ca levels on the env file
@@ -183,6 +184,7 @@ const Constants = {
sicpBackendUrl,
javaPackagesUrl,
workspaceSettingsLocalStorageKey,
+ collabSessionIdLocalStorageKey,
caFulfillmentLevel,
featureFlags
};
diff --git a/src/commons/utils/StoriesHelper.ts b/src/commons/utils/StoriesHelper.ts
index 3f74db64f1..4e78351793 100644
--- a/src/commons/utils/StoriesHelper.ts
+++ b/src/commons/utils/StoriesHelper.ts
@@ -1,7 +1,11 @@
import { h } from 'hastscript';
+import { Nodes as MdastNodes } from 'mdast';
import { fromMarkdown } from 'mdast-util-from-markdown';
-import { defaultHandlers, toHast } from 'mdast-util-to-hast';
-import { MdastNodes, Options as MdastToHastConverterOptions } from 'mdast-util-to-hast/lib';
+import {
+ defaultHandlers,
+ Options as MdastToHastConverterOptions,
+ toHast
+} from 'mdast-util-to-hast';
import React from 'react';
import * as runtime from 'react/jsx-runtime';
import { IEditorProps } from 'react-ace';
diff --git a/src/commons/workspace/WorkspaceReducer.ts b/src/commons/workspace/WorkspaceReducer.ts
index 7e4f241ea9..59fef33b93 100644
--- a/src/commons/workspace/WorkspaceReducer.ts
+++ b/src/commons/workspace/WorkspaceReducer.ts
@@ -1,4 +1,5 @@
import { createReducer, type Reducer } from '@reduxjs/toolkit';
+import { castDraft } from 'immer';
import { SourcecastReducer } from '../../features/sourceRecorder/sourcecast/SourcecastReducer';
import { SourcereelReducer } from '../../features/sourceRecorder/sourcereel/SourcereelReducer';
@@ -15,7 +16,8 @@ import {
import {
setEditorSessionId,
setSessionDetails,
- setSharedbConnected
+ setSharedbConnected,
+ setUpdateUserRoleCallback
} from '../collabEditing/CollabEditingActions';
import type { SourceActionType } from '../utils/ActionsHelper';
import { createContext } from '../utils/JsSlangHelper';
@@ -138,10 +140,10 @@ const newWorkspaceReducer = createReducer(defaultWorkspaceManager, builder => {
.addCase(logOut, (state, action) => {
// Preserve the playground workspace even after log out
const playgroundWorkspace = state.playground;
- return {
+ return castDraft({
...defaultWorkspaceManager,
playground: playgroundWorkspace
- };
+ });
})
.addCase(WorkspaceActions.enableTokenCounter, (state, action) => {
const workspaceLocation = getWorkspaceLocation(action);
@@ -308,7 +310,16 @@ const newWorkspaceReducer = createReducer(defaultWorkspaceManager, builder => {
})
.addCase(setSessionDetails, (state, action) => {
const workspaceLocation = getWorkspaceLocation(action);
- state[workspaceLocation].sessionDetails = action.payload.sessionDetails;
+ return {
+ ...state,
+ [workspaceLocation]: {
+ ...state[workspaceLocation],
+ sessionDetails: {
+ ...state[workspaceLocation].sessionDetails,
+ ...action.payload.sessionDetails
+ }
+ }
+ };
})
.addCase(WorkspaceActions.setIsEditorReadonly, (state, action) => {
const workspaceLocation = getWorkspaceLocation(action);
@@ -378,5 +389,9 @@ const newWorkspaceReducer = createReducer(defaultWorkspaceManager, builder => {
.addCase(WorkspaceActions.updateLastDebuggerResult, (state, action) => {
const workspaceLocation = getWorkspaceLocation(action);
state[workspaceLocation].lastDebuggerResult = action.payload.lastDebuggerResult;
+ })
+ .addCase(setUpdateUserRoleCallback, (state, action) => {
+ const workspaceLocation = getWorkspaceLocation(action);
+ state[workspaceLocation].updateUserRoleCallback = action.payload.updateUserRoleCallback;
});
});
diff --git a/src/commons/workspace/WorkspaceTypes.ts b/src/commons/workspace/WorkspaceTypes.ts
index ea9f137a9e..5511dec4d2 100644
--- a/src/commons/workspace/WorkspaceTypes.ts
+++ b/src/commons/workspace/WorkspaceTypes.ts
@@ -1,3 +1,4 @@
+import type { CollabEditingAccess } from '@sourceacademy/sharedb-ace/types';
import type { Context } from 'js-slang';
import type {
@@ -85,7 +86,7 @@ export type WorkspaceState = {
readonly programPrependValue: string;
readonly programPostpendValue: string;
readonly editorSessionId: string;
- readonly sessionDetails: { docId: string; readOnly: boolean } | null;
+ readonly sessionDetails: { docId: string; readOnly: boolean; owner: boolean } | null;
readonly editorTestcases: Testcase[];
readonly execTime: number;
readonly isRunning: boolean;
@@ -106,6 +107,7 @@ export type WorkspaceState = {
readonly debuggerContext: DebuggerContext;
readonly lastDebuggerResult: any;
readonly files: UploadResult;
+ readonly updateUserRoleCallback: (id: string, newRole: CollabEditingAccess) => void;
};
type ReplHistory = {
diff --git a/src/commons/workspace/sharedb-ace.d.ts b/src/commons/workspace/sharedb-ace.d.ts
deleted file mode 100644
index cc0d68acbf..0000000000
--- a/src/commons/workspace/sharedb-ace.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-declare module '@sourceacademy/sharedb-ace';
diff --git a/src/features/sourceRecorder/SourceRecorderTypes.ts b/src/features/sourceRecorder/SourceRecorderTypes.ts
index 74030ea2cd..016c501746 100644
--- a/src/features/sourceRecorder/SourceRecorderTypes.ts
+++ b/src/features/sourceRecorder/SourceRecorderTypes.ts
@@ -1,4 +1,4 @@
-import { Ace } from 'ace-builds/ace';
+import { Ace } from 'ace-builds';
import { Chapter } from 'js-slang/dist/types';
import { ExternalLibraryName } from '../../commons/application/types/ExternalTypes';
diff --git a/src/features/sourceRecorder/sourcecast/SourcecastReducer.ts b/src/features/sourceRecorder/sourcecast/SourcecastReducer.ts
index f47bf97f78..1ebd9eb34e 100644
--- a/src/features/sourceRecorder/sourcecast/SourcecastReducer.ts
+++ b/src/features/sourceRecorder/sourcecast/SourcecastReducer.ts
@@ -1,4 +1,5 @@
import { createReducer } from '@reduxjs/toolkit';
+import { castDraft } from 'immer';
import { defaultWorkspaceManager } from 'src/commons/application/ApplicationTypes';
import * as SourceRecorderActions from '../SourceRecorderActions';
@@ -11,23 +12,23 @@ export const SourcecastReducer = createReducer(defaultWorkspaceManager.sourcecas
state.description = action.payload.description;
state.uid = action.payload.uid;
state.audioUrl = action.payload.audioUrl;
- state.playbackData = action.payload.playbackData;
+ state.playbackData = castDraft(action.payload.playbackData);
})
.addCase(SourceRecorderActions.setCurrentPlayerTime, (state, action) => {
state.currentPlayerTime = action.payload.playerTime;
})
.addCase(SourceRecorderActions.setCodeDeltasToApply, (state, action) => {
- state.codeDeltasToApply = action.payload.deltas;
+ state.codeDeltasToApply = castDraft(action.payload.deltas);
})
.addCase(SourceRecorderActions.setInputToApply, (state, action) => {
- state.inputToApply = action.payload.inputToApply;
+ state.inputToApply = castDraft(action.payload.inputToApply);
})
.addCase(SourceRecorderActions.setSourcecastData, (state, action) => {
state.title = action.payload.title;
state.description = action.payload.description;
state.uid = action.payload.uid;
state.audioUrl = action.payload.audioUrl;
- state.playbackData = action.payload.playbackData;
+ state.playbackData = castDraft(action.payload.playbackData);
})
.addCase(SourceRecorderActions.setSourcecastDuration, (state, action) => {
state.playbackDuration = action.payload.duration;
diff --git a/src/features/sourceRecorder/sourcereel/SourcereelReducer.ts b/src/features/sourceRecorder/sourcereel/SourcereelReducer.ts
index b3637def5e..b9ecafb672 100644
--- a/src/features/sourceRecorder/sourcereel/SourcereelReducer.ts
+++ b/src/features/sourceRecorder/sourcereel/SourcereelReducer.ts
@@ -1,4 +1,5 @@
import { createReducer } from '@reduxjs/toolkit';
+import { castDraft } from 'immer';
import { defaultWorkspaceManager } from 'src/commons/application/ApplicationTypes';
import { RecordingStatus } from '../SourceRecorderTypes';
@@ -11,10 +12,10 @@ export const SourcereelReducer = createReducer(defaultWorkspaceManager.sourceree
state.playbackData.inputs = [];
})
.addCase(SourcereelActions.recordInput, (state, action) => {
- state.playbackData.inputs.push(action.payload.input);
+ state.playbackData.inputs.push(castDraft(action.payload.input));
})
.addCase(SourcereelActions.resetInputs, (state, action) => {
- state.playbackData.inputs = action.payload.inputs;
+ state.playbackData.inputs = castDraft(action.payload.inputs);
})
.addCase(SourcereelActions.timerPause, (state, action) => {
state.recordingStatus = RecordingStatus.paused;
diff --git a/src/pages/academy/adminPanel/subcomponents/assessmentConfigPanel/AssessmentConfigPanel.tsx b/src/pages/academy/adminPanel/subcomponents/assessmentConfigPanel/AssessmentConfigPanel.tsx
index ef091afd65..d00ef3cd92 100644
--- a/src/pages/academy/adminPanel/subcomponents/assessmentConfigPanel/AssessmentConfigPanel.tsx
+++ b/src/pages/academy/adminPanel/subcomponents/assessmentConfigPanel/AssessmentConfigPanel.tsx
@@ -267,7 +267,8 @@ const AssessmentConfigPanel: WithImperativeApi<
setHasVotingFeatures,
setHoursBeforeDecay,
setIsGradingAutoPublished,
- setIsManuallyGraded
+ setIsManuallyGraded,
+ setIsMinigame
]
);
diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx
index c85310f359..b664d3b24d 100644
--- a/src/pages/playground/Playground.tsx
+++ b/src/pages/playground/Playground.tsx
@@ -2,6 +2,7 @@ import { Classes } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import { type HotkeyItem, useHotkeys } from '@mantine/hooks';
import type { AnyAction, Dispatch } from '@reduxjs/toolkit';
+import type { SharedbAceUser } from '@sourceacademy/sharedb-ace/types';
import { Ace, Range } from 'ace-builds';
import type { FSModule } from 'browserfs/dist/node/core/FS';
import classNames from 'classnames';
@@ -91,6 +92,7 @@ import {
desktopOnlyTabIds,
makeIntroductionTabFrom,
makeRemoteExecutionTabFrom,
+ makeSessionManagementTabFrom,
makeSubstVisualizerTabFrom,
mobileOnlyTabIds
} from './PlaygroundTabs';
@@ -283,6 +285,7 @@ const Playground: React.FC = props => {
chapter: playgroundSourceChapter
})
);
+ const [users, setUsers] = useState>({});
// Playground hotkeys
const [isGreen, setIsGreen] = useState(false);
@@ -297,6 +300,10 @@ const Playground: React.FC = props => {
[deviceSecret]
);
+ const sessionManagementTab: SideContentTab = useMemo(() => {
+ return makeSessionManagementTabFrom(users, editorSessionId, sessionDetails?.readOnly || false);
+ }, [users, editorSessionId, sessionDetails?.readOnly]);
+
const usingRemoteExecution =
useTypedSelector(state => !!state.session.remoteExecutionSession) && !isSicpEditor;
// this is still used by remote execution (EV3)
@@ -663,25 +670,36 @@ const Playground: React.FC = props => {
[store, workspaceLocation]
);
+ const handleSetEditorSessionId = useCallback(
+ (id: string) => dispatch(setEditorSessionId(workspaceLocation, id)),
+ [dispatch, workspaceLocation]
+ );
+
+ const handleSetSessionDetails = useCallback(
+ (details: { docId: string; readOnly: boolean; owner: boolean } | null) =>
+ dispatch(setSessionDetails(workspaceLocation, details)),
+ [dispatch, workspaceLocation]
+ );
+
const sessionButtons = useMemo(
() => (
dispatch(setEditorSessionId(workspaceLocation, id))}
- handleSetSessionDetails={details => dispatch(setSessionDetails(workspaceLocation, details))}
+ handleSetEditorSessionId={handleSetEditorSessionId}
+ handleSetSessionDetails={handleSetSessionDetails}
sharedbConnected={sharedbConnected}
key="session"
/>
),
[
- dispatch,
- getEditorValue,
isFolderModeEnabled,
editorSessionId,
- sharedbConnected,
- workspaceLocation
+ getEditorValue,
+ handleSetEditorSessionId,
+ handleSetSessionDetails,
+ sharedbConnected
]
);
@@ -775,21 +793,26 @@ const Playground: React.FC = props => {
if (!isSicpEditor && !Constants.playgroundOnly) {
tabs.push(remoteExecutionTab);
+ if (editorSessionId !== '') {
+ tabs.push(sessionManagementTab);
+ }
}
return tabs;
}, [
playgroundIntroductionTab,
languageConfig.chapter,
- output,
usingRemoteExecution,
isSicpEditor,
- dispatch,
+ output,
workspaceLocation,
+ dispatch,
shouldShowDataVisualizer,
shouldShowCseMachine,
shouldShowSubstVisualizer,
- remoteExecutionTab
+ remoteExecutionTab,
+ editorSessionId,
+ sessionManagementTab
]);
// Remove Intro and Remote Execution tabs for mobile
@@ -933,7 +956,9 @@ const Playground: React.FC = props => {
externalLibraryName,
sourceVariant: languageConfig.variant,
handleEditorValueChange: onEditorValueChange,
- handleEditorUpdateBreakpoints: handleEditorUpdateBreakpoints
+ handleEditorUpdateBreakpoints: handleEditorUpdateBreakpoints,
+ setUsers,
+ updateLanguageCallback: chapterSelectHandler
};
const replHandlers = useMemo(() => {
diff --git a/src/pages/playground/PlaygroundTabs.tsx b/src/pages/playground/PlaygroundTabs.tsx
index 95aac5c5da..518688df62 100644
--- a/src/pages/playground/PlaygroundTabs.tsx
+++ b/src/pages/playground/PlaygroundTabs.tsx
@@ -1,7 +1,9 @@
import { IconNames } from '@blueprintjs/icons';
+import type { SharedbAceUser } from '@sourceacademy/sharedb-ace/types';
import { InterpreterOutput } from 'src/commons/application/ApplicationTypes';
import Markdown from 'src/commons/Markdown';
import SideContentRemoteExecution from 'src/commons/sideContent/content/remoteExecution/SideContentRemoteExecution';
+import SideContentSessionManagement from 'src/commons/sideContent/content/SideContentSessionManagement';
import SideContentSubstVisualizer from 'src/commons/sideContent/content/SideContentSubstVisualizer';
import {
SideContentLocation,
@@ -22,6 +24,24 @@ export const makeIntroductionTabFrom = (content: string): SideContentTab => ({
id: SideContentType.introduction
});
+export const makeSessionManagementTabFrom = (
+ users: Record,
+ playgroundCode: string,
+ readOnly: boolean
+): SideContentTab => ({
+ label: 'Session Management',
+ iconName: IconNames.PEOPLE,
+ body: (
+
+ ),
+ id: SideContentType.sessionManagement
+});
+
export const makeRemoteExecutionTabFrom = (
deviceSecret: string | undefined,
callback: React.Dispatch>
diff --git a/src/pages/playground/__tests__/__snapshots__/Playground.tsx.snap b/src/pages/playground/__tests__/__snapshots__/Playground.tsx.snap
index 17e0741271..a5c45043ad 100644
--- a/src/pages/playground/__tests__/__snapshots__/Playground.tsx.snap
+++ b/src/pages/playground/__tests__/__snapshots__/Playground.tsx.snap
@@ -397,6 +397,7 @@ exports[`Playground tests Playground renders correctly 1`] = `
+