diff --git a/package.json b/package.json index 0b7f0beea6..e3ba85dec5 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@blueprintjs/datetime2": "^2.3.3", "@blueprintjs/icons": "^5.9.0", "@blueprintjs/select": "^5.1.3", + "@convergencelabs/ace-collab-ext": "^0.6.0", "@mantine/hooks": "^7.11.2", "@octokit/rest": "^20.0.0", "@reduxjs/toolkit": "^1.9.7", @@ -43,7 +44,7 @@ "@szhsin/react-menu": "^4.0.0", "@tanstack/react-table": "^8.9.3", "@tremor/react": "^1.8.2", - "ace-builds": "^1.36.3", + "ace-builds": "^1.42.1", "acorn": "^8.9.0", "ag-grid-community": "^32.3.1", "ag-grid-react": "^32.3.1", @@ -58,6 +59,7 @@ "hastscript": "^9.0.0", "i18next": "^25.0.0", "i18next-browser-languagedetector": "^8.0.0", + "immer": "^10.1.1", "java-slang": "^1.0.13", "js-cookie": "^3.0.5", "js-slang": "^1.0.84", @@ -126,7 +128,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^16.0.0", - "@testing-library/user-event": "^14.4.3", + "@testing-library/user-event": "^14.6.0", "@types/estree": "^1.0.5", "@types/gapi": "^0.0.47", "@types/gapi.auth2": "^0.0.61", diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 9f91a27e1f..87bc1e9e2d 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -418,7 +418,8 @@ export const createDefaultWorkspace = (workspaceLocation: WorkspaceLocation): Wo enableDebugging: true, debuggerContext: {} as DebuggerContext, lastDebuggerResult: undefined, - files: {} + files: {}, + updateUserRoleCallback: () => {} }); const defaultFileName = 'program.js'; diff --git a/src/commons/collabEditing/CollabEditingActions.ts b/src/commons/collabEditing/CollabEditingActions.ts index 61b5516859..f9c80fcfd6 100644 --- a/src/commons/collabEditing/CollabEditingActions.ts +++ b/src/commons/collabEditing/CollabEditingActions.ts @@ -1,3 +1,5 @@ +import type { CollabEditingAccess } from '@sourceacademy/sharedb-ace/types'; + import { createActions } from '../redux/utils'; import { WorkspaceLocation } from '../workspace/WorkspaceTypes'; @@ -8,7 +10,7 @@ const CollabEditingActions = createActions('collabEditing', { }), setSessionDetails: ( workspaceLocation: WorkspaceLocation, - sessionDetails: { docId: string; readOnly: boolean } | null + sessionDetails: { docId?: string; readOnly?: boolean; owner?: boolean } | null ) => ({ workspaceLocation, sessionDetails }), /** * Sets ShareDB connection status. @@ -19,11 +21,23 @@ const CollabEditingActions = createActions('collabEditing', { setSharedbConnected: (workspaceLocation: WorkspaceLocation, connected: boolean) => ({ workspaceLocation, connected + }), + setUpdateUserRoleCallback: ( + workspaceLocation: WorkspaceLocation, + updateUserRoleCallback: (id: string, newRole: CollabEditingAccess) => void + ) => ({ + workspaceLocation, + updateUserRoleCallback }) }); // For compatibility with existing code (reducer) -export const { setEditorSessionId, setSessionDetails, setSharedbConnected } = CollabEditingActions; +export const { + setEditorSessionId, + setSessionDetails, + setSharedbConnected, + setUpdateUserRoleCallback +} = CollabEditingActions; // For compatibility with existing code (actions helper) export default CollabEditingActions; diff --git a/src/commons/collabEditing/CollabEditingHelper.ts b/src/commons/collabEditing/CollabEditingHelper.ts index c7a5d29ee8..e191c3840e 100644 --- a/src/commons/collabEditing/CollabEditingHelper.ts +++ b/src/commons/collabEditing/CollabEditingHelper.ts @@ -13,9 +13,17 @@ export function getSessionUrl(sessionId: string, ws?: boolean): string { return url.toString(); } +export function getPlaygroundSessionUrl(sessionId: string): string { + let url = window.location.href; + if (window.location.href.endsWith('/playground')) { + url += `/${sessionId}`; + } + return url; +} + export async function getDocInfoFromSessionId( sessionId: string -): Promise<{ docId: string; readOnly: boolean } | null> { +): Promise<{ docId: string; defaultReadOnly: boolean } | null> { const resp = await fetch(getSessionUrl(sessionId)); if (resp && resp.ok) { @@ -27,7 +35,7 @@ export async function getDocInfoFromSessionId( export async function createNewSession( contents: string -): Promise<{ docId: string; sessionEditingId: string; sessionViewingId: string }> { +): Promise<{ docId: string; sessionId: string }> { const resp = await fetch(Constants.sharedbBackendUrl, { method: 'POST', body: JSON.stringify({ contents }), @@ -42,3 +50,19 @@ export async function createNewSession( return resp.json(); } + +export async function changeDefaultEditable(sessionId: string, defaultReadOnly: boolean) { + const resp = await fetch(getSessionUrl(sessionId), { + method: 'PATCH', + body: JSON.stringify({ defaultReadOnly }), + headers: { 'Content-Type': 'application/json' } + }); + + if (!resp || !resp.ok) { + throw new Error( + resp ? `Could not update session: ${await resp.text()}` : 'Unknown error updating session' + ); + } + + return resp.json(); +} diff --git a/src/commons/controlBar/ControlBarSessionButton.tsx b/src/commons/controlBar/ControlBarSessionButton.tsx index 915f73b174..2368ffff4c 100644 --- a/src/commons/controlBar/ControlBarSessionButton.tsx +++ b/src/commons/controlBar/ControlBarSessionButton.tsx @@ -1,26 +1,20 @@ -import { - Classes, - Colors, - Divider, - FormGroup, - Menu, - Popover, - Text, - Tooltip -} from '@blueprintjs/core'; +import { Classes, Colors, Divider, FormGroup, Popover, Text, Tooltip } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import * as CopyToClipboard from 'react-copy-to-clipboard'; +import { useParams } from 'react-router'; import { createNewSession, getDocInfoFromSessionId } from '../collabEditing/CollabEditingHelper'; import ControlButton from '../ControlButton'; -import { showWarningMessage } from '../utils/notifications/NotificationsHelper'; +import { showSuccessMessage, showWarningMessage } from '../utils/notifications/NotificationsHelper'; type ControlBarSessionButtonsProps = DispatchProps & StateProps; type DispatchProps = { handleSetEditorSessionId?: (editorSessionId: string) => void; - handleSetSessionDetails?: (sessionDetails: { docId: string; readOnly: boolean } | null) => void; + handleSetSessionDetails?: ( + sessionDetails: { docId: string; readOnly: boolean; owner: boolean } | null + ) => void; }; type StateProps = { @@ -31,215 +25,177 @@ type StateProps = { key: string; }; -type State = { - joinElemValue: string; - sessionEditingId: string; - sessionViewingId: string; -}; - function handleError(error: any) { showWarningMessage(`Could not connect: ${(error && error.message) || error || 'Unknown error'}`); } -export class ControlBarSessionButtons extends React.PureComponent< - ControlBarSessionButtonsProps, - State -> { - private sessionEditingIdInputElem: React.RefObject; - private sessionViewingIdInputElem: React.RefObject; - - constructor(props: ControlBarSessionButtonsProps) { - super(props); - this.state = { joinElemValue: '', sessionEditingId: '', sessionViewingId: '' }; - - this.handleChange = this.handleChange.bind(this); - this.sessionEditingIdInputElem = React.createRef(); - this.sessionViewingIdInputElem = React.createRef(); - this.selectSessionEditingId = this.selectSessionEditingId.bind(this); - this.selectSessionViewingId = this.selectSessionViewingId.bind(this); - } - - public render() { - const handleStartInvite = () => { - // FIXME this handler should be a Saga action or at least in a controller - if (this.props.editorSessionId === '') { - createNewSession(this.props.getEditorValue()).then(resp => { - this.setState({ - sessionEditingId: resp.sessionEditingId, - sessionViewingId: resp.sessionViewingId - }); - this.props.handleSetEditorSessionId!(resp.sessionEditingId); - this.props.handleSetSessionDetails!({ docId: resp.docId, readOnly: false }); - }, handleError); - } - }; - - const inviteButtonPopoverContent = ( -
- {!this.props.editorSessionId ? ( - <> - You are not currently in any session. - - - - ) : ( - <> - - You have joined the session as{' '} - {this.state.sessionEditingId ? 'an editor' : 'a viewer'}. - - - {this.state.sessionEditingId && ( - - - - - - - )} - {this.state.sessionViewingId && ( - - - - - - - )} - - )} -
- ); - - const inviteButton = ( - - - - ); +export function ControlBarSessionButtons(props: ControlBarSessionButtonsProps) { + const joinElemRef = useRef(''); + const [sessionId, setSessionId] = useState(''); + const [defaultReadOnly, setDefaultReadOnly] = useState(true); + const [isOwner, setIsOwner] = useState(false); + + const handleChange = (event: React.ChangeEvent) => { + joinElemRef.current = event.target.value; + }; + + const handleStartInvite = () => { + // FIXME this handler should be a Saga action or at least in a controller + if (props.editorSessionId === '') { + createNewSession(props.getEditorValue()).then(resp => { + setSessionId(resp.sessionId); + props.handleSetEditorSessionId!(resp.sessionId); + props.handleSetSessionDetails!({ docId: resp.docId, readOnly: false, owner: true }); + setIsOwner(true); + }, handleError); + } + }; - const handleStartJoining = (event: React.FormEvent) => { + const handleStartJoining = useCallback( + (event: React.FormEvent) => { event.preventDefault(); + const joinElemValue = joinElemRef.current; + // FIXME this handler should be a Saga action or at least in a controller - getDocInfoFromSessionId(this.state.joinElemValue).then( + getDocInfoFromSessionId(joinElemValue).then( docInfo => { if (docInfo !== null) { - this.props.handleSetEditorSessionId!(this.state!.joinElemValue); - this.props.handleSetSessionDetails!(docInfo); - if (docInfo.readOnly) { - this.setState({ - sessionEditingId: '', - sessionViewingId: this.state.joinElemValue - }); - } else { - this.setState({ - sessionEditingId: this.state.joinElemValue, - sessionViewingId: '' - }); - } + props.handleSetEditorSessionId!(joinElemValue); + props.handleSetSessionDetails!({ + docId: docInfo.docId, + readOnly: docInfo.defaultReadOnly, + owner: false + }); + setSessionId(joinElemValue); + setDefaultReadOnly(docInfo.defaultReadOnly); + setIsOwner(false); } else { - this.props.handleSetEditorSessionId!(''); - this.props.handleSetSessionDetails!(null); + props.handleSetEditorSessionId!(''); + props.handleSetSessionDetails!(null); showWarningMessage('Could not find a session with that ID.'); + if ( + window.location.href.includes('/playground') && + !window.location.href.endsWith('/playground') + ) { + window.history.pushState({}, document.title, '/playground'); + } } }, error => { - this.props.handleSetEditorSessionId!(''); + props.handleSetEditorSessionId!(''); handleError(error); } ); - }; - - const joinButtonPopoverContent = ( - // TODO: this form should use Blueprint -
- - - - -
- ); - - const joinButton = ( - - - - ); - - const leaveButton = ( - { - // FIXME: this handler should be a Saga action or at least in a controller - this.props.handleSetEditorSessionId!(''); - this.setState({ joinElemValue: '', sessionEditingId: '', sessionViewingId: '' }); - }} - /> - ); - - const tooltipContent = this.props.isFolderModeEnabled - ? 'Currently unsupported in Folder mode' - : undefined; - - return ( - - - {inviteButton} - {this.props.editorSessionId === '' ? joinButton : leaveButton} - - } - disabled={this.props.isFolderModeEnabled} + }, + [props.handleSetEditorSessionId, props.handleSetSessionDetails] + ); + + const leaveButton = ( + { + // FIXME: this handler should be a Saga action or at least in a controller + props.handleSetEditorSessionId!(''); + joinElemRef.current = ''; + setSessionId(''); + }} + /> + ); + + const inviteButtonPopoverContent = ( +
+ {!props.editorSessionId ? ( +
+ You are not currently in any session. + - - - ); - } - - private selectSessionEditingId() { - if (this.sessionEditingIdInputElem.current !== null) { - this.sessionEditingIdInputElem.current.focus(); - this.sessionEditingIdInputElem.current.select(); - } - } - - private selectSessionViewingId() { - if (this.sessionViewingIdInputElem.current !== null) { - this.sessionViewingIdInputElem.current.focus(); - this.sessionViewingIdInputElem.current.select(); +
+ ... or join an existing one +
+
+ + + + +
+
+ ) : ( +
+ + You have joined the session as{' '} + {isOwner ? 'the owner' : defaultReadOnly ? 'a viewer' : 'an editor'}. + + + {sessionId && ( +
+ + + + showSuccessMessage('Copied to clipboard: ' + sessionId)} + > + + +
+ )} + {leaveButton} +
+ )} +
+ ); + + const tooltipContent = props.isFolderModeEnabled + ? 'Currently unsupported in Folder mode' + : undefined; + + const { playgroundCode } = useParams<{ playgroundCode: string }>(); + useEffect(() => { + if (playgroundCode) { + joinElemRef.current = playgroundCode; + handleStartJoining({ preventDefault: () => {} } as React.FormEvent); } - } - - private handleChange(event: React.ChangeEvent) { - this.setState({ joinElemValue: event.target.value }); - } + }, [playgroundCode, handleStartJoining]); + + return ( + + + + + + ); } diff --git a/src/commons/editor/Editor.tsx b/src/commons/editor/Editor.tsx index e941b3ec36..31127f0160 100644 --- a/src/commons/editor/Editor.tsx +++ b/src/commons/editor/Editor.tsx @@ -25,6 +25,7 @@ import { Chapter, Variant } from 'js-slang/dist/types'; import React from 'react'; import AceEditor, { IAceEditorProps, IEditorProps } from 'react-ace'; import { IAceEditor } from 'react-ace/lib/types'; +import { SALanguage } from '../application/ApplicationTypes'; import { EditorBinding } from '../WorkspaceSettingsContext'; import { getModeString, selectMode } from '../utils/AceHelper'; import { objectEntries } from '../utils/TypeHelper'; @@ -38,6 +39,8 @@ import useHighlighting from './UseHighlighting'; import useNavigation from './UseNavigation'; import useRefactor from './UseRefactor'; import useShareAce from './UseShareAce'; +import type { SharedbAceUser } from '@sourceacademy/sharedb-ace/types'; +import { ExternalLibraryName } from '../application/types/ExternalTypes'; export type EditorKeyBindingHandlers = { [name in KeyFunction]?: () => void }; export type EditorHook = ( @@ -62,13 +65,16 @@ type DispatchProps = { type EditorStateProps = { editorSessionId: string; - sessionDetails: { docId: string; readOnly: boolean } | null; + sessionDetails: { docId: string; readOnly: boolean; owner: boolean } | null; isEditorAutorun: boolean; sourceChapter?: Chapter; externalLibraryName?: string; sourceVariant?: Variant; hooks?: EditorHook[]; editorBinding?: EditorBinding; + setUsers?: React.Dispatch>>; + // TODO: Handle changing of external library + updateLanguageCallback?: (sublanguage: SALanguage, e: any) => void; }; export type EditorTabStateProps = { @@ -358,22 +364,7 @@ const EditorBase = React.memo((props: EditorProps & LocalStateProps) => { // See AceHelper#selectMode for more information. props.session.setMode(editor.getSession().getMode()); editor.setSession(props.session); - /* eslint-disable */ - - // Add changeCursor event listener onto the current session. - // In ReactAce, this event listener is only bound on component - // mounting/creation, and hence changing sessions will need rebinding. - // See react-ace/src/ace.tsx#263,#460 for more details. We also need to - // ensure that event listener is only bound once to prevent memory leaks. - // We also need to check non-documented property _eventRegistry to - // see if the changeCursor listener event has been added yet. - - // @ts-ignore - if (editor.getSession().selection._eventRegistry.changeCursor.length < 2) { - editor.getSession().selection.on('changeCursor', reactAceRef.current!.onCursorChange); - } - /* eslint-enable */ // Give focus to the editor tab only after switching from another tab. // This is necessary to prevent 'unstable_flushDiscreteUpdates' warnings. if (filePath !== undefined) { @@ -412,7 +403,7 @@ const EditorBase = React.memo((props: EditorProps & LocalStateProps) => { const [sourceChapter, sourceVariant, externalLibraryName] = [ props.sourceChapter || Chapter.SOURCE_1, props.sourceVariant || Variant.DEFAULT, - props.externalLibraryName || 'NONE' + props.externalLibraryName || ExternalLibraryName.NONE ]; // this function defines the Ace language and highlighting mode for the @@ -660,8 +651,9 @@ const EditorBase = React.memo((props: EditorProps & LocalStateProps) => { return ( -
+
+
); 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/subcomponents/__tests__/NavigationBarLangSelectButton.tsx b/src/commons/navigationBar/subcomponents/__tests__/NavigationBarLangSelectButton.tsx index 553ad6fd26..2e3f7db798 100644 --- a/src/commons/navigationBar/subcomponents/__tests__/NavigationBarLangSelectButton.tsx +++ b/src/commons/navigationBar/subcomponents/__tests__/NavigationBarLangSelectButton.tsx @@ -1,6 +1,5 @@ import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; +import userEvent, { type UserEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { SupportedLanguage } from '../../../../commons/application/ApplicationTypes'; diff --git a/src/commons/researchAgreementPrompt/__tests__/ResearchAgreementPrompt.tsx b/src/commons/researchAgreementPrompt/__tests__/ResearchAgreementPrompt.tsx index c52c9a999e..b45ee96697 100644 --- a/src/commons/researchAgreementPrompt/__tests__/ResearchAgreementPrompt.tsx +++ b/src/commons/researchAgreementPrompt/__tests__/ResearchAgreementPrompt.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; +import type { UserEvent } from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'; -import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import { Provider, useDispatch } from 'react-redux'; import SessionActions from 'src/commons/application/actions/SessionActions'; import { mockInitialStore } from 'src/commons/mocks/StoreMocks'; diff --git a/src/commons/sagas/SideContentSaga.ts b/src/commons/sagas/SideContentSaga.ts index 2f99877591..59fa2ed563 100644 --- a/src/commons/sagas/SideContentSaga.ts +++ b/src/commons/sagas/SideContentSaga.ts @@ -4,12 +4,15 @@ import StoriesActions from 'src/features/stories/StoriesActions'; import { combineSagaHandlers } from '../redux/utils'; import SideContentActions from '../sideContent/SideContentActions'; +import { SideContentType } from '../sideContent/SideContentTypes'; import WorkspaceActions from '../workspace/WorkspaceActions'; const isSpawnSideContent = ( action: Action ): action is ReturnType => - 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/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 28a5555c3a..4631a0dd11 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 @@ -181,6 +182,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 02bd08ee99..9eba309dce 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'; @@ -90,6 +91,7 @@ import { desktopOnlyTabIds, makeIntroductionTabFrom, makeRemoteExecutionTabFrom, + makeSessionManagementTabFrom, makeSubstVisualizerTabFrom, mobileOnlyTabIds } from './PlaygroundTabs'; @@ -281,6 +283,7 @@ const Playground: React.FC = props => { chapter: playgroundSourceChapter }) ); + const [users, setUsers] = useState>({}); // Playground hotkeys const [isGreen, setIsGreen] = useState(false); @@ -295,6 +298,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) @@ -638,25 +645,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 ] ); @@ -750,21 +768,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 @@ -908,7 +931,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/sicp/subcomponents/__tests__/SicpExercise.tsx b/src/pages/sicp/subcomponents/__tests__/SicpExercise.tsx index 0c499b7936..5f369b132b 100644 --- a/src/pages/sicp/subcomponents/__tests__/SicpExercise.tsx +++ b/src/pages/sicp/subcomponents/__tests__/SicpExercise.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; +import type { UserEvent } from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'; -import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import { renderTreeJson } from 'src/commons/utils/TestUtils'; import SicpExercise, { noSolutionPlaceholder } from '../SicpExercise'; diff --git a/src/routes/routerConfig.tsx b/src/routes/routerConfig.tsx index bcf5d326fe..70c63444db 100644 --- a/src/routes/routerConfig.tsx +++ b/src/routes/routerConfig.tsx @@ -137,7 +137,7 @@ export const getFullAcademyRouterConfig = ({ { path: 'welcome', lazy: Welcome, loader: welcomeLoader }, { path: 'courses', element: }, ensureUserAndRole({ path: 'courses/:courseId/*', lazy: Academy, children: academyRoutes }), - ensureUserAndRole({ path: 'playground', lazy: Playground }), + ensureUserAndRole({ path: 'playground/:playgroundCode?', lazy: Playground }), { path: 'mission-control/:assessmentId?/:questionId?', lazy: MissionControl }, ensureUserAndRole({ path: 'courses/:courseId/stories/new', lazy: EditStory }), ensureUserAndRole({ path: 'courses/:courseId/stories/view/:id', lazy: ViewStory }), diff --git a/src/styles/SideContentSessionManagement.module.scss b/src/styles/SideContentSessionManagement.module.scss new file mode 100644 index 0000000000..bf443ee438 --- /dev/null +++ b/src/styles/SideContentSessionManagement.module.scss @@ -0,0 +1,69 @@ +@import '_global'; +@import '@blueprintjs/core/lib/scss/variables'; + +.span { + display: inline-flex; + margin-block: 0.5rem; +} + +.left-cell { + min-height: 40px; + display: flex; + align-items: center; +} + +.right-cell { + vertical-align: middle !important; +} + +.switch { + margin-bottom: 0px; +} + +.default-switch { + display: inline-block; + margin-left: 5px; +} + +.table { + padding-inline: 0px !important; +} + +.table-container { + padding-top: 1rem; + padding-inline: 1rem; +} + +.session-code { + color: $blue4; + margin-left: 5px; + display: flex; + column-gap: 5px; +} + +.session-code:hover { + cursor: pointer; + color: $blue5; +} + +.user-icon { + margin-right: 5px; +} + +.side-content-tab-alert { + -webkit-animation: alert 1s infinite; + -moz-animation: alert 1s infinite; + -o-animation: alert 1s infinite; + animation: alert 1s infinite; +} + +@keyframes alert { + 0%, + 50% { + background-color: rgba(200, 100, 50, 0.5); + } + 51%, + 100% { + background-image: rgba(138, 155, 168, 0.3); + } +} diff --git a/src/styles/_workspace.scss b/src/styles/_workspace.scss index df4cbddc40..08c7e4da9d 100755 --- a/src/styles/_workspace.scss +++ b/src/styles/_workspace.scss @@ -745,6 +745,34 @@ $code-color-notification: #f9f0d7; } } + .session-management-table { + table { + width: 100%; + padding-inline: 0.5em; + border-spacing: 0 0.5em; + + th:last-child, + td:last-child { + width: auto; + } + + td:first-child { + display: flex; + flex-direction: row; + gap: 5px; + width: 100%; + } + + td { + div:first-child { + width: 15px; + height: 15px; + border-radius: 50%; + } + } + } + } + .react-mde { /* Colour the borders */ border-color: #1b2530; @@ -913,6 +941,8 @@ $code-color-notification: #f9f0d7; background: $cadet-color-3; color: rgb(128, 145, 160); } + display: inline-block; + flex: 1; } .react-ace-green { @@ -922,6 +952,22 @@ $code-color-notification: #f9f0d7; background: $dark-green; color: rgb(128, 145, 160); } + display: inline-block; + flex: 1; + } + + #ace-radar-view { + display: inline-block; + max-width: 2%; + background: #2f3129; + } + + .ace-radar-view-cursor-indicator { + display: none; + } + + .ace-radar-view-wrapper { + width: 1px; } .Autograder, diff --git a/tsconfig.json b/tsconfig.json index c988d3f032..2d499116a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "jsx": "react-jsx", "strict": true, "useUnknownInCatchVariables": false, - "moduleResolution": "node", + "moduleResolution": "bundler", "rootDir": "src", "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, diff --git a/yarn.lock b/yarn.lock index 96f22cfccb..60014643be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3684,7 +3684,7 @@ __metadata: languageName: node linkType: hard -"@testing-library/user-event@npm:^14.4.3": +"@testing-library/user-event@npm:^14.6.0": version: 14.6.1 resolution: "@testing-library/user-event@npm:14.6.1" peerDependencies: @@ -4493,10 +4493,10 @@ __metadata: languageName: node linkType: hard -"ace-builds@npm:^1.36.3, ace-builds@npm:^1.4.12": - version: 1.36.3 - resolution: "ace-builds@npm:1.36.3" - checksum: 10c0/7d7a35f393cd70555d559afcc0521dcda7fe78585cdfd41525a7bd9d59115f8b9f40ac3ff597907faf2ac2f71e1174c6797df698df48a8041c6b6433dba316b5 +"ace-builds@npm:^1.36.3, ace-builds@npm:^1.4.12, ace-builds@npm:^1.42.1": + version: 1.43.0 + resolution: "ace-builds@npm:1.43.0" + checksum: 10c0/df969c3d706272cc23fdb59b47b7ef04ee01cd6af11f90ab4a2dc244064a4120465d6a9dc4fd247fef77f9634f2b8dc0a81fae07fcf751b56b069013432e673b languageName: node linkType: hard @@ -7619,7 +7619,7 @@ __metadata: "@testing-library/dom": "npm:^10.4.0" "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^16.0.0" - "@testing-library/user-event": "npm:^14.4.3" + "@testing-library/user-event": "npm:^14.6.0" "@tremor/react": "npm:^1.8.2" "@types/estree": "npm:^1.0.5" "@types/gapi": "npm:^0.0.47" @@ -7641,7 +7641,7 @@ __metadata: "@types/redux-mock-store": "npm:^1.0.3" "@types/showdown": "npm:^2.0.1" "@types/xml2js": "npm:^0.4.11" - ace-builds: "npm:^1.36.3" + ace-builds: "npm:^1.42.1" acorn: "npm:^8.9.0" ag-grid-community: "npm:^32.3.1" ag-grid-react: "npm:^32.3.1" @@ -7670,6 +7670,7 @@ __metadata: i18next: "npm:^25.0.0" i18next-browser-languagedetector: "npm:^8.0.0" identity-obj-proxy: "npm:^3.0.0" + immer: "npm:^10.1.1" java-slang: "npm:^1.0.13" jest: "npm:^29.7.0" jest-environment-jsdom: "npm:^29.7.0" @@ -8454,6 +8455,13 @@ __metadata: languageName: node linkType: hard +"immer@npm:^10.1.1": + version: 10.1.1 + resolution: "immer@npm:10.1.1" + checksum: 10c0/b749e10d137ccae91788f41bd57e9387f32ea6d6ea8fd7eb47b23fd7766681575efc7f86ceef7fe24c3bc9d61e38ff5d2f49c2663b2b0c056e280a4510923653 + languageName: node + linkType: hard + "immer@npm:^9.0.21": version: 9.0.21 resolution: "immer@npm:9.0.21" @@ -11934,10 +11942,10 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.12.0 || ^17.0.0 || ^18.0.0, react-is@npm:^18.0.0": - version: 18.2.0 - resolution: "react-is@npm:18.2.0" - checksum: 10c0/6eb5e4b28028c23e2bfcf73371e72cd4162e4ac7ab445ddae2afe24e347a37d6dc22fae6e1748632cd43c6d4f9b8f86dcf26bf9275e1874f436d129952528ae0 +"react-is@npm:^16.12.0 || ^17.0.0 || ^18.0.0, react-is@npm:^18.0.0, react-is@npm:^18.3.1": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 languageName: node linkType: hard @@ -11948,13 +11956,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.3.1": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 - languageName: node - linkType: hard - "react-konva@npm:^18.2.10": version: 18.2.10 resolution: "react-konva@npm:18.2.10"