diff --git a/package.json b/package.json index cbcc84e026..88d074ea1e 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "classnames": "^2.3.2", "flexboxgrid": "^6.3.1", "flexboxgrid-helpers": "^1.1.3", - "js-slang": "^1.0.27", + "js-slang": "^1.0.29", "js-yaml": "^4.1.0", "konva": "^9.2.0", "lodash": "^4.17.21", diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 25f94849bb..1817a8384f 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -107,6 +107,13 @@ export enum Role { Admin = 'admin' } +// Must match https://github.com/source-academy/stories-backend/blob/main/internal/enums/groups/role.go +export enum StoriesRole { + Standard = 'member', + Moderator = 'moderator', + Admin = 'admin' +} + export enum SupportedLanguage { JAVASCRIPT = 'JavaScript', SCHEME = 'Scheme', diff --git a/src/commons/controlBar/ControlBarStepLimit.tsx b/src/commons/controlBar/ControlBarStepLimit.tsx index 5828080a63..9b33016222 100644 --- a/src/commons/controlBar/ControlBarStepLimit.tsx +++ b/src/commons/controlBar/ControlBarStepLimit.tsx @@ -12,6 +12,7 @@ type DispatchProps = { type StateProps = { stepLimit?: number; + stepSize: number; key: string; }; @@ -32,7 +33,7 @@ export const ControlBarStepLimit: React.FC = props => min={500} max={5000} value={props.stepLimit} - stepSize={2} + stepSize={props.stepSize} onBlur={onBlurAutoScale} onValueChange={props.handleChangeStepLimit} /> diff --git a/src/commons/mocks/ContextMocks.ts b/src/commons/mocks/ContextMocks.ts index dc76edb5a8..4789d7e8f6 100644 --- a/src/commons/mocks/ContextMocks.ts +++ b/src/commons/mocks/ContextMocks.ts @@ -36,7 +36,6 @@ export function mockRuntimeContext(): Context { ], agenda: null, stash: null, - envSteps: -1, envStepsTotal: 0, breakpointSteps: [] }; diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index 37ff78a507..461ee6837f 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -131,7 +131,7 @@ import { } from './RequestsSaga'; import { safeTakeEvery as takeEvery } from './SafeEffects'; -function selectTokens() { +export function selectTokens() { return select((state: OverallState) => ({ accessToken: state.session.accessToken, refreshToken: state.session.refreshToken @@ -198,6 +198,9 @@ function* BackendSaga(): SagaIterator { yield put(actions.setCourseRegistration(courseRegistration)); yield put(actions.setCourseConfiguration(courseConfiguration)); yield put(actions.setAssessmentConfigurations(assessmentConfigurations)); + + yield put(actions.getStoriesUser()); + // TODO: Fetch associated stories group ID } /** * NOTE: Navigation logic is now handled in component. @@ -211,7 +214,7 @@ function* BackendSaga(): SagaIterator { yield takeEvery( FETCH_USER_AND_COURSE, function* (action: ReturnType): any { - const tokens = yield selectTokens(); + const tokens: Tokens = yield selectTokens(); const { user, @@ -241,6 +244,9 @@ function* BackendSaga(): SagaIterator { yield put(actions.setCourseRegistration(courseRegistration)); yield put(actions.setCourseConfiguration(courseConfiguration)); yield put(actions.setAssessmentConfigurations(assessmentConfigurations)); + + yield put(actions.getStoriesUser()); + // TODO: Fetch associated stories group ID } } ); @@ -250,6 +256,9 @@ function* BackendSaga(): SagaIterator { const { config }: { config: CourseConfiguration | null } = yield call(getCourseConfig, tokens); if (config) { yield put(actions.setCourseConfiguration(config)); + + yield put(actions.getStoriesUser()); + // TODO: Fetch associated stories group ID } }); @@ -708,6 +717,10 @@ function* BackendSaga(): SagaIterator { yield put(actions.setCourseConfiguration(courseConfiguration)); yield put(actions.setAssessmentConfigurations(assessmentConfigurations)); yield put(actions.setCourseRegistration(courseRegistration)); + + yield put(actions.getStoriesUser()); + // TODO: Fetch associated stories group ID + yield call(showSuccessMessage, `Switched to ${courseConfiguration.courseName}!`, 5000); } ); diff --git a/src/commons/sagas/StoriesSaga.ts b/src/commons/sagas/StoriesSaga.ts index 45ff8398a0..b1164447a6 100644 --- a/src/commons/sagas/StoriesSaga.ts +++ b/src/commons/sagas/StoriesSaga.ts @@ -1,26 +1,40 @@ import { SagaIterator } from 'redux-saga'; -import { call, put, takeEvery, takeLatest } from 'redux-saga/effects'; +import { call, put, select, takeLatest } from 'redux-saga/effects'; +import { ADD_NEW_STORIES_USERS_TO_COURSE } from 'src/features/academy/AcademyTypes'; import { deleteStory, getStories, - getStory + getStoriesUser, + getStory, + postNewStoriesUsers, + postStory, + updateStory } from 'src/features/stories/storiesComponents/BackendAccess'; import { + CREATE_STORY, DELETE_STORY, GET_STORIES_LIST, + GET_STORIES_USER, + SAVE_STORY, SET_CURRENT_STORY_ID, StoryData, StoryListView, StoryView } from 'src/features/stories/StoriesTypes'; +import { OverallState } from '../application/ApplicationTypes'; +import { Tokens } from '../application/types/SessionTypes'; import { actions } from '../utils/ActionsHelper'; +import { showWarningMessage } from '../utils/notifications/NotificationsHelper'; import { defaultStoryContent } from '../utils/StoriesHelper'; +import { selectTokens } from './BackendSaga'; +import { safeTakeEvery as takeEvery } from './SafeEffects'; export function* storiesSaga(): SagaIterator { yield takeLatest(GET_STORIES_LIST, function* () { + const tokens: Tokens = yield selectTokens(); const allStories: StoryListView[] = yield call(async () => { - const resp = await getStories(); + const resp = await getStories(tokens); return resp ?? []; }); @@ -32,43 +46,101 @@ export function* storiesSaga(): SagaIterator { yield takeEvery( SET_CURRENT_STORY_ID, function* (action: ReturnType) { + const tokens: Tokens = yield selectTokens(); const storyId = action.payload; if (storyId) { - const story: StoryView = yield call(getStory, storyId); + const story: StoryView = yield call(getStory, tokens, storyId); yield put(actions.setCurrentStory(story)); } else { const defaultStory: StoryData = { title: '', - content: defaultStoryContent + content: defaultStoryContent, + pinOrder: null }; yield put(actions.setCurrentStory(defaultStory)); } } ); - // yield takeEvery(SAVE_STORY, function* (action: ReturnType) { - // const story = action.payload; - // const updatedStory: StoryView | null = yield call(async () => { - // // TODO: Support pin order - // const resp = await updateStory(story.id, story.title, story.content); - // if (!resp) { - // return null; - // } - // return resp.json(); - // }); - - // // TODO: Check correctness - // if (updatedStory) { - // yield put(actions.setCurrentStory(updatedStory)); - // } - // }); + yield takeEvery(CREATE_STORY, function* (action: ReturnType) { + const tokens: Tokens = yield selectTokens(); + const story = action.payload; + const userId: number | undefined = yield select((state: OverallState) => state.stories.userId); + + if (userId === undefined) { + showWarningMessage('Failed to create story: Invalid user'); + return; + } + + const createdStory: StoryView | null = yield call( + postStory, + tokens, + userId, + story.title, + story.content, + story.pinOrder + ); + + // TODO: Check correctness + if (createdStory) { + yield put(actions.setCurrentStoryId(createdStory.id)); + } + + yield put(actions.getStoriesList()); + }); + + yield takeEvery(SAVE_STORY, function* (action: ReturnType) { + const tokens: Tokens = yield selectTokens(); + const { story, id } = action.payload; + const updatedStory: StoryView | null = yield call( + updateStory, + tokens, + id, + story.title, + story.content, + story.pinOrder + ); + + // TODO: Check correctness + if (updatedStory) { + yield put(actions.setCurrentStory(updatedStory)); + } + + yield put(actions.getStoriesList()); + }); yield takeEvery(DELETE_STORY, function* (action: ReturnType) { + const tokens: Tokens = yield selectTokens(); const storyId = action.payload; - yield call(deleteStory, storyId); + yield call(deleteStory, tokens, storyId); yield put(actions.getStoriesList()); }); + + yield takeEvery(GET_STORIES_USER, function* () { + const tokens: Tokens = yield selectTokens(); + const me: { + id: number; + name: string; + } | null = yield call(getStoriesUser, tokens); + + if (!me) { + // set state to undefined + } + }); + + yield takeEvery( + ADD_NEW_STORIES_USERS_TO_COURSE, + function* (action: ReturnType): any { + const tokens: Tokens = yield selectTokens(); + const { users, provider } = action.payload; + + yield call(postNewStoriesUsers, tokens, users, provider); + + // TODO: Refresh the list of story users + // once that page is implemented + } + ); } export default storiesSaga; diff --git a/src/commons/sagas/WorkspaceSaga.ts b/src/commons/sagas/WorkspaceSaga.ts index 4d68afe75c..d69e9c2d88 100644 --- a/src/commons/sagas/WorkspaceSaga.ts +++ b/src/commons/sagas/WorkspaceSaga.ts @@ -103,6 +103,7 @@ export default function* WorkspaceSaga(): SagaIterator { actions.handleConsoleLog(action.payload.workspaceLocation, action.payload.errorMsg) ); } else { + // FIXME: Use story env yield put( actions.handleStoriesConsoleLog(action.payload.workspaceLocation, action.payload.errorMsg) ); @@ -324,8 +325,7 @@ export default function* WorkspaceSaga(): SagaIterator { const codeFiles = { [codeFilePath]: code }; - // TODO: Check what happens to the env here - yield call(evalCode, codeFiles, codeFilePath, context, execTime, 'stories', EVAL_STORY); + yield call(evalCode, codeFiles, codeFilePath, context, execTime, 'stories', EVAL_STORY, env); }); yield takeEvery(DEBUG_RESUME, function* (action: ReturnType) { @@ -654,12 +654,13 @@ function* clearContext(workspaceLocation: WorkspaceLocation, entrypointCode: str export function* dumpDisplayBuffer( workspaceLocation: WorkspaceLocation, - isStoriesBlock: boolean = false + isStoriesBlock: boolean = false, + storyEnv?: string ): Generator { if (!isStoriesBlock) { yield put(actions.handleConsoleLog(workspaceLocation, ...DisplayBufferService.dump())); } else { - yield put(actions.handleStoriesConsoleLog(workspaceLocation, ...DisplayBufferService.dump())); + yield put(actions.handleStoriesConsoleLog(storyEnv!, ...DisplayBufferService.dump())); } } @@ -1036,11 +1037,12 @@ export function* evalCode( context: Context, execTime: number, workspaceLocation: WorkspaceLocation, - actionType: string + actionType: string, + storyEnv?: string ): SagaIterator { context.runtime.debuggerOn = (actionType === EVAL_EDITOR || actionType === DEBUG_RESUME) && context.chapter > 2; - const isStoriesBlock = actionType === EVAL_STORY; + const isStoriesBlock = actionType === EVAL_STORY || workspaceLocation === 'stories'; // Logic for execution of substitution model visualizer const correctWorkspace = workspaceLocation === 'playground' || workspaceLocation === 'sicp'; @@ -1051,10 +1053,11 @@ export function* evalCode( .usingSubst ) : isStoriesBlock - ? yield select((state: OverallState) => state.stories.envs[workspaceLocation].usingSubst) + ? // Safe to use ! as storyEnv will be defined from above when we call from EVAL_STORY + yield select((state: OverallState) => state.stories.envs[storyEnv!].usingSubst) : false; const stepLimit: number = isStoriesBlock - ? yield select((state: OverallState) => state.stories.envs[workspaceLocation].stepLimit) + ? yield select((state: OverallState) => state.stories.envs[storyEnv!].stepLimit) : yield select((state: OverallState) => state.workspaces[workspaceLocation].stepLimit); const substActiveAndCorrectChapter = context.chapter <= 2 && substIsActive; if (substActiveAndCorrectChapter) { @@ -1081,7 +1084,10 @@ export function* evalCode( .updateEnv ) : false; - const envSteps: number = correctWorkspace + // When envSteps is -1, the entire code is run from the start. + const envSteps: number = needUpdateEnv + ? -1 + : correctWorkspace ? yield select( (state: OverallState) => (state.workspaces[workspaceLocation] as PlaygroundWorkspaceState | SicpWorkspaceState) @@ -1092,8 +1098,6 @@ export function* evalCode( if (envActiveAndCorrectChapter) { context.executionMethod = 'ec-evaluator'; } - // When envSteps is -1, the entire code is run from the start. - context.runtime.envSteps = needUpdateEnv ? -1 : envSteps; const entrypointCode = files[entrypointFilePath]; @@ -1105,14 +1109,16 @@ export function* evalCode( executionMethod: 'interpreter', originalMaxExecTime: execTime, stepLimit: stepLimit, - useSubst: substActiveAndCorrectChapter + useSubst: substActiveAndCorrectChapter, + envSteps: envSteps }); } else if (variant === Variant.LAZY) { return call(runFilesInContext, files, entrypointFilePath, context, { scheduler: 'preemptive', originalMaxExecTime: execTime, stepLimit: stepLimit, - useSubst: substActiveAndCorrectChapter + useSubst: substActiveAndCorrectChapter, + envSteps: envSteps }); } else if (variant === Variant.WASM) { // Note: WASM does not support multiple file programs. @@ -1167,7 +1173,8 @@ export function* evalCode( originalMaxExecTime: execTime, stepLimit: stepLimit, throwInfiniteLoops: true, - useSubst: substActiveAndCorrectChapter + useSubst: substActiveAndCorrectChapter, + envSteps: envSteps }), /** @@ -1213,11 +1220,12 @@ export function* evalCode( result.status !== 'suspended-non-det' && result.status !== 'suspended-ec-eval' ) { - yield* dumpDisplayBuffer(workspaceLocation, isStoriesBlock); + yield* dumpDisplayBuffer(workspaceLocation, isStoriesBlock, storyEnv); if (!isStoriesBlock) { yield put(actions.evalInterpreterError(context.errors, workspaceLocation)); } else { - yield put(actions.evalStoryError(context.errors, workspaceLocation)); + // Safe to use ! as storyEnv will be defined from above when we call from EVAL_STORY + yield put(actions.evalStoryError(context.errors, storyEnv!)); } // we need to parse again, but preserve the errors in context const oldErrors = context.errors; @@ -1248,14 +1256,15 @@ export function* evalCode( lastNonDetResult = result; } - yield* dumpDisplayBuffer(workspaceLocation, isStoriesBlock); + yield* dumpDisplayBuffer(workspaceLocation, isStoriesBlock, storyEnv); // Do not write interpreter output to REPL, if executing chunks (e.g. prepend/postpend blocks) if (actionType !== EVAL_SILENT) { if (!isStoriesBlock) { yield put(actions.evalInterpreterSuccess(result.value, workspaceLocation)); } else { - yield put(actions.evalStorySuccess(result.value, workspaceLocation)); + // Safe to use ! as storyEnv will be defined from above when we call from EVAL_STORY + yield put(actions.evalStorySuccess(result.value, storyEnv!)); } } @@ -1270,7 +1279,8 @@ export function* evalCode( } if (isStoriesBlock) { yield put( - notifyStoriesEvaluated(result, lastDebuggerResult, entrypointCode, context, workspaceLocation) + // Safe to use ! as storyEnv will be defined from above when we call from EVAL_STORY + notifyStoriesEvaluated(result, lastDebuggerResult, entrypointCode, context, storyEnv!) ); } diff --git a/src/commons/sagas/__tests__/WorkspaceSaga.ts b/src/commons/sagas/__tests__/WorkspaceSaga.ts index a3a95c02d2..a8765ddeeb 100644 --- a/src/commons/sagas/__tests__/WorkspaceSaga.ts +++ b/src/commons/sagas/__tests__/WorkspaceSaga.ts @@ -279,7 +279,8 @@ describe('EVAL_REPL', () => { originalMaxExecTime: 1000, stepLimit: 1000, useSubst: false, - throwInfiniteLoops: true + throwInfiniteLoops: true, + envSteps: -1 }) .dispatch({ type: EVAL_REPL, @@ -839,7 +840,8 @@ describe('evalCode', () => { originalMaxExecTime: 1000, stepLimit: 1000, useSubst: false, - throwInfiniteLoops: true + throwInfiniteLoops: true, + envSteps: -1 }; lastDebuggerResult = { status: 'error' }; state = generateDefaultState(workspaceLocation); @@ -868,7 +870,8 @@ describe('evalCode', () => { originalMaxExecTime: execTime, stepLimit: 1000, useSubst: false, - throwInfiniteLoops: true + throwInfiniteLoops: true, + envSteps: -1 }) .put(evalInterpreterSuccess(value, workspaceLocation)) .silentRun(); @@ -893,7 +896,8 @@ describe('evalCode', () => { originalMaxExecTime: execTime, stepLimit: 1000, useSubst: false, - throwInfiniteLoops: true + throwInfiniteLoops: true, + envSteps: -1 }) .put(endDebuggerPause(workspaceLocation)) .put(evalInterpreterSuccess('Breakpoint hit!', workspaceLocation)) @@ -916,7 +920,8 @@ describe('evalCode', () => { originalMaxExecTime: execTime, stepLimit: 1000, useSubst: false, - throwInfiniteLoops: true + throwInfiniteLoops: true, + envSteps: -1 }) .put.like({ action: { type: EVAL_INTERPRETER_ERROR } }) .silentRun(); @@ -950,7 +955,8 @@ describe('evalCode', () => { originalMaxExecTime: execTime, stepLimit: 1000, useSubst: false, - throwInfiniteLoops: true + throwInfiniteLoops: true, + envSteps: -1 }) .put(evalInterpreterError(context.errors, workspaceLocation)) .silentRun(); diff --git a/src/commons/sideContent/GenericSideContent.tsx b/src/commons/sideContent/GenericSideContent.tsx index c63b4cd369..bdde9425d7 100644 --- a/src/commons/sideContent/GenericSideContent.tsx +++ b/src/commons/sideContent/GenericSideContent.tsx @@ -1,6 +1,7 @@ import { TabId } from '@blueprintjs/core'; import React from 'react'; +import { OverallState } from '../application/ApplicationTypes'; import { useTypedSelector } from '../utils/Hooks'; import { DebuggerContext, WorkspaceLocation } from '../workspace/WorkspaceTypes'; import { getDynamicTabs } from './SideContentHelper'; @@ -47,7 +48,7 @@ type StateProps = { afterDynamicTabs: SideContentTab[]; }; workspaceLocation?: WorkspaceLocation; - isStories?: boolean; + getDebuggerContext?: (state: OverallState) => DebuggerContext | undefined; }; const GenericSideContent = (props: GenericSideContentProps) => { @@ -57,10 +58,10 @@ const GenericSideContent = (props: GenericSideContentProps) => { ); // Fetch debuggerContext from store - const debuggerContext = useTypedSelector(state => - props.isStories - ? props.workspaceLocation && state.stories.envs[props.workspaceLocation].debuggerContext - : props.workspaceLocation && state.workspaces[props.workspaceLocation].debuggerContext + const debuggerContext = useTypedSelector( + props.getDebuggerContext ?? + (state => + props.workspaceLocation && state.workspaces[props.workspaceLocation].debuggerContext) ); React.useEffect(() => { diff --git a/src/commons/sideContent/SideContentEnvVisualizer.tsx b/src/commons/sideContent/SideContentEnvVisualizer.tsx index f591950acd..b8873da2f2 100644 --- a/src/commons/sideContent/SideContentEnvVisualizer.tsx +++ b/src/commons/sideContent/SideContentEnvVisualizer.tsx @@ -29,6 +29,8 @@ type State = { value: number; height: number; width: number; + lastStep: boolean; + stepLimitExceeded: boolean; }; type EnvVisualizerProps = OwnProps & StateProps & DispatchProps; @@ -70,7 +72,9 @@ class SideContentEnvVisualizer extends React.Component this.setState({ visualization }), @@ -80,6 +84,9 @@ class SideContentEnvVisualizer extends React.Component { + this.setState({ stepLimitExceeded: !isAgendaEmpty && this.state.lastStep }); } ); } @@ -129,7 +136,9 @@ class SideContentEnvVisualizer extends React.Component {' '} - {this.state.visualization || ( + {this.state.visualization ? ( + this.state.stepLimitExceeded ? ( +
+ Maximum number of steps exceeded. + + Please increase the step limit if you would like to see futher evaluation. +
+ ) : ( + this.state.visualization + ) + ) : (
{ + if (newValue === this.props.numOfStepsTotal) { + this.setState({ lastStep: true }); + } else { + this.setState({ lastStep: false }); + } this.props.handleEditorEval(this.props.workspaceLocation); }; @@ -367,7 +396,7 @@ class SideContentEnvVisualizer extends React.Component { if (this.state.value !== 1) { this.sliderShift(this.state.value - 1); - this.props.handleEditorEval(this.props.workspaceLocation); + this.sliderRelease(this.state.value - 1); } }; @@ -375,32 +404,32 @@ class SideContentEnvVisualizer extends React.Component { // Move to the first step this.sliderShift(1); - this.props.handleEditorEval(this.props.workspaceLocation); + this.sliderRelease(1); }; private stepLast = (lastStepValue: number) => () => { // Move to the last step this.sliderShift(lastStepValue); - this.props.handleEditorEval(this.props.workspaceLocation); + this.sliderRelease(lastStepValue); }; private stepNextBreakpoint = () => { for (const step of this.props.breakpointSteps) { if (step > this.state.value) { this.sliderShift(step); - this.props.handleEditorEval(this.props.workspaceLocation); + this.sliderRelease(step); return; } } this.sliderShift(this.props.numOfStepsTotal); - this.props.handleEditorEval(this.props.workspaceLocation); + this.sliderRelease(this.props.numOfStepsTotal); }; private stepPrevBreakpoint = () => { @@ -408,12 +437,12 @@ class SideContentEnvVisualizer extends React.Component action(SAVE_CANVAS, canvas); @@ -11,3 +17,6 @@ export const createCourse = (courseConfig: UpdateCourseConfiguration) => export const addNewUsersToCourse = (users: UsernameRoleGroup[], provider: string) => action(ADD_NEW_USERS_TO_COURSE, { users, provider }); + +export const addNewStoriesUsersToCourse = (users: NameUsernameRole[], provider: string) => + action(ADD_NEW_STORIES_USERS_TO_COURSE, { users, provider }); diff --git a/src/features/academy/AcademyTypes.ts b/src/features/academy/AcademyTypes.ts index d50464de69..07b12c94a9 100644 --- a/src/features/academy/AcademyTypes.ts +++ b/src/features/academy/AcademyTypes.ts @@ -10,6 +10,7 @@ export const gradingRegExp = ':submissionId?/:questionId?'; export const CREATE_COURSE = 'CREATE_COURSE'; export const ADD_NEW_USERS_TO_COURSE = 'ADD_NEW_USERS_TO_COURSE'; +export const ADD_NEW_STORIES_USERS_TO_COURSE = 'ADD_STORIES_NEW_USERS_TO_COURSE'; export type AcademyState = { readonly gameCanvas?: HTMLCanvasElement; diff --git a/src/features/envVisualizer/EnvVisualizer.tsx b/src/features/envVisualizer/EnvVisualizer.tsx index 3c70ce2576..fa0640483e 100644 --- a/src/features/envVisualizer/EnvVisualizer.tsx +++ b/src/features/envVisualizer/EnvVisualizer.tsx @@ -8,21 +8,24 @@ import { deepCopyTree, getEnvID } from './EnvVisualizerUtils'; type SetVis = (vis: React.ReactNode) => void; type SetEditorHighlightedLines = (segments: [number, number][]) => void; +type SetisStepLimitExceeded = (isAgendaEmpty: boolean) => void; /** Environment Visualizer is exposed from this class */ export default class EnvVisualizer { /** callback function to update the visualization state in the SideContentEnvVis component */ private static setVis: SetVis; /** function to highlight editor lines */ + public static setEditorHighlightedLines: SetEditorHighlightedLines; + /** callback function to update the step limit exceeded state in the SideContentEnvVis component */ + private static setIsStepLimitExceeded: SetisStepLimitExceeded; private static printableMode: boolean = false; private static compactLayout: boolean = true; private static agendaStash: boolean = false; private static stackTruncated: boolean = false; - private static environmentTree: EnvTree; + private static environmentTree: EnvTree | undefined; private static currentEnvId: string; - private static agenda: Agenda; - private static stash: Stash; - public static setEditorHighlightedLines: SetEditorHighlightedLines; + private static agenda: Agenda | undefined; + private static stash: Stash | undefined; public static togglePrintableMode(): void { EnvVisualizer.printableMode = !EnvVisualizer.printableMode; } @@ -51,17 +54,23 @@ export default class EnvVisualizer { return EnvVisualizer.stackTruncated; } + public static isAgenda(): boolean { + return this.agenda ? !this.agenda.isEmpty() : false; + } + /** SideContentEnvVis initializes this onMount with the callback function */ static init( setVis: SetVis, width: number, height: number, - setEditorHighlightedLines: (segments: [number, number][]) => void + setEditorHighlightedLines: (segments: [number, number][]) => void, + setIsStepLimitExceeded: SetisStepLimitExceeded ) { Layout.visibleHeight = height; Layout.visibleWidth = width; this.setVis = setVis; this.setEditorHighlightedLines = setEditorHighlightedLines; + this.setIsStepLimitExceeded = setIsStepLimitExceeded; } static clear() { @@ -86,6 +95,7 @@ export default class EnvVisualizer { context.runtime.stash ); this.setVis(Layout.draw()); + this.setIsStepLimitExceeded(context.runtime.agenda.isEmpty()); Layout.updateDimensions(Layout.visibleWidth, Layout.visibleHeight); // icon to blink @@ -94,7 +104,7 @@ export default class EnvVisualizer { } static redraw() { - if (this.environmentTree) { + if (EnvVisualizer.environmentTree && EnvVisualizer.agenda && EnvVisualizer.stash) { // checks if the required diagram exists, and updates the dom node using setVis if ( EnvVisualizer.getCompactLayout() && @@ -173,6 +183,9 @@ export default class EnvVisualizer { static clearEnv() { if (this.setVis) { this.setVis(undefined); + EnvVisualizer.environmentTree = undefined; + EnvVisualizer.agenda = undefined; + EnvVisualizer.stash = undefined; } this.clear(); } diff --git a/src/features/envVisualizer/EnvVisualizerAgendaStash.ts b/src/features/envVisualizer/EnvVisualizerAgendaStash.ts index f8f83d4a82..89c1aa37cd 100644 --- a/src/features/envVisualizer/EnvVisualizerAgendaStash.ts +++ b/src/features/envVisualizer/EnvVisualizerAgendaStash.ts @@ -15,6 +15,11 @@ export enum AgendaStashConfig { StashMaxTextHeight = 30, StashItemCornerRadius = AgendaItemCornerRadius, + ShowMoreButtonWidth = 80, + ShowMoreButtonHeight = 15, + ShowMoreButtonX = 40, + ShowMoreButtonY = 25, + TooltipOpacity = 0.7, TooltipMargin = 5, TooltipPadding = 5, diff --git a/src/features/envVisualizer/EnvVisualizerUtils.ts b/src/features/envVisualizer/EnvVisualizerUtils.ts index 6023fa3a0d..8fa8d3e9e7 100644 --- a/src/features/envVisualizer/EnvVisualizerUtils.ts +++ b/src/features/envVisualizer/EnvVisualizerUtils.ts @@ -616,13 +616,15 @@ export function getAgendaItemComponent( } export function getStashItemComponent(stashItem: StashValue, stackHeight: number, index: number) { - if (isFn(stashItem) || isArray(stashItem)) { + if (isFn(stashItem) || isGlobalFn(stashItem) || isArray(stashItem)) { for (const level of Layout.compactLevels) { for (const frame of level.frames) { - if (isFn(stashItem)) { + if (isFn(stashItem) || isGlobalFn(stashItem)) { const fn: FnValue | GlobalFnValue | undefined = frame.bindings.find(binding => { - if (isFn(binding.data)) { + if (isFn(stashItem) && isFn(binding.data)) { return binding.data.id === stashItem.id; + } else if (isGlobalFn(stashItem) && isGlobalFn(binding.data)) { + return binding.data?.toString() === stashItem.toString(); } return false; })?.value as FnValue | GlobalFnValue; @@ -658,16 +660,22 @@ export const isStashItemInDanger = (stashIndex: number): boolean => { case InstrType.UNARY_OP: case InstrType.POP: case InstrType.BRANCH: - return Layout.stash.size() - stashIndex <= 1; + return Layout.stashComponent.stashItemComponents.length - stashIndex <= 1; case InstrType.BINARY_OP: case InstrType.ARRAY_ACCESS: - return Layout.stash.size() - stashIndex <= 2; + return Layout.stashComponent.stashItemComponents.length - stashIndex <= 2; case InstrType.ARRAY_ASSIGNMENT: - return Layout.stash.size() - stashIndex <= 3; + return Layout.stashComponent.stashItemComponents.length - stashIndex <= 3; case InstrType.APPLICATION: - return Layout.stash.size() - stashIndex <= (agendaItem as AppInstr).numOfArgs + 1; + return ( + Layout.stashComponent.stashItemComponents.length - stashIndex <= + (agendaItem as AppInstr).numOfArgs + 1 + ); case InstrType.ARRAY_LITERAL: - return Layout.stash.size() - stashIndex <= (agendaItem as ArrLitInstr).arity; + return ( + Layout.stashComponent.stashItemComponents.length - stashIndex <= + (agendaItem as ArrLitInstr).arity + ); } } return false; diff --git a/src/features/envVisualizer/__tests__/EnvVisualizer.tsx b/src/features/envVisualizer/__tests__/EnvVisualizer.tsx index b64da4716d..81c7f512d4 100644 --- a/src/features/envVisualizer/__tests__/EnvVisualizer.tsx +++ b/src/features/envVisualizer/__tests__/EnvVisualizer.tsx @@ -2,6 +2,8 @@ import { runInContext } from 'js-slang/dist/'; import createContext from 'js-slang/dist/createContext'; import { Config } from 'src/features/envVisualizer/EnvVisualizerConfig'; +import { AgendaItemComponent } from '../compactComponents/AgendaItemComponent'; +import { StashItemComponent } from '../compactComponents/StashItemComponent'; import { ArrayUnit } from '../components/ArrayUnit'; import { ArrowFromArrayUnit } from '../components/arrows/ArrowFromArrayUnit'; import { Frame } from '../components/Frame'; @@ -171,3 +173,77 @@ codeSamples.forEach((code, idx) => { checkNonCompactLayout(); }); }); + +const codeSamplesAgendaStash = [ + [ + 'arrows from the environment instruction to the frame and arrows from the stash to closures', + ` + function create(n) { + const arr = []; + let x = 0; + + while (x < n) { + arr[x] = () => x; + x = x + 1; + } + return arr; + } + create(3)[1](); + `, + 33 + ], + [ + 'global environments are treated correctly', + ` + { + const math_sin = x => x; + } + math_sin(math_PI / 2); + `, + 7 + ], + [ + 'Agenda is truncated properly', + ` + function fact(n) { + return n <= 1 ? 1 : n * fact(n - 1); + } + fact(10); + `, + 161, + true + ] +]; + +codeSamplesAgendaStash.forEach((codeSample, idx) => { + test('EnvVisualizer Agenda Stash correctly renders: ' + codeSample[0], async () => { + const code = codeSample[1] as string; + const envSteps = codeSample[2] as number; + const truncate = codeSample[3]; + if (!EnvVisualizer.getCompactLayout()) { + EnvVisualizer.toggleCompactLayout(); + } + if (truncate) { + EnvVisualizer.toggleStackTruncated(); + } + EnvVisualizer.toggleAgendaStash(); + const context = createContext(4); + await runInContext(code, context, { executionMethod: 'ec-evaluator', envSteps: envSteps }); + Layout.setContext( + context.runtime.environmentTree as EnvTree, + context.runtime.agenda!, + context.runtime.stash! + ); + Layout.draw(); + const agendaItemsToTest: AgendaItemComponent[] = Layout.agendaComponent.stackItemComponents; + const stashItemsToTest: StashItemComponent[] = Layout.stashComponent.stashItemComponents; + agendaItemsToTest.forEach(item => { + expect(item.draw()).toMatchSnapshot(); + if (item.value == 'ENVIRONMENT') expect(item.arrow).toBeDefined(); + }); + if (truncate) expect(agendaItemsToTest.length).toBeLessThanOrEqual(10); + stashItemsToTest.forEach(item => { + expect(item.draw()).toMatchSnapshot(); + }); + }); +}); diff --git a/src/features/envVisualizer/__tests__/__snapshots__/EnvVisualizer.tsx.snap b/src/features/envVisualizer/__tests__/__snapshots__/EnvVisualizer.tsx.snap index 1b3a509d70..db36055464 100644 --- a/src/features/envVisualizer/__tests__/__snapshots__/EnvVisualizer.tsx.snap +++ b/src/features/envVisualizer/__tests__/__snapshots__/EnvVisualizer.tsx.snap @@ -1,5 +1,2008 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 1`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 2`] = ` + + + + + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 3`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 4`] = ` + + + + + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 5`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 6`] = ` + + + + + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 7`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 8`] = ` + + + + + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 9`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 10`] = ` + + + + + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 11`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 12`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 13`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 14`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 15`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: Agenda is truncated properly 16`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 1`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 2`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 3`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 4`] = ` + + + + + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 5`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 6`] = ` + + + + + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 7`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 8`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 9`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 10`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 11`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 12`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 13`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 14`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 15`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 16`] = ` + + + + + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: arrows from the environment instruction to the frame and arrows from the stash to closures 17`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: global environments are treated correctly 1`] = ` + + + + +`; + +exports[`EnvVisualizer Agenda Stash correctly renders: global environments are treated correctly 2`] = ` + + + + + + + + +`; + exports[`EnvVisualizer calculates correct layout for code sample 0 1`] = ` Object { "head": Object { @@ -1308,6 +3311,7 @@ Array [ exports[`EnvVisualizer calculates correct layout for code sample 7 1`] = ` Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -1320,7 +3324,6 @@ Object { exports[`EnvVisualizer calculates correct layout for code sample 7 2`] = ` Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -1347,6 +3350,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -1386,6 +3390,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -1457,6 +3462,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -1496,6 +3502,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -1566,6 +3573,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -1605,6 +3613,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -1625,7 +3634,6 @@ Object { "name": "blockEnvironment", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -1652,6 +3660,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -1691,6 +3700,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -1762,7 +3772,6 @@ Object { "name": "eval_stream", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -1789,6 +3798,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -1828,6 +3838,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -1855,7 +3866,6 @@ Object { "name": "blockEnvironment", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -1882,6 +3892,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -1921,6 +3932,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -1949,7 +3961,6 @@ Object { "name": "blockEnvironment", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -1976,6 +3987,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -2015,6 +4027,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -2043,7 +4056,6 @@ Object { "name": "blockEnvironment", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -2070,6 +4082,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -2109,6 +4122,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -2137,7 +4151,6 @@ Object { "name": "blockEnvironment", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -2164,6 +4177,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -2203,6 +4217,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -2281,7 +4296,6 @@ Object { "name": "eval_stream", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -2308,6 +4322,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -2347,6 +4362,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -2381,7 +4397,6 @@ Object { "name": "blockEnvironment", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -2408,6 +4423,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -2447,6 +4463,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -2482,7 +4499,6 @@ Object { "name": "blockEnvironment", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -2509,6 +4525,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -2548,6 +4565,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -2583,7 +4601,6 @@ Object { "name": "blockEnvironment", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -2610,6 +4627,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -2649,6 +4667,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -2684,7 +4703,6 @@ Object { "name": "blockEnvironment", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -2711,6 +4729,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -2750,6 +4769,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -2885,7 +4905,6 @@ Object { "name": "eval_stream", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -2912,6 +4931,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -2951,6 +4971,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -3086,7 +5107,6 @@ Object { "name": "eval_stream", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -3113,6 +5133,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -3152,6 +5173,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -3195,7 +5217,6 @@ Object { "name": "blockEnvironment", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -3222,6 +5243,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -3261,6 +5283,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -3305,7 +5328,6 @@ Object { "name": "blockEnvironment", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -3332,6 +5354,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -3371,6 +5394,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -3415,7 +5439,6 @@ Object { "name": "blockEnvironment", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -3442,6 +5465,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -3481,6 +5505,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -3525,7 +5550,6 @@ Object { "name": "blockEnvironment", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -3552,6 +5576,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -3591,6 +5616,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -3742,7 +5768,6 @@ Object { "name": "eval_stream", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -3769,6 +5794,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -3808,6 +5834,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, @@ -3959,7 +5986,6 @@ Object { "name": "eval_stream", "tail": Object { "head": Object { - "412": [Function], "eval_stream": [Function], "x": 1, "y": Array [ @@ -3986,6 +6012,7 @@ Object { "$remove": [Function], "$remove_all": [Function], "$reverse": [Function], + "411": [Function], "__access_export__": [Function], "__access_named_export__": [Function], "accumulate": [Function], @@ -4025,6 +6052,7 @@ Object { "name": "programEnvironment", "tail": Object { "head": Object { + "411": [Function], ":::pre-declared names::": Symbol(), "integers_from": [Function], }, diff --git a/src/features/envVisualizer/compactComponents/AgendaStack.tsx b/src/features/envVisualizer/compactComponents/AgendaStack.tsx index 77a000e8b5..6f18087643 100644 --- a/src/features/envVisualizer/compactComponents/AgendaStack.tsx +++ b/src/features/envVisualizer/compactComponents/AgendaStack.tsx @@ -3,14 +3,22 @@ import { Agenda } from 'js-slang/dist/ec-evaluator/interpreter'; import { AgendaItem, Instr } from 'js-slang/dist/ec-evaluator/types'; import { KonvaEventObject } from 'konva/lib/Node'; import React from 'react'; -import { Group } from 'react-konva'; +import { Group, Label, Tag, Text } from 'react-konva'; import { Visible } from '../components/Visible'; import EnvVisualizer from '../EnvVisualizer'; import { AgendaStashConfig } from '../EnvVisualizerAgendaStash'; +import { CompactConfig } from '../EnvVisualizerCompactConfig'; import { Layout } from '../EnvVisualizerLayout'; import { IHoverable } from '../EnvVisualizerTypes'; -import { getAgendaItemComponent } from '../EnvVisualizerUtils'; +import { + defaultSAColor, + getAgendaItemComponent, + setHoveredCursor, + setHoveredStyle, + setUnhoveredCursor, + setUnhoveredStyle +} from '../EnvVisualizerUtils'; import { AgendaItemComponent } from './AgendaItemComponent'; export class AgendaStack extends Visible implements IHoverable { @@ -25,7 +33,7 @@ export class AgendaStack extends Visible implements IHoverable { this._x = AgendaStashConfig.AgendaPosX; this._y = AgendaStashConfig.AgendaPosY; this._width = AgendaStashConfig.AgendaItemWidth; - this._height = AgendaStashConfig.StashItemHeight + AgendaStashConfig.AgendaItemTextPadding * 2; + this._height = AgendaStashConfig.StashItemHeight + AgendaStashConfig.StashItemTextPadding * 2; this.agenda = agenda; // Function to convert the stack items to their components @@ -61,16 +69,53 @@ export class AgendaStack extends Visible implements IHoverable { .slice(EnvVisualizer.getStackTruncated() ? -10 : 0) .map(agendaItemToComponent); } - onMouseEnter(e: KonvaEventObject): void {} - onMouseLeave(e: KonvaEventObject): void {} + onMouseEnter(e: KonvaEventObject): void { + setHoveredStyle(e.currentTarget); + setHoveredCursor(e.currentTarget); + } + onMouseLeave(e: KonvaEventObject): void { + setUnhoveredStyle(e.currentTarget); + setUnhoveredCursor(e.currentTarget); + } destroy() { this.ref.current.destroyChildren(); } draw(): React.ReactNode { + const textProps = { + fontFamily: AgendaStashConfig.FontFamily.toString(), + fontSize: 12, + fontStyle: AgendaStashConfig.FontStyle.toString(), + fontVariant: AgendaStashConfig.FontVariant.toString() + }; return ( + {EnvVisualizer.getStackTruncated() && Layout.agenda.size() > 10 && ( + + )} {this.stackItemComponents.map(c => c?.draw())} ); diff --git a/src/features/stories/StoriesActions.ts b/src/features/stories/StoriesActions.ts index 28259a7064..c418411db0 100644 --- a/src/features/stories/StoriesActions.ts +++ b/src/features/stories/StoriesActions.ts @@ -10,13 +10,16 @@ import { EVAL_STORY_ERROR, EVAL_STORY_SUCCESS, GET_STORIES_LIST, + GET_STORIES_USER, HANDLE_STORIES_CONSOLE_LOG, NOTIFY_STORIES_EVALUATED, SAVE_STORY, + SET_CURRENT_STORIES_USER, SET_CURRENT_STORY, SET_CURRENT_STORY_ID, StoryData, StoryListView, + StoryParams, TOGGLE_STORIES_USING_SUBST, UPDATE_STORIES_LIST } from './StoriesTypes'; @@ -34,7 +37,7 @@ export const evalStoryError = (errors: SourceError[], env: string) => export const evalStorySuccess = (value: Value, env: string) => action(EVAL_STORY_SUCCESS, { type: 'result', value, env }); -export const handleStoriesConsoleLog = (env: String, ...logString: string[]) => +export const handleStoriesConsoleLog = (env: string, ...logString: string[]) => action(HANDLE_STORIES_CONSOLE_LOG, { logString, env }); export const notifyStoriesEvaluated = ( @@ -61,6 +64,10 @@ export const updateStoriesList = (storyList: StoryListView[]) => action(UPDATE_STORIES_LIST, storyList); export const setCurrentStory = (story: StoryData | null) => action(SET_CURRENT_STORY, story); export const setCurrentStoryId = (id: number | null) => action(SET_CURRENT_STORY_ID, id); -export const createStory = (story: StoryData) => action(CREATE_STORY, story); // TODO: Unused as of now -export const saveStory = (story: StoryData, id: number) => action(SAVE_STORY, story, id); // TODO: Unused as of now +export const createStory = (story: StoryParams) => action(CREATE_STORY, story); +export const saveStory = (story: StoryParams, id: number) => action(SAVE_STORY, { story, id }); export const deleteStory = (id: number) => action(DELETE_STORY, id); +// Auth-related actions +export const getStoriesUser = () => action(GET_STORIES_USER); +export const setCurrentStoriesUser = (id: number | undefined, name: string | undefined) => + action(SET_CURRENT_STORIES_USER, { id, name }); diff --git a/src/features/stories/StoriesReducer.ts b/src/features/stories/StoriesReducer.ts index b8c0608f61..e67fcd8430 100644 --- a/src/features/stories/StoriesReducer.ts +++ b/src/features/stories/StoriesReducer.ts @@ -18,6 +18,7 @@ import { EVAL_STORY_SUCCESS, HANDLE_STORIES_CONSOLE_LOG, NOTIFY_STORIES_EVALUATED, + SET_CURRENT_STORIES_USER, SET_CURRENT_STORY, SET_CURRENT_STORY_ID, StoriesState, @@ -29,7 +30,7 @@ export const StoriesReducer: Reducer = ( state = defaultStories, action: SourceActionType ) => { - const env: string = (action as any).payload ? (action as any).payload.env : DEFAULT_ENV; + const env: string = (action as any).payload?.env ?? DEFAULT_ENV; let newOutput: InterpreterOutput[]; let lastOutput: InterpreterOutput; switch (action.type) { @@ -208,6 +209,12 @@ export const StoriesReducer: Reducer = ( ...state, currentStory: action.payload }; + case SET_CURRENT_STORIES_USER: + return { + ...state, + // TODO: Use action.payload.name + userId: action.payload.id + }; default: return state; } diff --git a/src/features/stories/StoriesTypes.ts b/src/features/stories/StoriesTypes.ts index 5291cad581..e678438d38 100644 --- a/src/features/stories/StoriesTypes.ts +++ b/src/features/stories/StoriesTypes.ts @@ -1,7 +1,7 @@ import { Context } from 'js-slang'; import { DebuggerContext } from 'src/commons/workspace/WorkspaceTypes'; -import { InterpreterOutput } from '../../commons/application/ApplicationTypes'; +import { InterpreterOutput, StoriesRole } from '../../commons/application/ApplicationTypes'; export const ADD_STORY_ENV = 'ADD_STORY_ENV'; export const CLEAR_STORY_ENV = 'CLEAR_STORY_ENV'; @@ -19,6 +19,9 @@ export const SET_CURRENT_STORY = 'SET_CURRENT_STORY'; export const CREATE_STORY = 'CREATE_STORY'; export const SAVE_STORY = 'SAVE_STORY'; export const DELETE_STORY = 'DELETE_STORY'; +// Auth-related actions +export const GET_STORIES_USER = 'GET_STORIES_USER'; +export const SET_CURRENT_STORIES_USER = 'SET_CURRENT_STORIES_USER'; export type StoryMetadata = { authorId: number; @@ -28,8 +31,11 @@ export type StoryMetadata = { export type StoryData = { title: string; content: string; + pinOrder: number | null; }; +export type StoryParams = StoryData; + export type StoryListView = StoryData & StoryMetadata & { id: number; @@ -52,9 +58,15 @@ export type StoriesEnvState = { readonly debuggerContext: DebuggerContext; }; +type StoriesAuthState = { + readonly userId?: number; + readonly groupId?: number; + readonly role?: StoriesRole; +}; + export type StoriesState = { readonly storyList: StoryListView[]; readonly currentStoryId: number | null; readonly currentStory: StoryData | null; readonly envs: { [key: string]: StoriesEnvState }; -}; +} & StoriesAuthState; diff --git a/src/features/stories/storiesComponents/BackendAccess.ts b/src/features/stories/storiesComponents/BackendAccess.ts index 3917b382a3..35d7b10695 100644 --- a/src/features/stories/storiesComponents/BackendAccess.ts +++ b/src/features/stories/storiesComponents/BackendAccess.ts @@ -1,7 +1,12 @@ import Constants from 'src/commons/utils/Constants'; -import { showWarningMessage } from 'src/commons/utils/notifications/NotificationsHelper'; +import { + showSuccessMessage, + showWarningMessage +} from 'src/commons/utils/notifications/NotificationsHelper'; import { request } from 'src/commons/utils/RequestHelper'; +import { Tokens } from '../../../commons/application/types/SessionTypes'; +import { NameUsernameRole } from '../../../pages/academy/adminPanel/subcomponents/AddStoriesUserPanel'; import { StoryListView, StoryView } from '../StoriesTypes'; type RemoveLast = T extends [...infer U, any] ? U : T; @@ -11,8 +16,45 @@ const requestStoryBackend = async (...[path, method, opts]: StoryRequestHelperPa return resp; }; -export const getStories = async (): Promise => { - const resp = await requestStoryBackend('/stories', 'GET', {}); +export const getStoriesUser = async ( + tokens: Tokens +): Promise<{ + id: number; + name: string; + // TODO: Return role once permissions framework is implemented +} | null> => { + const resp = await requestStoryBackend('/user', 'GET', { ...tokens }); + if (!resp) { + return null; + } + const me = await resp.json(); + return me; +}; + +export const postNewStoriesUsers = async ( + tokens: Tokens, + users: NameUsernameRole[], + provider: string +): Promise => { + const resp = await requestStoryBackend('/users/batch', 'POST', { + // TODO: backend create params does not support roles yet, i.e. + // the role in NameUsernameRole is currently still unused + body: { users: users.map(user => ({ ...user, provider })) }, + ...tokens + }); + + if (!resp) { + showWarningMessage('Failed to add users'); + return null; + } + + showSuccessMessage('Users added!'); + return resp; + // TODO: Return response JSON directly. +}; + +export const getStories = async (tokens: Tokens): Promise => { + const resp = await requestStoryBackend('/stories', 'GET', { ...tokens }); if (!resp) { return null; } @@ -20,8 +62,8 @@ export const getStories = async (): Promise => { return stories; }; -export const getStory = async (storyId: number): Promise => { - const resp = await requestStoryBackend(`/stories/${storyId}`, 'GET', {}); +export const getStory = async (tokens: Tokens, storyId: number): Promise => { + const resp = await requestStoryBackend(`/stories/${storyId}`, 'GET', { ...tokens }); if (!resp) { return null; } @@ -30,60 +72,48 @@ export const getStory = async (storyId: number): Promise => { }; export const postStory = async ( + tokens: Tokens, authorId: number, title: string, content: string, - pinOrder?: number + pinOrder: number | null ): Promise => { const resp = await requestStoryBackend('/stories', 'POST', { - body: { authorId, title, content, pinOrder } + body: { authorId, title, content, pinOrder }, + ...tokens }); if (!resp) { + showWarningMessage('Failed to create story'); return null; } + showSuccessMessage('Story created'); const story = await resp.json(); return story; }; export const updateStory = async ( + tokens: Tokens, id: number, title: string, content: string, - pinOrder?: number -): Promise => { - try { - const resp = await fetch(`${Constants.storiesBackendUrl}/stories/${id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - title: title, - content: content, - pinOrder: pinOrder - }) - }); - if (!resp.ok) { - showWarningMessage( - `Error while communicating with backend: ${resp.status} ${resp.statusText}${ - resp.status === 401 || resp.status === 403 - ? '; try logging in again, after manually saving any work.' - : '' - }` - ); - return null; - } - return resp; - } catch (e) { - showWarningMessage('Error while communicating with backend; check your network?'); - + pinOrder: number | null +): Promise => { + const resp = await requestStoryBackend(`/stories/${id}`, 'PUT', { + body: { title, content, pinOrder }, + ...tokens + }); + if (!resp) { + showWarningMessage('Failed to save story'); return null; } + showSuccessMessage('Story saved'); + const updatedStory = await resp.json(); + return updatedStory; }; // Returns the deleted story, or null if errors occur -export const deleteStory = async (id: number): Promise => { - const resp = await requestStoryBackend(`/stories/${id}`, 'DELETE', {}); +export const deleteStory = async (tokens: Tokens, id: number): Promise => { + const resp = await requestStoryBackend(`/stories/${id}`, 'DELETE', { ...tokens }); if (!resp) { return null; } diff --git a/src/features/stories/storiesComponents/SourceBlock.tsx b/src/features/stories/storiesComponents/SourceBlock.tsx index 764d0e4f3e..678669cf55 100644 --- a/src/features/stories/storiesComponents/SourceBlock.tsx +++ b/src/features/stories/storiesComponents/SourceBlock.tsx @@ -44,7 +44,7 @@ function parseCommands(key: string, commandsString: string): string | undefined const SourceBlock: React.FC = props => { const dispatch = useDispatch(); const [code, setCode] = useState(props.children); - const [outputIndex, setOutputIndex] = useState(Infinity); + const [outputIndex, setOutputIndex] = useState(Infinity); const [sideContentHidden, setSideContentHidden] = useState(true); const [selectedTab, setSelectedTab] = useState(SideContentType.introduction); @@ -197,7 +197,7 @@ const SourceBlock: React.FC = props => { }, workspaceLocation: 'stories', storyEnv: env, - isStories: true + getDebuggerContext: state => state.stories.envs[env].debuggerContext }; const execEvaluate = () => { @@ -216,12 +216,17 @@ const SourceBlock: React.FC = props => { // to handle environment reset useEffect(() => { if (output.length === 0) { + console.log('setting to infinity'); setOutputIndex(Infinity); } + setOutputIndex(output.length); }, [output]); selectMode(chapter, variant, ExternalLibraryName.NONE); + console.log(outputIndex); + console.log(output); + return (
diff --git a/src/pages/academy/adminPanel/AdminPanel.tsx b/src/pages/academy/adminPanel/AdminPanel.tsx index 9353091fad..470e07c9be 100644 --- a/src/pages/academy/adminPanel/AdminPanel.tsx +++ b/src/pages/academy/adminPanel/AdminPanel.tsx @@ -7,7 +7,10 @@ import React from 'react'; import { useDispatch } from 'react-redux'; import { Role } from 'src/commons/application/ApplicationTypes'; import { useTypedSelector } from 'src/commons/utils/Hooks'; -import { addNewUsersToCourse } from 'src/features/academy/AcademyActions'; +import { + addNewStoriesUsersToCourse, + addNewUsersToCourse +} from 'src/features/academy/AcademyActions'; import { deleteAssessmentConfig, @@ -24,6 +27,7 @@ import { import { UpdateCourseConfiguration } from '../../../commons/application/types/SessionTypes'; import { AssessmentConfiguration } from '../../../commons/assessment/AssessmentTypes'; import ContentDisplay from '../../../commons/ContentDisplay'; +import AddStoriesUserPanel, { NameUsernameRole } from './subcomponents/AddStoriesUserPanel'; import AddUserPanel, { UsernameRoleGroup } from './subcomponents/AddUserPanel'; import AssessmentConfigPanel from './subcomponents/assessmentConfigPanel/AssessmentConfigPanel'; import CourseConfigPanel from './subcomponents/CourseConfigPanel'; @@ -129,6 +133,11 @@ const AdminPanel: React.FC = () => { dispatch(addNewUsersToCourse(users, provider)) }; + const addStoriesUserPanelProps = { + handleAddNewUsersToCourse: (users: NameUsernameRole[], provider: string) => + dispatch(addNewStoriesUsersToCourse(users, provider)) + }; + // Handler to submit changes to Course Configration and Assessment Configuration to the backend. // Changes made to users are handled separately. const submitHandler = () => { @@ -180,6 +189,11 @@ const AdminPanel: React.FC = () => { /> } /> } /> + } + /> } />
diff --git a/src/pages/academy/adminPanel/subcomponents/AddStoriesUserPanel.tsx b/src/pages/academy/adminPanel/subcomponents/AddStoriesUserPanel.tsx new file mode 100644 index 0000000000..569e132a2b --- /dev/null +++ b/src/pages/academy/adminPanel/subcomponents/AddStoriesUserPanel.tsx @@ -0,0 +1,276 @@ +import { + Button, + Callout, + FileInput, + FormGroup, + H2, + H4, + HTMLSelect, + Icon, + Intent, + Position +} from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import { Popover2 } from '@blueprintjs/popover2'; +import { GridApi, GridReadyEvent } from 'ag-grid-community'; +import { AgGridReact } from 'ag-grid-react'; +import { uniqBy } from 'lodash'; +import React from 'react'; +import { useCSVReader } from 'react-papaparse'; +import { StoriesRole } from 'src/commons/application/ApplicationTypes'; + +import Constants from '../../../../commons/utils/Constants'; + +type Props = { + handleAddNewUsersToCourse: (users: NameUsernameRole[], provider: string) => void; +}; + +export type NameUsernameRole = { + name: string; + username: string; + role: StoriesRole; +}; + +const AddStoriesUserPanel: React.FC = props => { + const [users, setUsers] = React.useState([]); + const [invalidCsvMsg, setInvalidCsvMsg] = React.useState(''); + const gridApi = React.useRef(); + const { CSVReader } = useCSVReader(); + + const columnDefs = [ + { + headerName: 'Name', + field: 'name' + }, + { + headerName: 'Username', + field: 'username' + }, + { + headerName: 'Role', + field: 'role' + } + ]; + + const defaultColumnDefs = { + filter: true, + resizable: true, + sortable: true + }; + + const onGridReady = (params: GridReadyEvent) => { + gridApi.current = params.api; + }; + + const grid = ( +
+ gridApi.current?.sizeColumnsToFit()} + rowData={users} + rowHeight={36} + suppressCellSelection={true} + suppressMovableColumns={true} + pagination + /> +
+ ); + + const htmlSelectOptions = [...Constants.authProviders.entries()].map(([id, _]) => id); + const [provider, setProvider] = React.useState(htmlSelectOptions[0]); + + const validateCsvInput = (results: any) => { + const { data, errors }: { data: string[][]; errors: any[] } = results; + + // react-papaparse upload errors + if (!!errors.length) { + setInvalidCsvMsg( + 'Error detected while uploading the CSV file! Please recheck the file and try again.' + ); + return; + } + + /** + * Begin CSV validation. + * + * Terminate early if validation errors are encountered, and do not add to existing + * valid uploaded entries in the table + */ + const processed: NameUsernameRole[] = [...users]; + + if (data.length + users.length > 1000) { + setInvalidCsvMsg('Please limit each upload to 1000 entries!'); + return; + } + + for (let i = 0; i < data.length; i++) { + // Incorrect number of columns + if (data[i].length !== 3) { + setInvalidCsvMsg( + <> +
+ Invalid format (line {i})! Please ensure that the name, username and role is specified + for each row entry! +
+
+
+ Format: name,username,role +
+
+
(please hover over the question mark above for more details)
+ + ); + return; + } + // Invalid role specified + if (!Object.values(StoriesRole).includes(data[i][2] as StoriesRole)) { + setInvalidCsvMsg( + `Invalid role (line ${i})! Please ensure that the third column of each entry contains one of the following: 'member, moderator, admin'` + ); + return; + } + } + + data.forEach(e => { + processed.push({ + name: e[0], + username: e[1], + role: e[2] as StoriesRole + }); + }); + + // Check for duplicate usernames in data + if (uniqBy(processed, val => val.username).length !== processed.length) { + setInvalidCsvMsg('There are duplicate usernames in the uploaded CSV(s)!'); + return; + } + + // No validation errors + setUsers(processed); + setInvalidCsvMsg(''); + }; + + const submitHandler = () => { + props.handleAddNewUsersToCourse(users, provider); + setUsers([]); + setProvider(htmlSelectOptions[0]); + }; + + return ( +
+

Add Stories Users

+ {grid} +

Upload a CSV file to mass insert or update users in your course.

+
+
+
+
+ validateCsvInput(results)} + config={{ + delimiter: ',', + skipEmptyLines: true + }} + > + {({ getRootProps, acceptedFile, ProgressBar, getRemoveFileProps }: any) => ( + <> + + +

+ CSV Format:   + + name,username,role + +

+

+ + name + + : the name of the user +

+

+ + username + + : username of the user in the corresponding authentication +

+

+ + role + + : the role of the user (member | moderator | admin) +

+ +

 

+

+ Examples: +

+

+ (Luminus):   Wei Kai,e1234567,member +

+

+ (Google):   Timothy,learner@gmail.com,moderator +

+

+ (GitHub):   Mingkai,ghusername,admin +

+
+ } + interactionKind="hover" + position={Position.TOP} + popoverClassName="file-input-popover" + > + + + + )} + +
+ + +
Authentication Provider
+ + + +
+ } + inline + > + setProvider(e.target.value)} + /> + +
+ {invalidCsvMsg && ( + + {invalidCsvMsg} + + )} +
+
+
+ ); +}; + +export default AddStoriesUserPanel; diff --git a/src/pages/academy/adminPanel/subcomponents/AddUserPanel.tsx b/src/pages/academy/adminPanel/subcomponents/AddUserPanel.tsx index e6c43f86ad..80ed57f3c5 100644 --- a/src/pages/academy/adminPanel/subcomponents/AddUserPanel.tsx +++ b/src/pages/academy/adminPanel/subcomponents/AddUserPanel.tsx @@ -21,9 +21,7 @@ import { Role } from 'src/commons/application/ApplicationTypes'; import Constants from '../../../../commons/utils/Constants'; -export type AddUserPanelProps = OwnProps; - -type OwnProps = { +type Props = { handleAddNewUsersToCourse: (users: UsernameRoleGroup[], provider: string) => void; }; @@ -33,7 +31,7 @@ export type UsernameRoleGroup = { group?: string; }; -const AddUserPanel: React.FC = props => { +const AddUserPanel: React.FC = props => { const [users, setUsers] = React.useState([]); const [invalidCsvMsg, setInvalidCsvMsg] = React.useState(''); const gridApi = React.useRef(); diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 5b68481297..f36943d545 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -254,6 +254,7 @@ const Playground: React.FC = props => { sideContentHeight, sharedbConnected, usingSubst, + usingEnv, isFolderModeEnabled, activeEditorTabIndex, context: { chapter: playgroundSourceChapter, variant: playgroundSourceVariant } @@ -646,16 +647,21 @@ const Playground: React.FC = props => { () => ( dispatch(changeStepLimit(limit, workspaceLocation))} + stepSize={usingSubst ? 2 : 1} + handleChangeStepLimit={limit => { + dispatch(changeStepLimit(limit, workspaceLocation)); + usingEnv && dispatch(toggleUpdateEnv(true, workspaceLocation)); + }} handleOnBlurAutoScale={limit => { - limit % 2 === 0 + limit % 2 === 0 || !usingSubst ? dispatch(changeStepLimit(limit, workspaceLocation)) : dispatch(changeStepLimit(limit + 1, workspaceLocation)); + usingEnv && dispatch(toggleUpdateEnv(true, workspaceLocation)); }} key="step_limit" /> ), - [dispatch, stepLimit, workspaceLocation] + [dispatch, stepLimit, usingSubst, usingEnv, workspaceLocation] ); const getEditorValue = useCallback( @@ -976,7 +982,7 @@ const Playground: React.FC = props => { githubButtons, usingRemoteExecution || !isSourceLanguage(languageConfig.chapter) ? null - : usingSubst + : usingSubst || usingEnv ? stepperStepLimit : executionTime ] diff --git a/src/pages/stories/Stories.tsx b/src/pages/stories/Stories.tsx index 2f459bbd50..51aa1b9328 100644 --- a/src/pages/stories/Stories.tsx +++ b/src/pages/stories/Stories.tsx @@ -2,28 +2,16 @@ import '@tremor/react/dist/esm/tremor.css'; import { Button as BpButton, Icon as BpIcon } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import { - Card, - Flex, - Icon, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - Text, - TextInput, - Title -} from '@tremor/react'; +import { Card, Flex, TextInput, Title } from '@tremor/react'; import React, { useCallback, useState } from 'react'; import { useDispatch } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import ContentDisplay from 'src/commons/ContentDisplay'; import { showSimpleConfirmDialog } from 'src/commons/utils/DialogHelper'; import { useTypedSelector } from 'src/commons/utils/Hooks'; -import { deleteStory, getStoriesList } from 'src/features/stories/StoriesActions'; +import { deleteStory, getStoriesList, saveStory } from 'src/features/stories/StoriesActions'; +import StoriesTable from './StoriesTable'; import StoryActions from './StoryActions'; const columns = [ @@ -33,8 +21,6 @@ const columns = [ { id: 'actions', header: 'Actions' } ]; -const MAX_EXCERPT_LENGTH = 35; - const Stories: React.FC = () => { const [query, setQuery] = useState(''); const navigate = useNavigate(); @@ -58,6 +44,62 @@ const Stories: React.FC = () => { const storyList = useTypedSelector(state => state.stories.storyList); + const handleTogglePinStory = useCallback( + (id: number) => { + // Safe to use ! as the story ID comes a story in storyList + const story = storyList.find(story => story.id === id)!; + const pinnedLength = storyList.filter(story => story.isPinned).length; + const newStory = { + ...story, + isPinned: !story.isPinned, + // Pinning a story appends to the end of the pinned list + pinOrder: story.isPinned ? null : pinnedLength + }; + dispatch(saveStory(newStory, id)); + }, + [dispatch, storyList] + ); + + const handleMovePinUp = useCallback( + (id: number) => { + // Safe to use ! as the story ID comes a story in storyList + const oldIndex = storyList.findIndex(story => story.id === id)!; + if (oldIndex === 0) { + return; + } + + const toMoveUp = storyList[oldIndex]; + const toMoveDown = storyList[oldIndex - 1]; + + const storiesToUpdate = [ + { ...toMoveUp, pinOrder: oldIndex - 1 }, + { ...toMoveDown, pinOrder: oldIndex } + ]; + storiesToUpdate.forEach(story => dispatch(saveStory(story, story.id))); + }, + [dispatch, storyList] + ); + + const handleMovePinDown = useCallback( + (id: number) => { + // Safe to use ! as the story ID comes a story in storyList + const oldIndex = storyList.findIndex(story => story.id === id)!; + const pinnedLength = storyList.filter(story => story.isPinned).length; + if (oldIndex === pinnedLength - 1) { + return; + } + const toMoveDown = storyList[oldIndex]; + const toMoveUp = storyList[oldIndex + 1]; + + const storiesToUpdate = [ + { ...toMoveDown, pinOrder: oldIndex + 1 }, + { ...toMoveUp, pinOrder: oldIndex } + ]; + storiesToUpdate.forEach(story => dispatch(saveStory(story, story.id))); + }, + [dispatch, storyList] + ); + return ( dispatch(getStoriesList())} @@ -78,54 +120,28 @@ const Stories: React.FC = () => { /> - - - - {columns.map(({ id, header }) => ( - {header} - ))} - - - - {storyList - .filter( - story => - // Always show pinned stories - story.isPinned || story.authorName.toLowerCase().includes(query.toLowerCase()) - ) - .map(({ id, authorName, isPinned, title, content }) => ( - - {authorName} - - - {isPinned && } />} - {title} - - - - - {content.replaceAll(/\s+/g, ' ').length <= MAX_EXCERPT_LENGTH - ? content.replaceAll(/\s+/g, ' ') - : content.split(/\s+/).reduce((acc, cur) => { - return acc.length + cur.length <= MAX_EXCERPT_LENGTH - ? acc + ' ' + cur - : acc; - }, '') + '…'} - - - - - - - ))} - -
+ + // Always show pinned stories + story.isPinned || story.authorName.toLowerCase().includes(query.toLowerCase()) + )} + storyActions={story => ( + + )} + /> } /> diff --git a/src/pages/stories/StoriesTable.tsx b/src/pages/stories/StoriesTable.tsx new file mode 100644 index 0000000000..f0f88391eb --- /dev/null +++ b/src/pages/stories/StoriesTable.tsx @@ -0,0 +1,67 @@ +import { Icon as BpIcon } from '@blueprintjs/core/lib/esm/components/icon/icon'; +import { IconNames } from '@blueprintjs/icons'; +import { + Flex, + Icon, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, + Text +} from '@tremor/react'; +import React from 'react'; +import { StoryListView } from 'src/features/stories/StoriesTypes'; + +type Props = { + headers: Array<{ id: string; header: string }>; + stories: StoryListView[]; + storyActions: (stor: StoryListView) => React.ReactNode; +}; + +const MAX_EXCERPT_LENGTH = 35; + +const StoriesTable: React.FC = ({ headers, stories, storyActions }) => { + return ( + + + + {headers.map(({ id, header }) => ( + {header} + ))} + + + + {stories.map(story => { + const { id, authorName, isPinned, title, content } = story; + return ( + + {authorName} + + + {isPinned && } />} + {title} + + + + + {content.replaceAll(/\s+/g, ' ').length <= MAX_EXCERPT_LENGTH + ? content.replaceAll(/\s+/g, ' ') + : content.split(/\s+/).reduce((acc, cur) => { + return acc.length + cur.length <= MAX_EXCERPT_LENGTH + ? acc + ' ' + cur + : acc; + }, '') + '…'} + + + {storyActions(story)} + + ); + })} + +
+ ); +}; + +export default StoriesTable; diff --git a/src/pages/stories/Story.tsx b/src/pages/stories/Story.tsx index 03d02bb074..6a9432fe42 100644 --- a/src/pages/stories/Story.tsx +++ b/src/pages/stories/Story.tsx @@ -10,13 +10,13 @@ import { useParams } from 'react-router'; import ControlBar, { ControlBarProps } from 'src/commons/controlBar/ControlBar'; import { ControlButtonSaveButton } from 'src/commons/controlBar/ControlBarSaveButton'; import { useTypedSelector } from 'src/commons/utils/Hooks'; -import { - showSuccessMessage, - showWarningMessage -} from 'src/commons/utils/notifications/NotificationsHelper'; import { scrollSync } from 'src/commons/utils/StoriesHelper'; -import { setCurrentStory, setCurrentStoryId } from 'src/features/stories/StoriesActions'; -import { updateStory } from 'src/features/stories/storiesComponents/BackendAccess'; +import { + createStory, + saveStory, + setCurrentStory, + setCurrentStoryId +} from 'src/features/stories/StoriesActions'; import UserBlogContent from '../../features/stories/storiesComponents/UserBlogContent'; @@ -28,17 +28,7 @@ const Story: React.FC = ({ isViewOnly = false }) => { const dispatch = useDispatch(); const [isDirty, setIsDirty] = useState(false); - const onScroll = (e: IEditorProps) => { - const userblogContainer = document.getElementById('userblogContainer'); - if (userblogContainer) { - scrollSync(e, userblogContainer); - } - }; - const { currentStory: story, currentStoryId: storyId } = useTypedSelector(store => store.stories); - const storyTitle = story?.title ?? ''; - const content = story?.content ?? ''; - const { id: idToSet } = useParams<{ id: string }>(); useEffect(() => { // Clear screen on first load @@ -53,20 +43,29 @@ const Story: React.FC = ({ isViewOnly = false }) => { return <>; } + const onEditorScroll = (e: IEditorProps) => { + const userblogContainer = document.getElementById('userblogContainer'); + if (userblogContainer) { + scrollSync(e, userblogContainer); + } + }; + const onEditorValueChange = (val: string) => { setIsDirty(true); dispatch(setCurrentStory({ ...story, content: val })); }; + const { title, content } = story; + const controlBarProps: ControlBarProps = { editorButtons: [ isViewOnly ? ( - <>{storyTitle} + <>{title} ) : ( { const newTitle = e.target.value; dispatch(setCurrentStory({ ...story, title: newTitle })); @@ -78,18 +77,14 @@ const Story: React.FC = ({ isViewOnly = false }) => { { - if (!storyId) { - // TODO: Create story - return; + if (storyId) { + // Update story + dispatch(saveStory(story, storyId)); + } else { + // Create story + dispatch(createStory(story)); } - updateStory(storyId, storyTitle, content) - .then(() => { - showSuccessMessage('Story saved'); - setIsDirty(false); - }) - .catch(() => { - showWarningMessage('Failed to save story'); - }); + // TODO: Set isDirty to false }} hasUnsavedChanges={isDirty} /> @@ -109,7 +104,7 @@ const Story: React.FC = ({ isViewOnly = false }) => { theme="source" value={content} onChange={onEditorValueChange} - onScroll={onScroll} + onScroll={onEditorScroll} fontSize={17} highlightActiveLine={false} showPrintMargin={false} diff --git a/src/pages/stories/StoryActions.tsx b/src/pages/stories/StoryActions.tsx index 418ed57595..2b32c50c26 100644 --- a/src/pages/stories/StoryActions.tsx +++ b/src/pages/stories/StoryActions.tsx @@ -10,6 +10,11 @@ type Props = { canView?: boolean; canEdit?: boolean; canDelete?: boolean; + canPin?: boolean; + isPinned?: boolean; + handleTogglePin?: (id: number) => void; + handleMovePinUp?: (id: number) => void; + handleMovePinDown?: (id: number) => void; }; const StoryActions: React.FC = ({ @@ -17,7 +22,12 @@ const StoryActions: React.FC = ({ handleDeleteStory, canView = false, canEdit = false, - canDelete = false + canDelete = false, + canPin = false, + isPinned = false, + handleTogglePin = () => {}, + handleMovePinUp = () => {}, + handleMovePinDown = () => {} }) => { return ( @@ -33,9 +43,44 @@ const StoryActions: React.FC = ({ )} {canEdit && ( - } variant="light" /> + } + variant="light" + color="sky" + /> )} + {canPin && isPinned && ( + <> + + + + )} + {canPin && ( + + )} {canDelete && (