From bbe8abebfb619d789961214aced7d633a74c30f5 Mon Sep 17 00:00:00 2001 From: Chang-CH <37036869+Chang-CH@users.noreply.github.com> Date: Sun, 12 May 2024 15:34:51 +0800 Subject: [PATCH 1/4] Java upload tab (#2945) * add class file upload tab * unused code param * fix imports * update reducer pattern * update upload text * fix: tsc missing properties * disable upload tab on prod * fix comments * Fix component typing * Fix incorrect workspace location in reducer * Refactor SideContentUpload component * Memoize change handler * Fix layout and UI bugs * Use Blueprint file input instead of HTML input component * Fix typing * remove location prop * Show upload count * Fix format * Improve typing * Update typing --------- Co-authored-by: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> --- src/commons/application/ApplicationTypes.ts | 5 +- src/commons/sagas/PlaygroundSaga.ts | 7 ++ .../sagas/WorkspaceSaga/helpers/evalCode.ts | 14 ++- src/commons/sagas/__tests__/PlaygroundSaga.ts | 6 ++ src/commons/sideContent/SideContentTypes.ts | 3 +- .../sideContent/content/SideContentUpload.tsx | 85 +++++++++++++++++++ src/commons/utils/JavaHelper.ts | 67 +++++++++------ src/commons/workspace/WorkspaceActions.ts | 21 ++++- src/commons/workspace/WorkspaceReducer.ts | 12 +++ src/commons/workspace/WorkspaceTypes.ts | 5 ++ .../workspace/__tests__/WorkspaceReducer.ts | 1 + src/pages/playground/Playground.tsx | 8 +- 12 files changed, 202 insertions(+), 32 deletions(-) create mode 100644 src/commons/sideContent/content/SideContentUpload.tsx diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 40fe994fd8..851ea3b1bf 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -419,7 +419,8 @@ export const createDefaultWorkspace = (workspaceLocation: WorkspaceLocation): Wo enableDebugging: true, debuggerContext: {} as DebuggerContext, lastDebuggerResult: undefined, - lastNonDetResult: null + lastNonDetResult: null, + files: {} }); const defaultFileName = 'program.js'; @@ -447,6 +448,7 @@ export const defaultWorkspaceManager: WorkspaceManagerState = { usingSubst: false, usingCse: false, updateCse: true, + usingUpload: false, currentStep: -1, stepsTotal: 0, breakpointSteps: [], @@ -501,6 +503,7 @@ export const defaultWorkspaceManager: WorkspaceManagerState = { usingSubst: false, usingCse: false, updateCse: true, + usingUpload: false, currentStep: -1, stepsTotal: 0, breakpointSteps: [], diff --git a/src/commons/sagas/PlaygroundSaga.ts b/src/commons/sagas/PlaygroundSaga.ts index ab212d471b..2e99e0cc7c 100644 --- a/src/commons/sagas/PlaygroundSaga.ts +++ b/src/commons/sagas/PlaygroundSaga.ts @@ -26,6 +26,7 @@ import { toggleUpdateCse, toggleUsingCse, toggleUsingSubst, + toggleUsingUpload, updateCurrentStep, updateStepsTotal } from '../workspace/WorkspaceActions'; @@ -123,6 +124,12 @@ export default function* PlaygroundSaga(): SagaIterator { } } + if (newId === SideContentType.upload) { + yield put(toggleUsingUpload(true, workspaceLocation)); + } else { + yield put(toggleUsingUpload(false, workspaceLocation)); + } + if (isSchemeLanguage(playgroundSourceChapter) && newId === SideContentType.cseMachine) { yield put(toggleUsingCse(true, workspaceLocation)); } diff --git a/src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts b/src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts index 36f2ffa050..2e7c67190c 100644 --- a/src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts +++ b/src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts @@ -64,6 +64,15 @@ export function* evalCodeSaga( context.executionMethod = 'interpreter'; } + const uploadIsActive: boolean = correctWorkspace + ? yield select( + (state: OverallState) => + (state.workspaces[workspaceLocation] as PlaygroundWorkspaceState | SicpWorkspaceState) + .usingUpload + ) + : false; + const uploads = yield select((state: OverallState) => state.workspaces[workspaceLocation].files); + // For the CSE machine slider const cseIsActive: boolean = correctWorkspace ? yield select( @@ -262,7 +271,10 @@ export function* evalCodeSaga( : isC ? call(cCompileAndRun, entrypointCode, context) : isJava - ? call(javaRun, entrypointCode, context, currentStep, isUsingCse) + ? call(javaRun, entrypointCode, context, currentStep, isUsingCse, { + uploadIsActive, + uploads + }) : call( runFilesInContext, isFolderModeEnabled diff --git a/src/commons/sagas/__tests__/PlaygroundSaga.ts b/src/commons/sagas/__tests__/PlaygroundSaga.ts index 56fa53175c..5fb965d415 100644 --- a/src/commons/sagas/__tests__/PlaygroundSaga.ts +++ b/src/commons/sagas/__tests__/PlaygroundSaga.ts @@ -85,6 +85,7 @@ describe('Playground saga tests', () => { usingSubst: false, usingCse: false, updateCse: true, + usingUpload: false, currentStep: -1, stepsTotal: 0, breakpointSteps: [], @@ -153,6 +154,7 @@ describe('Playground saga tests', () => { usingSubst: false, usingCse: false, updateCse: true, + usingUpload: false, currentStep: -1, stepsTotal: 0, breakpointSteps: [], @@ -221,6 +223,7 @@ describe('Playground saga tests', () => { usingSubst: false, usingCse: false, updateCse: true, + usingUpload: false, currentStep: -1, stepsTotal: 0, breakpointSteps: [], @@ -271,6 +274,7 @@ describe('Playground saga tests', () => { ], usingSubst: false, usingCse: false, + usingUpload: false, updateCse: true, currentStep: -1, stepsTotal: 0, @@ -342,6 +346,7 @@ describe('Playground saga tests', () => { usingSubst: false, usingCse: false, updateCse: true, + usingUpload: false, currentStep: -1, stepsTotal: 0, breakpointSteps: [], @@ -401,6 +406,7 @@ describe('Playground saga tests', () => { usingSubst: false, usingCse: false, updateCse: true, + usingUpload: false, currentStep: -1, stepsTotal: 0, breakpointSteps: [], diff --git a/src/commons/sideContent/SideContentTypes.ts b/src/commons/sideContent/SideContentTypes.ts index 7868383cab..dd87e949d7 100644 --- a/src/commons/sideContent/SideContentTypes.ts +++ b/src/commons/sideContent/SideContentTypes.ts @@ -41,7 +41,8 @@ export enum SideContentType { testcases = 'testcases', toneMatrix = 'tone_matrix', htmlDisplay = 'html_display', - storiesRun = 'stories_run' + storiesRun = 'stories_run', + upload = 'upload' } /** diff --git a/src/commons/sideContent/content/SideContentUpload.tsx b/src/commons/sideContent/content/SideContentUpload.tsx new file mode 100644 index 0000000000..7a0c2f8f81 --- /dev/null +++ b/src/commons/sideContent/content/SideContentUpload.tsx @@ -0,0 +1,85 @@ +import { FileInput } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import React, { useCallback } from 'react'; + +import { SideContentTab, SideContentType } from '../SideContentTypes'; + +export type UploadResult = { + [key: string]: any; +}; + +async function getBase64(file: Blob, onFinish: (result: string) => void) { + const reader = new FileReader(); + return new Promise((resolve, reject) => { + reader.readAsDataURL(file); + reader.onload = () => { + onFinish((reader.result as string).slice(37)); + resolve(reader.result); + }; + reader.onerror = error => reject(error); + }); +} + +type Props = { + onUpload: (files: UploadResult) => void; +}; + +/** + * This component is responsible for uploading Java class files to bypass the compiler. + */ +const SideContentUpload: React.FC = ({ onUpload }) => { + const [count, setCount] = React.useState(0); + + const handleFileUpload: React.ChangeEventHandler = useCallback( + e => { + const ret: { [key: string]: string } = {}; + const promises = []; + for (const file of e.target.files ?? []) { + if (file.name.endsWith('.class')) { + promises.push( + getBase64(file, (b64: string) => { + ret[file.name] = b64; + }) + ); + } + } + Promise.all(promises).then(() => { + onUpload(ret); + setCount(promises.length); + }); + }, + [onUpload] + ); + + return ( +
+

Bypass the compiler and type checker by uploading class files to run in the JVM.

+

+ Only .class files are accepted. Code in the editor will be ignored when running while this + tab is active. +

+

Compile the files with the following command:

+
+        javac *.java -target 8 -source 8
+      
+

Avoid running class files downloaded from unknown sources.

+

+ Main class must be named Main and uploaded as Main.class. +

+ +
+ ); +}; + +const makeUploadTabFrom = (onUpload: (files: UploadResult) => void): SideContentTab => ({ + label: 'Upload files', + iconName: IconNames.Upload, + body: , + id: SideContentType.upload +}); + +export default makeUploadTabFrom; diff --git a/src/commons/utils/JavaHelper.ts b/src/commons/utils/JavaHelper.ts index 9d04ee752e..fbb7199135 100644 --- a/src/commons/utils/JavaHelper.ts +++ b/src/commons/utils/JavaHelper.ts @@ -2,11 +2,12 @@ import { compileFromSource, ECE, typeCheck } from 'java-slang'; import { BinaryWriter } from 'java-slang/dist/compiler/binary-writer'; import setupJVM, { parseBin } from 'java-slang/dist/jvm'; import { createModuleProxy, loadCachedFiles } from 'java-slang/dist/jvm/utils/integration'; -import { Context } from 'js-slang'; +import { Context, Result } from 'js-slang'; import loadSourceModules from 'js-slang/dist/modules/loader'; -import { ErrorSeverity, ErrorType, Result, SourceError } from 'js-slang/dist/types'; +import { ErrorSeverity, ErrorType, SourceError } from 'js-slang/dist/types'; import { CseMachine } from '../../features/cseMachine/java/CseMachine'; +import { UploadResult } from '../sideContent/content/SideContentUpload'; import Constants from './Constants'; import DisplayBufferService from './DisplayBufferService'; @@ -14,7 +15,8 @@ export async function javaRun( javaCode: string, context: Context, targetStep: number, - isUsingCse: boolean + isUsingCse: boolean, + options?: { uploadIsActive?: boolean; uploads?: UploadResult } ) { let compiled = {}; @@ -28,30 +30,11 @@ export async function javaRun( }); }; - const typeCheckResult = typeCheck(javaCode); - if (typeCheckResult.hasTypeErrors) { - const typeErrMsg = typeCheckResult.errorMsgs.join('\n'); - stderr('TypeCheck', typeErrMsg); - return Promise.resolve({ status: 'error' }); - } - - if (isUsingCse) return await runJavaCseMachine(javaCode, targetStep, context); - - try { - const classFile = compileFromSource(javaCode); - compiled = { - 'Main.class': Buffer.from(new BinaryWriter().generateBinary(classFile)).toString('base64') - }; - } catch (e) { - stderr('Compile', e); - return Promise.resolve({ status: 'error' }); - } - - let files = {}; + let files: UploadResult = {}; let buffer: string[] = []; const readClassFiles = (path: string) => { - let item = files[path as keyof typeof files]; + let item = files[path]; // not found: attempt to fetch from CDN if (!item && path) { @@ -69,11 +52,11 @@ export async function javaRun( // we might want to cache the files in IndexedDB here files = { ...files, ...json }; - if (!files[path as keyof typeof files]) { + if (!files[path]) { throw new Error('File not found: ' + path); } - item = files[path as keyof typeof files]; + item = files[path]; } // convert base64 to classfile object @@ -108,6 +91,35 @@ export async function javaRun( } }; + if (options?.uploadIsActive) { + compiled = options.uploads ?? {}; + if (!options.uploads) { + stderr('Compile', 'No files uploaded'); + return Promise.resolve({ status: 'error' }); + } + } else { + const typeCheckResult = typeCheck(javaCode); + if (typeCheckResult.hasTypeErrors) { + const typeErrMsg = typeCheckResult.errorMsgs.join('\n'); + stderr('TypeCheck', typeErrMsg); + return Promise.resolve({ status: 'error' }); + } + + if (isUsingCse) { + return await runJavaCseMachine(javaCode, targetStep, context); + } + + try { + const classFile = compileFromSource(javaCode); + compiled = { + 'Main.class': Buffer.from(new BinaryWriter().generateBinary(classFile)).toString('base64') + }; + } catch (e) { + stderr('Compile', e); + return Promise.resolve({ status: 'error' }); + } + } + // load cached classfiles from IndexedDB return loadCachedFiles(() => // Initial loader to fetch commonly used classfiles @@ -195,6 +207,7 @@ export async function runJavaCseMachine(code: string, targetStep: number, contex }) .catch(e => { console.error(e); - return { status: 'error' } as Result; + const errorResult: Result = { status: 'error' }; + return errorResult; }); } diff --git a/src/commons/workspace/WorkspaceActions.ts b/src/commons/workspace/WorkspaceActions.ts index 0edeae31d2..24252df9a8 100644 --- a/src/commons/workspace/WorkspaceActions.ts +++ b/src/commons/workspace/WorkspaceActions.ts @@ -7,11 +7,14 @@ import { ExternalLibraryName } from '../application/types/ExternalTypes'; import { Library } from '../assessment/AssessmentTypes'; import { HighlightedLines, Position } from '../editor/EditorTypes'; import { createActions } from '../redux/utils'; +import { UploadResult } from '../sideContent/content/SideContentUpload'; import { EditorTabState, SubmissionsTableFilters, + TOGGLE_USING_UPLOAD, UPDATE_LAST_DEBUGGER_RESULT, UPDATE_LAST_NON_DET_RESULT, + UPLOAD_FILES, WorkspaceLocation, WorkspaceLocationsWithTools, WorkspaceState @@ -278,6 +281,20 @@ export const updateLastNonDetResult = createAction( }) ); +export const toggleUsingUpload = createAction( + TOGGLE_USING_UPLOAD, + (usingUpload: boolean, workspaceLocation: WorkspaceLocationsWithTools) => ({ + payload: { usingUpload, workspaceLocation } + }) +); + +export const uploadFiles = createAction( + UPLOAD_FILES, + (files: UploadResult, workspaceLocation: WorkspaceLocation) => ({ + payload: { files, workspaceLocation } + }) +); + // For compatibility with existing code (reducer) export const { setTokenCount, @@ -345,5 +362,7 @@ export const { export default { ...newActions, updateLastDebuggerResult, - updateLastNonDetResult + updateLastNonDetResult, + toggleUsingUpload, + uploadFiles }; diff --git a/src/commons/workspace/WorkspaceReducer.ts b/src/commons/workspace/WorkspaceReducer.ts index 006a13b549..54cf35fd2d 100644 --- a/src/commons/workspace/WorkspaceReducer.ts +++ b/src/commons/workspace/WorkspaceReducer.ts @@ -354,6 +354,18 @@ const newWorkspaceReducer = createReducer(defaultWorkspaceManager, builder => { // debuggerContext.context = action.payload.context; // debuggerContext.workspaceLocation = action.payload.workspaceLocation; // }) + .addCase(WorkspaceActions.toggleUsingUpload, (state, action) => { + const { workspaceLocation } = action.payload; + if (workspaceLocation === 'playground' || workspaceLocation === 'sicp') { + state[workspaceLocation].usingUpload = action.payload.usingUpload; + } + }) + .addCase(WorkspaceActions.uploadFiles, (state, action) => { + const workspaceLocation = getWorkspaceLocation(action); + if (workspaceLocation === 'playground' || workspaceLocation === 'sicp') { + state[workspaceLocation].files = action.payload.files; + } + }) .addCase(WorkspaceActions.updateLastDebuggerResult, (state, action) => { const workspaceLocation = getWorkspaceLocation(action); state[workspaceLocation].lastDebuggerResult = action.payload.lastDebuggerResult; diff --git a/src/commons/workspace/WorkspaceTypes.ts b/src/commons/workspace/WorkspaceTypes.ts index 569e6aace0..aa20bed986 100644 --- a/src/commons/workspace/WorkspaceTypes.ts +++ b/src/commons/workspace/WorkspaceTypes.ts @@ -6,10 +6,13 @@ import { InterpreterOutput } from '../application/ApplicationTypes'; import { ExternalLibraryName } from '../application/types/ExternalTypes'; import { AutogradingResult, Testcase } from '../assessment/AssessmentTypes'; import { HighlightedLines, Position } from '../editor/EditorTypes'; +import { UploadResult } from '../sideContent/content/SideContentUpload'; export const EVAL_SILENT = 'EVAL_SILENT'; export const UPDATE_LAST_DEBUGGER_RESULT = 'UPDATE_LAST_DEBUGGER_RESULT'; export const UPDATE_LAST_NON_DET_RESULT = 'UPDATE_LAST_NON_DET_RESULT'; +export const TOGGLE_USING_UPLOAD = 'TOGGLE_USING_UPLOAD'; +export const UPLOAD_FILES = 'UPLOAD_FILES'; export type WorkspaceLocation = keyof WorkspaceManagerState; export type WorkspaceLocationsWithTools = Extract; @@ -33,6 +36,7 @@ type GradingWorkspaceState = GradingWorkspaceAttr & WorkspaceState; type PlaygroundWorkspaceAttr = { readonly usingSubst: boolean; readonly usingCse: boolean; + readonly usingUpload: boolean; readonly updateCse: boolean; readonly currentStep: number; readonly stepsTotal: number; @@ -96,6 +100,7 @@ export type WorkspaceState = { readonly debuggerContext: DebuggerContext; readonly lastDebuggerResult: any; readonly lastNonDetResult: Result | null; + readonly files: UploadResult; }; type ReplHistory = { diff --git a/src/commons/workspace/__tests__/WorkspaceReducer.ts b/src/commons/workspace/__tests__/WorkspaceReducer.ts index e9e35af436..8d38ac8ede 100644 --- a/src/commons/workspace/__tests__/WorkspaceReducer.ts +++ b/src/commons/workspace/__tests__/WorkspaceReducer.ts @@ -832,6 +832,7 @@ describe('LOG_OUT', () => { sharedbConnected: false, usingSubst: false, usingCse: false, + usingUpload: false, updateCse: true, currentStep: -1, stepsTotal: 0, diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 9d352d04e1..3acb2a317f 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -21,6 +21,7 @@ import { import makeCseMachineTabFrom from 'src/commons/sideContent/content/SideContentCseMachine'; import makeDataVisualizerTabFrom from 'src/commons/sideContent/content/SideContentDataVisualizer'; import makeHtmlDisplayTabFrom from 'src/commons/sideContent/content/SideContentHtmlDisplay'; +import makeUploadTabFrom from 'src/commons/sideContent/content/SideContentUpload'; import { changeSideContentHeight } from 'src/commons/sideContent/SideContentActions'; import { useSideContent } from 'src/commons/sideContent/SideContentHelper'; import { useResponsive, useTypedSelector } from 'src/commons/utils/Hooks'; @@ -54,7 +55,8 @@ import { toggleUsingSubst, updateActiveEditorTabIndex, updateEditorValue, - updateReplValue + updateReplValue, + uploadFiles } from 'src/commons/workspace/WorkspaceActions'; import { WorkspaceLocation } from 'src/commons/workspace/WorkspaceTypes'; import GithubActions from 'src/features/github/GitHubActions'; @@ -742,6 +744,10 @@ const Playground: React.FC = props => { return tabs; } + if (currentLang === Chapter.FULL_JAVA && process.env.NODE_ENV === 'development') { + tabs.push(makeUploadTabFrom(files => dispatch(uploadFiles(files, workspaceLocation)))); + } + if (!usingRemoteExecution) { // Don't show the following when using remote execution if (shouldShowDataVisualizer) { From 4b63863a719c7326bea989514c99a015c454c71d Mon Sep 17 00:00:00 2001 From: Zsigmond Poh <57909679+zsiggg@users.noreply.github.com> Date: Sun, 12 May 2024 17:55:22 +0800 Subject: [PATCH 2/4] Display Stories Users in Admin Panel (#2763) * chore: initial copy from userConfigPanel * feat: add stories user tab in admin panel tab is still showing users, instead of stories users. * chore: add type AdminPanelStoriesUsers meant to be analagous to AdminPanelCourseRegistrations, based on the return fields of /groups/{groupId}/users from stories backend. * refactor: update types and parameters Role to StoriesRole, courseRegId to id (to match StoriesUsers) * refactor: update AdminPanelStoriesUsers type * feat: fetch users from stories db * feat: enable role column * chore: update csv title and columns * Merge master * chore: fix merge conflicts * fix: errors from merging and dep version bumps suppressCellSelection of is now suppressCellFocus (AG-5953). cellRendererFramework of ColDef is now cellRenderer, see [blog](https://blog.ag-grid.com/whats-new-in-ag-grid-27/). * style: run yarn format * feat: add deleting user and updating user roles added sagas, actions, calls to the backend to enable functionality of deleting user and updating their roles in the Stories Users panel. updated some variable names (previously copied from Users panel) to match newly created names. * refactor: linter changes and dialog box text refactored code based on changes made by linter during pre-push hook. also refactored text shown in dialog box upon clicking Delete User button in Stories User panel. * style: run yarn format * Migrate new actions to RTK * Migrate new actions to RTK * Fix compile errors post-merge * Migrate new saga handlers post-merge * Reformat new saga handlers post-merge * Fix format * Fix incorrect action creator signature * Fix type errors * Fix format * Fix incorrect merge resolution * Update stories `RolesCell` To align with new updates in the default `RolesCell` component. * Update StoriesUserActionsCell.tsx Reflect new updates to the `UserActionsCell` component. * Update StoriesUserConfigPanel.tsx Reflect recent updaates to `UserConfigPanel`. * Remove unnecessary typecast * Fix incorrect type definition * Remove unnecessary type annotations --------- Co-authored-by: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Co-authored-by: Martin Henz --- src/commons/application/ApplicationTypes.ts | 3 +- .../application/actions/SessionActions.ts | 18 ++- src/commons/application/types/SessionTypes.ts | 2 + src/commons/sagas/StoriesSaga.ts | 40 +++++- src/features/stories/StoriesActions.ts | 14 ++- src/features/stories/StoriesReducer.ts | 7 +- src/features/stories/StoriesTypes.ts | 11 ++ .../storiesComponents/BackendAccess.ts | 57 ++++++++- src/pages/academy/adminPanel/AdminPanel.tsx | 21 +++- .../storiesUserConfigPanel/RolesCell.tsx | 48 ++++++++ .../StoriesUserActionsCell.tsx | 93 ++++++++++++++ .../StoriesUserConfigPanel.tsx | 114 ++++++++++++++++++ 12 files changed, 419 insertions(+), 9 deletions(-) create mode 100644 src/pages/academy/adminPanel/subcomponents/storiesUserConfigPanel/RolesCell.tsx create mode 100644 src/pages/academy/adminPanel/subcomponents/storiesUserConfigPanel/StoriesUserActionsCell.tsx create mode 100644 src/pages/academy/adminPanel/subcomponents/storiesUserConfigPanel/StoriesUserConfigPanel.tsx diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 851ea3b1bf..6ccaa75a57 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -552,7 +552,8 @@ export const defaultStories: StoriesState = { storyList: [], currentStoryId: null, currentStory: null, - envs: {} + envs: {}, + storiesUsers: [] }; export const createDefaultStoriesEnv = ( diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index 80b98537b9..73f1b41a2a 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -19,16 +19,18 @@ import { NotificationFilterFunction } from '../../notificationBadge/NotificationBadgeTypes'; import { generateOctokitInstance } from '../../utils/GitHubPersistenceHelper'; -import { Role } from '../ApplicationTypes'; +import { Role, StoriesRole } from '../ApplicationTypes'; import { AdminPanelCourseRegistration, CourseRegistration, + DELETE_STORIES_USER_USER_GROUPS, NotificationConfiguration, NotificationPreference, TimeOption, Tokens, UPDATE_ASSESSMENT, UPDATE_COURSE_RESEARCH_AGREEMENT, + UPDATE_STORIES_USER_ROLE, UPDATE_TOTAL_XP, UpdateCourseConfiguration, User @@ -174,11 +176,23 @@ export const updateCourseResearchAgreement = createAction( (agreedToResearch: boolean) => ({ payload: { agreedToResearch } }) ); +export const updateStoriesUserRole = createAction( + UPDATE_STORIES_USER_ROLE, + (userId: number, role: StoriesRole) => ({ payload: { userId, role } }) +); + +export const deleteStoriesUserUserGroups = createAction( + DELETE_STORIES_USER_USER_GROUPS, + (userId: number) => ({ payload: { userId } }) +); + // For compatibility with existing code (actions helper) export default { ...newActions, updateTotalXp, updateAssessment, ...newActions2, - updateCourseResearchAgreement + updateCourseResearchAgreement, + updateStoriesUserRole, + deleteStoriesUserUserGroups }; diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index 50071ecfd0..3cdb7bec7e 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -15,6 +15,8 @@ import { GameState, Role, Story } from '../ApplicationTypes'; export const UPDATE_TOTAL_XP = 'UPDATE_TOTAL_XP'; export const UPDATE_ASSESSMENT = 'UPDATE_ASSESSMENT'; export const UPDATE_COURSE_RESEARCH_AGREEMENT = 'UPDATE_COURSE_RESEARCH_AGREEMENT'; +export const UPDATE_STORIES_USER_ROLE = 'UPDATE_STORIES_USER_ROLE'; +export const DELETE_STORIES_USER_USER_GROUPS = 'DELETE_STORIES_USER_USER_GROUPS'; export type SessionState = { // Tokens diff --git a/src/commons/sagas/StoriesSaga.ts b/src/commons/sagas/StoriesSaga.ts index 4d771c78b0..c071186724 100644 --- a/src/commons/sagas/StoriesSaga.ts +++ b/src/commons/sagas/StoriesSaga.ts @@ -3,25 +3,31 @@ import { call, put, select } from 'redux-saga/effects'; import StoriesActions from 'src/features/stories/StoriesActions'; import { deleteStory, + deleteUserUserGroups, + getAdminPanelStoriesUsers, getStories, getStoriesUser, getStory, postStory, + putStoriesUserRole, updateStory } from 'src/features/stories/storiesComponents/BackendAccess'; import { StoryData, StoryListView, StoryView } from 'src/features/stories/StoriesTypes'; +import SessionActions from '../application/actions/SessionActions'; import { OverallState, StoriesRole } from '../application/ApplicationTypes'; import { Tokens } from '../application/types/SessionTypes'; import { combineSagaHandlers } from '../redux/utils'; import { resetSideContent } from '../sideContent/SideContentActions'; import { actions } from '../utils/ActionsHelper'; -import { showWarningMessage } from '../utils/notifications/NotificationsHelper'; +import { showSuccessMessage, showWarningMessage } from '../utils/notifications/NotificationsHelper'; import { defaultStoryContent } from '../utils/StoriesHelper'; import { selectTokens } from './BackendSaga'; import { evalCodeSaga } from './WorkspaceSaga/helpers/evalCode'; -const StoriesSaga = combineSagaHandlers(StoriesActions, { +// TODO: Refactor and combine in a future commit +const sagaActions = { ...StoriesActions, ...SessionActions }; +const StoriesSaga = combineSagaHandlers(sagaActions, { // TODO: This should be using `takeLatest`, not `takeEvery` getStoriesList: function* () { const tokens: Tokens = yield selectTokens(); @@ -141,6 +147,36 @@ const StoriesSaga = combineSagaHandlers(StoriesActions, { action.type, env ); + }, + fetchAdminPanelStoriesUsers: function* (action) { + const tokens: Tokens = yield selectTokens(); + + const storiesUsers = yield call(getAdminPanelStoriesUsers, tokens); + + if (storiesUsers) { + yield put(actions.setAdminPanelStoriesUsers(storiesUsers)); + } + }, + updateStoriesUserRole: function* (action) { + const tokens: Tokens = yield selectTokens(); + const { userId, role } = action.payload; + + const resp: Response | null = yield call(putStoriesUserRole, tokens, userId, role); + + if (resp) { + yield put(actions.fetchAdminPanelStoriesUsers()); + yield call(showSuccessMessage, 'Role updated!'); + } + }, + deleteStoriesUserUserGroups: function* (action) { + const tokens: Tokens = yield selectTokens(); + const { userId } = action.payload; + + const resp: Response | null = yield call(deleteUserUserGroups, tokens, userId); + if (resp) { + yield put(actions.fetchAdminPanelStoriesUsers()); + yield call(showSuccessMessage, 'Stories user deleted!'); + } } }); diff --git a/src/features/stories/StoriesActions.ts b/src/features/stories/StoriesActions.ts index 2b8faed6c1..8715024154 100644 --- a/src/features/stories/StoriesActions.ts +++ b/src/features/stories/StoriesActions.ts @@ -4,8 +4,11 @@ import { StoriesRole } from 'src/commons/application/ApplicationTypes'; import { createActions } from 'src/commons/redux/utils'; import { + AdminPanelStoriesUser, CLEAR_STORIES_USER_AND_GROUP, + FETCH_ADMIN_PANEL_STORIES_USERS, GET_STORIES_USER, + SET_ADMIN_PANEL_STORIES_USERS, SET_CURRENT_STORIES_GROUP, SET_CURRENT_STORIES_USER, StoryData, @@ -53,6 +56,13 @@ export const setCurrentStoriesGroup = createAction( export const clearStoriesUserAndGroup = createAction(CLEAR_STORIES_USER_AND_GROUP, () => ({ payload: {} })); +export const fetchAdminPanelStoriesUsers = createAction(FETCH_ADMIN_PANEL_STORIES_USERS, () => ({ + payload: {} +})); +export const setAdminPanelStoriesUsers = createAction( + SET_ADMIN_PANEL_STORIES_USERS, + (users: AdminPanelStoriesUser[]) => ({ payload: { users } }) +); // For compatibility with existing code (reducer) export const { @@ -80,5 +90,7 @@ export default { getStoriesUser, setCurrentStoriesUser, setCurrentStoriesGroup, - clearStoriesUserAndGroup + clearStoriesUserAndGroup, + fetchAdminPanelStoriesUsers, + setAdminPanelStoriesUsers }; diff --git a/src/features/stories/StoriesReducer.ts b/src/features/stories/StoriesReducer.ts index b578bbfd2a..e7c0d9df96 100644 --- a/src/features/stories/StoriesReducer.ts +++ b/src/features/stories/StoriesReducer.ts @@ -28,7 +28,7 @@ import { updateStoriesList } from './StoriesActions'; import { DEFAULT_ENV } from './storiesComponents/UserBlogContent'; -import { StoriesState } from './StoriesTypes'; +import { SET_ADMIN_PANEL_STORIES_USERS, StoriesState } from './StoriesTypes'; export const StoriesReducer: Reducer = ( state = defaultStories, @@ -187,6 +187,11 @@ const oldStoriesReducer: Reducer = ( action ) => { switch (action.type) { + case SET_ADMIN_PANEL_STORIES_USERS: + return { + ...state, + storiesUsers: action.payload.users + }; default: return state; } diff --git a/src/features/stories/StoriesTypes.ts b/src/features/stories/StoriesTypes.ts index b281b70ed3..46ada017ff 100644 --- a/src/features/stories/StoriesTypes.ts +++ b/src/features/stories/StoriesTypes.ts @@ -9,6 +9,8 @@ export const CLEAR_STORIES_USER_AND_GROUP = 'CLEAR_STORIES_USER_AND_GROUP'; // TODO: Investigate possibility of combining the two actions export const SET_CURRENT_STORIES_USER = 'SET_CURRENT_STORIES_USER'; export const SET_CURRENT_STORIES_GROUP = 'SET_CURRENT_STORIES_GROUP'; +export const FETCH_ADMIN_PANEL_STORIES_USERS = 'FETCH_ADMIN_PANEL_STORIES_USERS'; +export const SET_ADMIN_PANEL_STORIES_USERS = 'SET_ADMIN_PANEL_STORIES_USERS'; export type StoryMetadata = { authorId: number; @@ -53,9 +55,18 @@ export type StoriesAuthState = { readonly role?: StoriesRole; }; +export type AdminPanelStoriesUser = { + readonly id: number; + readonly name: string; + readonly username: string; + readonly provider: string; + readonly role: string; +}; + export type StoriesState = { readonly storyList: StoryListView[]; readonly currentStoryId: number | null; readonly currentStory: StoryData | null; readonly envs: { [key: string]: StoriesEnvState }; + readonly storiesUsers: AdminPanelStoriesUser[]; } & StoriesAuthState; diff --git a/src/features/stories/storiesComponents/BackendAccess.ts b/src/features/stories/storiesComponents/BackendAccess.ts index f996240be4..c494c13ed6 100644 --- a/src/features/stories/storiesComponents/BackendAccess.ts +++ b/src/features/stories/storiesComponents/BackendAccess.ts @@ -11,7 +11,7 @@ import { store } from 'src/pages/createStore'; import { Tokens } from '../../../commons/application/types/SessionTypes'; import { NameUsernameRole } from '../../../pages/academy/adminPanel/subcomponents/AddStoriesUserPanel'; -import { StoryListView, StoryView } from '../StoriesTypes'; +import { AdminPanelStoriesUser, StoryListView, StoryView } from '../StoriesTypes'; // Helpers @@ -150,3 +150,58 @@ export const deleteStory = async (tokens: Tokens, id: number): Promise => { + const resp = await requestStoryBackend(`/groups/${getStoriesGroupId()}/users`, 'GET', { + ...tokens + }); + if (!resp) { + return null; + } + const users = await resp.json(); + return users; +}; + +export const putStoriesUserRole = async ( + tokens: Tokens, + userId: number, + role: StoriesRole +): Promise => { + const resp = await requestStoryBackend( + `/groups/${getStoriesGroupId()}/users/${userId}/role`, + 'PUT', + { + ...tokens, + body: { role } + } + ); + + if (!resp) { + showWarningMessage("Failed to update stories user's role"); + return null; + } + const user = await resp.json(); + return user; +}; + +export const deleteUserUserGroups = async ( + tokens: Tokens, + userId: number +): Promise => { + const resp = await requestStoryBackend( + `/groups/${getStoriesGroupId()}/users/${userId}`, + 'DELETE', + { + ...tokens + } + ); + + if (!resp) { + showWarningMessage('Failed to delete stories user'); + return null; + } + const user = await resp.json(); + return user; +}; diff --git a/src/pages/academy/adminPanel/AdminPanel.tsx b/src/pages/academy/adminPanel/AdminPanel.tsx index 3e84c92ff7..73a77d6f46 100644 --- a/src/pages/academy/adminPanel/AdminPanel.tsx +++ b/src/pages/academy/adminPanel/AdminPanel.tsx @@ -4,11 +4,13 @@ import 'ag-grid-community/styles/ag-theme-balham.css'; import { Button, Divider, H1, Intent, Tab, Tabs } from '@blueprintjs/core'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; -import { useSession } from 'src/commons/utils/Hooks'; +import { StoriesRole } from 'src/commons/application/ApplicationTypes'; +import { useSession, useTypedSelector } from 'src/commons/utils/Hooks'; import { addNewStoriesUsersToCourse, addNewUsersToCourse } from 'src/features/academy/AcademyActions'; +import { fetchAdminPanelStoriesUsers } from 'src/features/stories/StoriesActions'; import SessionActions from '../../../commons/application/actions/SessionActions'; import { UpdateCourseConfiguration } from '../../../commons/application/types/SessionTypes'; @@ -20,6 +22,7 @@ import AssessmentConfigPanel, { } from './subcomponents/assessmentConfigPanel/AssessmentConfigPanel'; import CourseConfigPanel from './subcomponents/CourseConfigPanel'; import NotificationConfigPanel from './subcomponents/NotificationConfigPanel'; +import StoriesUserConfigPanel from './subcomponents/storiesUserConfigPanel/StoriesUserConfigPanel'; import UserConfigPanel from './subcomponents/userConfigPanel/UserConfigPanel'; const defaultCourseConfig: UpdateCourseConfiguration = { @@ -40,12 +43,14 @@ const AdminPanel: React.FC = () => { const dispatch = useDispatch(); const session = useSession(); + const stories = useTypedSelector(state => state.stories); useEffect(() => { dispatch(SessionActions.fetchCourseConfig()); dispatch(SessionActions.fetchAssessmentConfigs()); dispatch(SessionActions.fetchAdminPanelCourseRegistrations()); dispatch(SessionActions.fetchNotificationConfigs()); + dispatch(fetchAdminPanelStoriesUsers()); }, [dispatch]); useEffect(() => { @@ -83,6 +88,15 @@ const AdminPanel: React.FC = () => { } }; + const storiesUserConfigPanelProps = { + userId: stories.userId, + storiesUsers: stories.storiesUsers, + handleUpdateStoriesUserRole: (id: number, role: StoriesRole) => + dispatch(SessionActions.updateStoriesUserRole(id, role)), + handleDeleteStoriesUserFromUserGroup: (id: number) => + dispatch(SessionActions.deleteStoriesUserUserGroups(id)) + }; + // Handler to submit changes to Course Configration and Assessment Configuration to the backend. // Changes made to users are handled separately. const submitHandler = useCallback(() => { @@ -156,6 +170,11 @@ const AdminPanel: React.FC = () => { /> } /> + } + /> void; +}; + +const RolesCell: React.FC = props => { + const { data } = props; + + const changeHandler = React.useCallback( + (e: React.ChangeEvent) => { + props.handleUpdateStoriesUserRole(data.id, e.target.value as StoriesRole); + }, + [data, props] + ); + + const roleOptions = [ + { label: 'User', value: StoriesRole.Standard }, + { label: 'Moderator', value: StoriesRole.Moderator }, + { label: 'Admin', value: StoriesRole.Admin } + ]; + return ( + + + + ); +}; + +export default RolesCell; diff --git a/src/pages/academy/adminPanel/subcomponents/storiesUserConfigPanel/StoriesUserActionsCell.tsx b/src/pages/academy/adminPanel/subcomponents/storiesUserConfigPanel/StoriesUserActionsCell.tsx new file mode 100644 index 0000000000..f0fa01917a --- /dev/null +++ b/src/pages/academy/adminPanel/subcomponents/storiesUserConfigPanel/StoriesUserActionsCell.tsx @@ -0,0 +1,93 @@ +import { + Button, + Dialog, + DialogBody, + DialogFooter, + Intent, + Popover, + Position +} from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import React, { useCallback, useState } from 'react'; +import { StoriesRole } from 'src/commons/application/ApplicationTypes'; +import ControlButton from 'src/commons/ControlButton'; +import { showWarningMessage } from 'src/commons/utils/notifications/NotificationsHelper'; +import { AdminPanelStoriesUser } from 'src/features/stories/StoriesTypes'; + +type Props = { + data: AdminPanelStoriesUser; + rowIndex: number; + handleDeleteStoriesUserFromUserGroup: (id: number) => void; +}; + +const DeleteStoriesUserCell: React.FC = ({ data, handleDeleteStoriesUserFromUserGroup }) => { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const clickHandler = () => { + if (data.role === StoriesRole.Admin) { + showWarningMessage('You cannot delete an admin user!'); + return; + } + setIsDialogOpen(true); + }; + + const handleDelete = useCallback(() => { + handleDeleteStoriesUserFromUserGroup(data.id); + setIsDialogOpen(false); + }, [data.id, handleDeleteStoriesUserFromUserGroup]); + + return ( + <> + +