From 635cde869e50d9bb583baab1b2f603218a9ceed7 Mon Sep 17 00:00:00 2001 From: Rachel Date: Thu, 28 Mar 2024 12:56:59 +0800 Subject: [PATCH 01/18] connect shared links creation with backend database --- package.json | 1 + .../controlBar/ControlBarShareButton.tsx | 95 ++++++-- src/features/playground/PlaygroundActions.ts | 3 + src/pages/playground/Decoder.tsx | 131 ++++++++++ src/pages/playground/Encoder.tsx | 65 +++++ src/pages/playground/Playground.tsx | 227 ++++++++++-------- tsconfig.json | 1 + yarn.lock | 7 +- 8 files changed, 415 insertions(+), 115 deletions(-) create mode 100644 src/pages/playground/Decoder.tsx create mode 100644 src/pages/playground/Encoder.tsx diff --git a/package.json b/package.json index d09d618e9d..68b28d09fe 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "react-draggable": "^4.4.5", "react-dropzone": "^14.2.3", "react-hotkeys": "^2.0.0", + "react-hotkeys-hook": "^4.4.4", "react-konva": "^18.2.10", "react-latex-next": "^2.1.0", "react-mde": "^11.5.0", diff --git a/src/commons/controlBar/ControlBarShareButton.tsx b/src/commons/controlBar/ControlBarShareButton.tsx index d5ddbab6bd..e1ddf255ff 100644 --- a/src/commons/controlBar/ControlBarShareButton.tsx +++ b/src/commons/controlBar/ControlBarShareButton.tsx @@ -6,6 +6,7 @@ import * as CopyToClipboard from 'react-copy-to-clipboard'; import ControlButton from '../ControlButton'; import Constants from '../utils/Constants'; +import { shortenURLRequest } from '../sagas/PlaygroundSaga'; type ControlBarShareButtonProps = DispatchProps & StateProps; @@ -20,11 +21,13 @@ type StateProps = { shortURL?: string; key: string; isSicp?: boolean; + programConfig: object }; type State = { keyword: string; isLoading: boolean; + isSuccess: boolean; }; export class ControlBarShareButton extends React.PureComponent { @@ -36,7 +39,28 @@ export class ControlBarShareButton extends React.PureComponent { + if (event.key === 'Enter' && event.ctrlKey) { + // console.log('Ctrl+Enter pressed!'); + this.setState({ keyword: "Test" }) + this.props.handleShortenURL(this.state.keyword); + this.setState({ isLoading: true }); + if (this.props.shortURL || this.props.isSicp) { + this.selectShareInputText(); + console.log("link created.") + } + } } public render() { @@ -57,8 +81,12 @@ export class ControlBarShareButton extends React.PureComponent ) : ( <> - {!this.props.shortURL || this.props.shortURL === 'ERROR' ? ( + {/* check this.props.postSuccess */} + {/* {!this.props.shortURL || this.props.shortURL === 'ERROR' ? ( + !this.state.isLoading || this.props.shortURL === 'ERROR' ? ( */} + {!this.state.isSuccess || this.props.shortURL === 'ERROR' ? ( !this.state.isLoading || this.props.shortURL === 'ERROR' ? ( +
{Constants.urlShortenerBase}  + <>{console.log(this.props.programConfig)} { - this.props.handleShortenURL(this.state.keyword); - this.setState({ isLoading: true }); + // post request to backend, set keyword as return uuid + const requestBody = { + shared_program: { + data: this.props.programConfig + } + }; + const fetchOpts: RequestInit = { + method: 'POST', + body: JSON.stringify(requestBody), + headers: { + 'Content-Type': 'application/json' + } + }; + fetch("http://localhost:4000/api/shared_programs", fetchOpts) + .then(res => { + return res.json() + }) + .then(resp => { + this.setState({ keyword: resp.uuid }) + console.log(resp) + }) + .catch(err => console.log("Error: ", err)); + + // this.props.handleShortenURL(this.state.keyword); + // console.log("base", this.props.shortURL) + this.setState({ isLoading: true, isSuccess: true }); }} />
@@ -84,10 +137,13 @@ export class ControlBarShareButton extends React.PureComponent ) ) : ( -
- + //
+
+ {/* */} + - + {/* */} + @@ -97,15 +153,15 @@ export class ControlBarShareButton extends React.PureComponent - - this.toggleButton()} /> - - + + + this.toggleButton()} /> + + ); } @@ -121,8 +177,8 @@ export class ControlBarShareButton extends React.PureComponent) { @@ -135,4 +191,7 @@ export class ControlBarShareButton extends React.PureComponent action(GENERATE_LZ_STRING); @@ -20,6 +21,8 @@ export const updateShortURL = (shortURL: string) => action(UPDATE_SHORT_URL, sho export const changeQueryString = (queryString: string) => action(CHANGE_QUERY_STRING, queryString); +// export const changeProgramConfig = (config: programConfig) => action(CHANGE_PROGRAM_CONFIG, config); + export const playgroundUpdatePersistenceFile = (file?: PersistenceFile) => action(PLAYGROUND_UPDATE_PERSISTENCE_FILE, file); diff --git a/src/pages/playground/Decoder.tsx b/src/pages/playground/Decoder.tsx new file mode 100644 index 0000000000..1352121a7e --- /dev/null +++ b/src/pages/playground/Decoder.tsx @@ -0,0 +1,131 @@ +import { FSModule } from 'browserfs/dist/node/core/FS'; +import { Dispatch } from 'react'; +import { AnyAction } from 'redux'; +import { WorkspaceLocation } from 'src/commons/workspace/WorkspaceTypes'; +import { IParsedQuery, parseQuery } from '../../commons/utils/QueryHelper'; +import { convertParamToBoolean, convertParamToInt } from '../../commons/utils/ParamParseHelper'; +import { + showFullJSWarningOnUrlLoad, + showFulTSWarningOnUrlLoad, + showHTMLDisclaimer + } from 'src/commons/utils/WarningDialogHelper'; +import { decompressFromEncodedURIComponent } from 'lz-string'; +import { getDefaultFilePath, getLanguageConfig } from 'src/commons/application/ApplicationTypes'; +import { overwriteFilesInWorkspace } from 'src/commons/fileSystem/utils'; +import { setFolderMode, removeEditorTabsForDirectory, addEditorTab, updateActiveEditorTabIndex } from 'src/commons/workspace/WorkspaceActions'; +import { playgroundConfigLanguage } from 'src/features/playground/PlaygroundActions'; +import { WORKSPACE_BASE_PATHS } from '../fileSystem/createInBrowserFileSystem'; +import { Chapter, Variant } from 'js-slang/dist/types'; + + +export type programConfig = { + isFolder: string | undefined, + tabs: string | undefined, + tabIdx: string | undefined, + chap: string | undefined, + variant: string | undefined, + ext: string | undefined, + exec: string | undefined, + files: string | undefined, + prgrm: string | undefined +} +/** + * #chap=4 + * exec=1000 + * ext=NONE + * files=KQJgYgDgNghgngcwE4HsCuA7AJqSrkwC2AdAFYDOAvEA + * isFolder=false + * tabIdx=0 + * tabs=PQBwNghgng5gTgewK4DsAmpHwgWwHQBWAzkA + * variant=default + */ +export var Decoder = { + decodeString: function (inputString: string) { + const qs: Partial = parseQuery(inputString); + return { + chap: qs.chap, + exec: qs.exec, + files: qs.files, + isFolder: qs.isFolder, + tabIdx: qs.tabIdx, + tabs: qs.tabs, + variant: qs.variant, + prgrm: qs.prgrm, + ext: qs.ext + }; + }, + + decodeJSON: function (inputJSON: string) { + const jsonObject = JSON.parse(inputJSON); + return jsonObject.data; + } +}; + +export async function resetConfig( + configObj: programConfig, + handlers: { + handleChapterSelect: (chapter: Chapter, variant: Variant) => void; + handleChangeExecTime: (execTime: number) => void; + }, + workspaceLocation: WorkspaceLocation, + dispatch: Dispatch, + fileSystem: FSModule | null +) { + const chapter = convertParamToInt(configObj.chap?.toString()) ?? undefined; + if (chapter === Chapter.FULL_JS) { + showFullJSWarningOnUrlLoad(); + } else if (chapter === Chapter.FULL_TS) { + showFulTSWarningOnUrlLoad(); + } else { + if (chapter === Chapter.HTML) { + const continueToHtml = await showHTMLDisclaimer(); + if (!continueToHtml) { + return; + } + } + + // For backward compatibility with old share links - 'prgrm' is no longer used. + const program = configObj.prgrm === undefined ? '' : decompressFromEncodedURIComponent(configObj.prgrm); + + // By default, create just the default file. + const defaultFilePath = getDefaultFilePath(workspaceLocation); + const files: Record = + configObj.files === undefined + ? { + [defaultFilePath]: program + } + : parseQuery(decompressFromEncodedURIComponent(configObj.files)); + if (fileSystem !== null) { + await overwriteFilesInWorkspace(workspaceLocation, fileSystem, files); + } + + dispatch(setFolderMode(workspaceLocation, false)); + const isFolderModeEnabled = convertParamToBoolean(configObj.isFolder?.toString()) ?? false; + dispatch(setFolderMode(workspaceLocation, isFolderModeEnabled)); + + const editorTabFilePaths = configObj.tabs?.split(',').map(decompressFromEncodedURIComponent) ?? [ + defaultFilePath + ]; + + dispatch( + removeEditorTabsForDirectory(workspaceLocation, WORKSPACE_BASE_PATHS[workspaceLocation]) + ); + + editorTabFilePaths.forEach(filePath => + dispatch(addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) + ); + + const activeEditorTabIndex = convertParamToInt(configObj.tabIdx?.toString()) ?? 0; + dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)); + if (chapter) { + const languageConfig = getLanguageConfig(chapter, configObj.variant as Variant); + handlers.handleChapterSelect(chapter, languageConfig.variant); + dispatch(playgroundConfigLanguage(languageConfig)); + } + + const execTime = Math.max(convertParamToInt(configObj.exec?.toString() || '1000') || 1000, 1000); + if (execTime) { + handlers.handleChangeExecTime(execTime); + } + } +} \ No newline at end of file diff --git a/src/pages/playground/Encoder.tsx b/src/pages/playground/Encoder.tsx new file mode 100644 index 0000000000..604d9b48c5 --- /dev/null +++ b/src/pages/playground/Encoder.tsx @@ -0,0 +1,65 @@ +import { OverallState } from 'src/commons/application/ApplicationTypes'; +import { useSelector } from 'react-redux'; +import { Chapter, Variant } from 'js-slang/dist/types'; +import { retrieveFilesInWorkspaceAsRecord } from 'src/commons/fileSystem/utils'; +import { FSModule } from 'browserfs/dist/node/core/FS'; +import { EditorTabState } from 'src/commons/workspace/WorkspaceTypes'; +import * as qs from 'query-string'; +import { compressToEncodedURIComponent } from 'lz-string'; +import { useState } from 'react'; + +export const EncodeURL = () => { + const isFolderModeEnabled: boolean = useSelector( + (state: OverallState) => state.workspaces.playground.isFolderModeEnabled + ); + + const editorTabs: EditorTabState[] = useSelector( + (state: OverallState) => state.workspaces.playground.editorTabs + ); + const editorTabFilePaths = editorTabs + .map((editorTab: EditorTabState) => editorTab.filePath) + .filter((filePath): filePath is string => filePath !== undefined); + const activeEditorTabIndex: number | null = useSelector( + (state: OverallState) => state.workspaces.playground.activeEditorTabIndex + ); + const chapter: Chapter = useSelector( + (state: OverallState) => state.workspaces.playground.context.chapter + ); + const variant: Variant = useSelector( + (state: OverallState) => state.workspaces.playground.context.variant + ); + const execTime: number = useSelector( + (state: OverallState) => state.workspaces.playground.execTime + ); + const fileSystem: FSModule | null = GetFileSystem(); + + const result: object = { + isFolder: isFolderModeEnabled, + files: fileSystem == null ? null : GetFile(fileSystem), + tabs: editorTabFilePaths.map(compressToEncodedURIComponent)[0], + tabIdx: activeEditorTabIndex, + chap: chapter, + variant, + ext: "NONE", + exec: execTime + }; + + return result; + } + + const GetFileSystem = () => { + const fileSystem: FSModule | null = useSelector( + (state: OverallState) => state.fileSystem.inBrowserFileSystem + ); + return fileSystem; + } + + const GetFile = (fileSystem: FSModule) => { + const [files, setFiles] = useState>(); + retrieveFilesInWorkspaceAsRecord('playground', fileSystem) + .then((result: Record) => { + setFiles(result) + }) + return files == null ? null : compressToEncodedURIComponent(qs.stringify(files)) + // compressToEncodedURIComponent(qs.stringify(retrieveFilesInWorkspaceAsRecord('playground', fileSystem))) + } \ No newline at end of file diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index fb84485c19..6ea7bca6bf 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -4,12 +4,12 @@ import { Ace, Range } from 'ace-builds'; import { FSModule } from 'browserfs/dist/node/core/FS'; import classNames from 'classnames'; import { Chapter, Variant } from 'js-slang/dist/types'; -import { isEqual } from 'lodash'; +import { isEqual, set } from 'lodash'; import { decompressFromEncodedURIComponent } from 'lz-string'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; import { useDispatch, useStore } from 'react-redux'; -import { useLocation, useNavigate } from 'react-router'; +import { Location, useLocation, useNavigate } from 'react-router'; import { AnyAction, Dispatch } from 'redux'; import { beginDebuggerPause, @@ -99,7 +99,7 @@ import { ControlBarEvalButton } from '../../commons/controlBar/ControlBarEvalBut import { ControlBarExecutionTime } from '../../commons/controlBar/ControlBarExecutionTime'; import { ControlBarGoogleDriveButtons } from '../../commons/controlBar/ControlBarGoogleDriveButtons'; import { ControlBarSessionButtons } from '../../commons/controlBar/ControlBarSessionButton'; -import { ControlBarShareButton } from '../../commons/controlBar/ControlBarShareButton'; +import { ControlBarShareButton} from '../../commons/controlBar/ControlBarShareButton'; import { ControlBarStepLimit } from '../../commons/controlBar/ControlBarStepLimit'; import { ControlBarToggleFolderModeButton } from '../../commons/controlBar/ControlBarToggleFolderModeButton'; import { ControlBarGitHubButtons } from '../../commons/controlBar/github/ControlBarGitHubButtons'; @@ -137,6 +137,8 @@ import { makeSubstVisualizerTabFrom, mobileOnlyTabIds } from './PlaygroundTabs'; +import { Decoder, programConfig, resetConfig } from './Decoder'; +import { EncodeURL } from './Encoder'; export type PlaygroundProps = { isSicpEditor?: boolean; @@ -147,90 +149,90 @@ export type PlaygroundProps = { const keyMap = { goGreen: 'h u l k' }; -export async function handleHash( - hash: string, - handlers: { - handleChapterSelect: (chapter: Chapter, variant: Variant) => void; - handleChangeExecTime: (execTime: number) => void; - }, - workspaceLocation: WorkspaceLocation, - dispatch: Dispatch, - fileSystem: FSModule | null -) { - // Make the parsed query string object a Partial because we might access keys which are not set. - const qs: Partial = parseQuery(hash); - - const chapter = convertParamToInt(qs.chap) ?? undefined; - if (chapter === Chapter.FULL_JS) { - showFullJSWarningOnUrlLoad(); - } else if (chapter === Chapter.FULL_TS) { - showFulTSWarningOnUrlLoad(); - } else { - if (chapter === Chapter.HTML) { - const continueToHtml = await showHTMLDisclaimer(); - if (!continueToHtml) { - return; - } - } - - // For backward compatibility with old share links - 'prgrm' is no longer used. - const program = qs.prgrm === undefined ? '' : decompressFromEncodedURIComponent(qs.prgrm); - - // By default, create just the default file. - const defaultFilePath = getDefaultFilePath(workspaceLocation); - const files: Record = - qs.files === undefined - ? { - [defaultFilePath]: program - } - : parseQuery(decompressFromEncodedURIComponent(qs.files)); - if (fileSystem !== null) { - await overwriteFilesInWorkspace(workspaceLocation, fileSystem, files); - } - - // BrowserFS does not provide a way of listening to changes in the file system, which makes - // updating the file system view troublesome. To force the file system view to re-render - // (and thus display the updated file system), we first disable Folder mode. - dispatch(setFolderMode(workspaceLocation, false)); - const isFolderModeEnabled = convertParamToBoolean(qs.isFolder) ?? false; - // If Folder mode should be enabled, enabling it after disabling it earlier will cause the - // newly-added files to be shown. Note that this has to take place after the files are - // already added to the file system. - dispatch(setFolderMode(workspaceLocation, isFolderModeEnabled)); - - // By default, open a single editor tab containing the default playground file. - const editorTabFilePaths = qs.tabs?.split(',').map(decompressFromEncodedURIComponent) ?? [ - defaultFilePath - ]; - // Remove all editor tabs before populating with the ones from the query string. - dispatch( - removeEditorTabsForDirectory(workspaceLocation, WORKSPACE_BASE_PATHS[workspaceLocation]) - ); - // Add editor tabs from the query string. - editorTabFilePaths.forEach(filePath => - // Fall back on the empty string if the file contents do not exist. - dispatch(addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) - ); - - // By default, use the first editor tab. - const activeEditorTabIndex = convertParamToInt(qs.tabIdx) ?? 0; - dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)); - if (chapter) { - // TODO: To migrate the state logic away from playgroundSourceChapter - // and playgroundSourceVariant into the language config instead - const languageConfig = getLanguageConfig(chapter, qs.variant as Variant); - handlers.handleChapterSelect(chapter, languageConfig.variant); - // Hardcoded for Playground only for now, while we await workspace refactoring - // to decouple the SicpWorkspace from the Playground. - dispatch(playgroundConfigLanguage(languageConfig)); - } - - const execTime = Math.max(convertParamToInt(qs.exec || '1000') || 1000, 1000); - if (execTime) { - handlers.handleChangeExecTime(execTime); - } - } -} +// export async function handleHash( +// hash: string, +// handlers: { +// handleChapterSelect: (chapter: Chapter, variant: Variant) => void; +// handleChangeExecTime: (execTime: number) => void; +// }, +// workspaceLocation: WorkspaceLocation, +// dispatch: Dispatch, +// fileSystem: FSModule | null +// ) { +// // Make the parsed query string object a Partial because we might access keys which are not set. +// const qs: Partial = parseQuery(hash); + +// const chapter = convertParamToInt(qs.chap) ?? undefined; +// if (chapter === Chapter.FULL_JS) { +// showFullJSWarningOnUrlLoad(); +// } else if (chapter === Chapter.FULL_TS) { +// showFulTSWarningOnUrlLoad(); +// } else { +// if (chapter === Chapter.HTML) { +// const continueToHtml = await showHTMLDisclaimer(); +// if (!continueToHtml) { +// return; +// } +// } + +// // For backward compatibility with old share links - 'prgrm' is no longer used. +// const program = qs.prgrm === undefined ? '' : decompressFromEncodedURIComponent(qs.prgrm); + +// // By default, create just the default file. +// const defaultFilePath = getDefaultFilePath(workspaceLocation); +// const files: Record = +// qs.files === undefined +// ? { +// [defaultFilePath]: program +// } +// : parseQuery(decompressFromEncodedURIComponent(qs.files)); +// if (fileSystem !== null) { +// await overwriteFilesInWorkspace(workspaceLocation, fileSystem, files); +// } + +// // BrowserFS does not provide a way of listening to changes in the file system, which makes +// // updating the file system view troublesome. To force the file system view to re-render +// // (and thus display the updated file system), we first disable Folder mode. +// dispatch(setFolderMode(workspaceLocation, false)); +// const isFolderModeEnabled = convertParamToBoolean(qs.isFolder) ?? false; +// // If Folder mode should be enabled, enabling it after disabling it earlier will cause the +// // newly-added files to be shown. Note that this has to take place after the files are +// // already added to the file system. +// dispatch(setFolderMode(workspaceLocation, isFolderModeEnabled)); + +// // By default, open a single editor tab containing the default playground file. +// const editorTabFilePaths = qs.tabs?.split(',').map(decompressFromEncodedURIComponent) ?? [ +// defaultFilePath +// ]; +// // Remove all editor tabs before populating with the ones from the query string. +// dispatch( +// removeEditorTabsForDirectory(workspaceLocation, WORKSPACE_BASE_PATHS[workspaceLocation]) +// ); +// // Add editor tabs from the query string. +// editorTabFilePaths.forEach(filePath => +// // Fall back on the empty string if the file contents do not exist. +// dispatch(addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) +// ); + +// // By default, use the first editor tab. +// const activeEditorTabIndex = convertParamToInt(qs.tabIdx) ?? 0; +// dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)); +// if (chapter) { +// // TODO: To migrate the state logic away from playgroundSourceChapter +// // and playgroundSourceVariant into the language config instead +// const languageConfig = getLanguageConfig(chapter, qs.variant as Variant); +// handlers.handleChapterSelect(chapter, languageConfig.variant); +// // Hardcoded for Playground only for now, while we await workspace refactoring +// // to decouple the SicpWorkspace from the Playground. +// dispatch(playgroundConfigLanguage(languageConfig)); +// } + +// const execTime = Math.max(convertParamToInt(qs.exec || '1000') || 1000, 1000); +// if (execTime) { +// handlers.handleChangeExecTime(execTime); +// } +// } +// } const Playground: React.FC = props => { const { isSicpEditor } = props; @@ -238,9 +240,10 @@ const Playground: React.FC = props => { const { isMobileBreakpoint } = useResponsive(); const [deviceSecret, setDeviceSecret] = useState(); - const location = useLocation(); + // const location = useLocation(); const navigate = useNavigate(); const store = useStore(); + const location = useLocation() const searchParams = new URLSearchParams(location.search); const shouldAddDevice = searchParams.get('add_device'); @@ -336,6 +339,34 @@ const Playground: React.FC = props => { state => state.workspaces.playground.externalLibrary ); + const handleHash = (hash:string) => { + if (hash?.includes('uuid')) { + const curr_uuid = parseQuery(location.hash).uuid; + fetch(`http://localhost:4000/api/shared_programs/${curr_uuid}`) + .then(response => response.json()) + .then(resp => { + // console.log("resp", resp) + const res: programConfig = Decoder.decodeJSON(resp) + console.log("decode", res) + resetConfig( + res, + { handleChangeExecTime, handleChapterSelect }, + workspaceLocation, + dispatch, + fileSystem + ) + }); + } else { + const config = Decoder.decodeString(location.hash); + resetConfig( + config, + { handleChangeExecTime, handleChapterSelect }, + workspaceLocation, + dispatch, + fileSystem) + } + } + useEffect(() => { // When the editor session Id changes, then treat it as a new session. setSessionId( @@ -367,13 +398,14 @@ const Playground: React.FC = props => { } return; } - handleHash( - hash, - { handleChangeExecTime, handleChapterSelect }, - workspaceLocation, - dispatch, - fileSystem - ); + // handleHash( + // hash, + // { handleChangeExecTime, handleChapterSelect }, + // workspaceLocation, + // dispatch, + // fileSystem + // ); + handleHash(hash); }, [ dispatch, fileSystem, @@ -695,6 +727,8 @@ const Playground: React.FC = props => { workspaceLocation ] ); + + const config = EncodeURL(); const shareButton = useMemo(() => { const qs = isSicpEditor ? Links.playground + '#' + props.initialEditorValueHash : queryString; @@ -704,6 +738,7 @@ const Playground: React.FC = props => { handleShortenURL={s => dispatch(shortenURL(s))} handleUpdateShortURL={s => dispatch(updateShortURL(s))} queryString={qs} + programConfig={config} shortURL={shortURL} isSicp={isSicpEditor} key="share" @@ -1077,4 +1112,4 @@ const Playground: React.FC = props => { export const Component = Playground; Component.displayName = 'Playground'; -export default Playground; +export default Playground; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index cc12a427ca..8a255afbca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "strictNullChecks": true, "skipLibCheck": true, "suppressImplicitAnyIndexErrors": true, + "ignoreDeprecations": "5.0", "noUnusedLocals": true, "esModuleInterop": false, "allowSyntheticDefaultImports": true, diff --git a/yarn.lock b/yarn.lock index cb877267dc..c4f8b2cb99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10865,9 +10865,14 @@ react-fast-compare@^3.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== +react-hotkeys-hook@^4.4.4: + version "4.4.4" + resolved "https://registry.npmmirror.com/react-hotkeys-hook/-/react-hotkeys-hook-4.4.4.tgz#5f055f39113218fe5e23f8723db68ccf99d155ab" + integrity sha512-wzZmqb/Obr0ds9Myc1sIFPJ52GA/Eeg/vXBWV0HA1LvHlVAW5Va3KB0q6EZNlNSHQWscWZ2K8+6w0GYSie2o7A== + react-hotkeys@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-2.0.0.tgz#a7719c7340cbba888b0e9184f806a9ec0ac2c53f" + resolved "https://registry.npmmirror.com/react-hotkeys/-/react-hotkeys-2.0.0.tgz#a7719c7340cbba888b0e9184f806a9ec0ac2c53f" integrity sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q== dependencies: prop-types "^15.6.1" From 5b655e02bb7cabfed5d0c64e0ba39a70a9ef7db7 Mon Sep 17 00:00:00 2001 From: Rachel Date: Sat, 30 Mar 2024 12:18:18 +0800 Subject: [PATCH 02/18] shared links url change --- package.json | 2 +- .../controlBar/ControlBarShareButton.tsx | 1 - src/features/playground/PlaygroundActions.ts | 38 ++++++------------- src/pages/playground/Encoder.tsx | 11 +++--- src/pages/playground/Playground.tsx | 12 +++--- src/routes/routerConfig.tsx | 4 ++ yarn.lock | 38 +++++++++++++++++-- 7 files changed, 64 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index bf4c20a5f8..44c2aac156 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prepare": "husky" }, "dependencies": { - "@blueprintjs/core": "^5.7.0", + "@blueprintjs/core": "^5.10.1", "@blueprintjs/datetime2": "^2.2.7", "@blueprintjs/icons": "^5.5.0", "@blueprintjs/popover2": "^2.0.0", diff --git a/src/commons/controlBar/ControlBarShareButton.tsx b/src/commons/controlBar/ControlBarShareButton.tsx index d8ff42d0ec..79a3de0b29 100644 --- a/src/commons/controlBar/ControlBarShareButton.tsx +++ b/src/commons/controlBar/ControlBarShareButton.tsx @@ -6,7 +6,6 @@ import * as CopyToClipboard from 'react-copy-to-clipboard'; import ControlButton from '../ControlButton'; import Constants from '../utils/Constants'; -import { shortenURLRequest } from '../sagas/PlaygroundSaga'; type ControlBarShareButtonProps = DispatchProps & StateProps; diff --git a/src/features/playground/PlaygroundActions.ts b/src/features/playground/PlaygroundActions.ts index cb5c6e6000..1d9813afcb 100644 --- a/src/features/playground/PlaygroundActions.ts +++ b/src/features/playground/PlaygroundActions.ts @@ -1,5 +1,5 @@ -import { createAction } from '@reduxjs/toolkit'; import { SALanguage } from 'src/commons/application/ApplicationTypes'; +import { action } from 'typesafe-actions'; import { PersistenceFile } from '../persistence/PersistenceTypes'; import { @@ -13,38 +13,24 @@ import { } from './PlaygroundTypes'; import { programConfig } from 'src/pages/playground/Decoder'; -export const generateLzString = createAction(GENERATE_LZ_STRING, () => ({ payload: {} })); +export const generateLzString = () => action(GENERATE_LZ_STRING); -export const shortenURL = createAction(SHORTEN_URL, (keyword: string) => ({ payload: keyword })); +export const shortenURL = (keyword: string) => action(SHORTEN_URL, keyword); -export const updateShortURL = createAction(UPDATE_SHORT_URL, (shortURL: string) => ({ - payload: shortURL -})); +export const updateShortURL = (shortURL: string) => action(UPDATE_SHORT_URL, shortURL); -export const changeQueryString = createAction(CHANGE_QUERY_STRING, (queryString: string) => ({ - payload: queryString -})); +export const changeQueryString = (queryString: string) => action(CHANGE_QUERY_STRING, queryString); -<<<<<<< HEAD // export const changeProgramConfig = (config: programConfig) => action(CHANGE_PROGRAM_CONFIG, config); export const playgroundUpdatePersistenceFile = (file?: PersistenceFile) => action(PLAYGROUND_UPDATE_PERSISTENCE_FILE, file); -======= -export const playgroundUpdatePersistenceFile = createAction( - PLAYGROUND_UPDATE_PERSISTENCE_FILE, - (file?: PersistenceFile) => ({ payload: file }) -); ->>>>>>> 489b7e41f15c9e512b8974dea861f4c54406de37 -export const playgroundUpdateGitHubSaveInfo = createAction( - PLAYGROUND_UPDATE_GITHUB_SAVE_INFO, - (repoName: string, filePath: string, lastSaved: Date) => ({ - payload: { repoName, filePath, lastSaved } - }) -); +export const playgroundUpdateGitHubSaveInfo = ( + repoName: string, + filePath: string, + lastSaved: Date +) => action(PLAYGROUND_UPDATE_GITHUB_SAVE_INFO, { repoName, filePath, lastSaved }); -export const playgroundConfigLanguage = createAction( - PLAYGROUND_UPDATE_LANGUAGE_CONFIG, - (languageConfig: SALanguage) => ({ payload: languageConfig }) -); +export const playgroundConfigLanguage = (languageConfig: SALanguage) => + action(PLAYGROUND_UPDATE_LANGUAGE_CONFIG, languageConfig); diff --git a/src/pages/playground/Encoder.tsx b/src/pages/playground/Encoder.tsx index 604d9b48c5..0b55ec6c16 100644 --- a/src/pages/playground/Encoder.tsx +++ b/src/pages/playground/Encoder.tsx @@ -4,7 +4,7 @@ import { Chapter, Variant } from 'js-slang/dist/types'; import { retrieveFilesInWorkspaceAsRecord } from 'src/commons/fileSystem/utils'; import { FSModule } from 'browserfs/dist/node/core/FS'; import { EditorTabState } from 'src/commons/workspace/WorkspaceTypes'; -import * as qs from 'query-string'; +import qs from 'query-string' import { compressToEncodedURIComponent } from 'lz-string'; import { useState } from 'react'; @@ -31,11 +31,11 @@ export const EncodeURL = () => { const execTime: number = useSelector( (state: OverallState) => state.workspaces.playground.execTime ); - const fileSystem: FSModule | null = GetFileSystem(); + const fileSystem: FSModule = GetFileSystem(); const result: object = { isFolder: isFolderModeEnabled, - files: fileSystem == null ? null : GetFile(fileSystem), + files: GetFile(fileSystem), tabs: editorTabFilePaths.map(compressToEncodedURIComponent)[0], tabIdx: activeEditorTabIndex, chap: chapter, @@ -51,7 +51,7 @@ export const EncodeURL = () => { const fileSystem: FSModule | null = useSelector( (state: OverallState) => state.fileSystem.inBrowserFileSystem ); - return fileSystem; + return fileSystem as FSModule; } const GetFile = (fileSystem: FSModule) => { @@ -60,6 +60,5 @@ export const EncodeURL = () => { .then((result: Record) => { setFiles(result) }) - return files == null ? null : compressToEncodedURIComponent(qs.stringify(files)) - // compressToEncodedURIComponent(qs.stringify(retrieveFilesInWorkspaceAsRecord('playground', fileSystem))) + return compressToEncodedURIComponent(qs.stringify(files as Record)) } \ No newline at end of file diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 03681679f7..dd3ed465b8 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -137,6 +137,7 @@ import { } from './PlaygroundTabs'; import { Decoder, programConfig, resetConfig } from './Decoder'; import { EncodeURL } from './Encoder'; +import { useParams } from 'react-router'; export type PlaygroundProps = { isSicpEditor?: boolean; @@ -336,10 +337,10 @@ const Playground: React.FC = props => { state => state.workspaces.playground.externalLibrary ); - const handleHash = (hash:string) => { - if (hash?.includes('uuid')) { - const curr_uuid = parseQuery(location.hash).uuid; - fetch(`http://localhost:4000/api/shared_programs/${curr_uuid}`) + const handleURL = () => { + const { uuid } = useParams(); + if (uuid) { + fetch(`http://localhost:4000/api/shared_programs/${uuid}`) .then(response => response.json()) .then(resp => { // console.log("resp", resp) @@ -402,7 +403,7 @@ const Playground: React.FC = props => { // dispatch, // fileSystem // ); - handleHash(hash); + handleURL(); }, [ dispatch, fileSystem, @@ -720,6 +721,7 @@ const Playground: React.FC = props => { ); const config = EncodeURL(); + // const config = {}; const shareButton = useMemo(() => { const qs = isSicpEditor ? Links.playground + '#' + props.initialEditorValueHash : queryString; diff --git a/src/routes/routerConfig.tsx b/src/routes/routerConfig.tsx index eee0624946..14a8a6c0fc 100644 --- a/src/routes/routerConfig.tsx +++ b/src/routes/routerConfig.tsx @@ -68,6 +68,10 @@ export const playgroundOnlyRouterConfig: RouteObject[] = [ path: 'playground', lazy: Playground }, + { + path: 'playground/share/:uuid?', + lazy: Playground + }, ...commonChildrenRoutes, { path: '*', diff --git a/yarn.lock b/yarn.lock index 709ebde4f6..4e5d2be7d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1216,7 +1216,30 @@ dependencies: tslib "~2.6.2" -"@blueprintjs/core@^5.7.0", "@blueprintjs/core@^5.9.1": +"@blueprintjs/colors@^5.1.1": + version "5.1.1" + resolved "https://registry.npmmirror.com/@blueprintjs/colors/-/colors-5.1.1.tgz#b227d1ce8b95d9266d976441f34cd71d436735db" + integrity sha512-Mni/GgCYLaMf5U5zsCN42skOG49w3U0QmUFuJgFZ/1pv+3cHF/9xR4L4dXcj5DtgJoHkmUbK36PR5mdFB65WEA== + dependencies: + tslib "~2.6.2" + +"@blueprintjs/core@^5.10.1": + version "5.10.1" + resolved "https://registry.npmmirror.com/@blueprintjs/core/-/core-5.10.1.tgz#d3df1743258a10841d2885dd2ba712d116a96689" + integrity sha512-yE10tC/tA/NWo8zsged3n0PUcjlQzh9GqNKOqMAoSSnsAKGI+RJiHtbS2RERyKU+NailIsuFx1k282ohkJvkOQ== + dependencies: + "@blueprintjs/colors" "^5.1.1" + "@blueprintjs/icons" "^5.9.0" + "@popperjs/core" "^2.11.8" + classnames "^2.3.1" + normalize.css "^8.0.1" + react-popper "^2.3.0" + react-transition-group "^4.4.5" + react-uid "^2.3.3" + tslib "~2.6.2" + use-sync-external-store "^1.2.0" + +"@blueprintjs/core@^5.9.1": version "5.9.1" resolved "https://registry.yarnpkg.com/@blueprintjs/core/-/core-5.9.1.tgz#79e474aa063468aec4c2e82cd9b6e12d0b98b1bc" integrity sha512-/Cq6CAV/GyMhTOGrzgIBDsneduQCFWxzDwlX2f8MMAFZFO7FaeCONPvzbcxpTRAXR+RFkb4HpPdIsAslyZUMzA== @@ -1270,6 +1293,15 @@ classnames "^2.3.1" tslib "~2.6.2" +"@blueprintjs/icons@^5.9.0": + version "5.9.0" + resolved "https://registry.npmmirror.com/@blueprintjs/icons/-/icons-5.9.0.tgz#f1d6ebffa3033bbdd8ea05dbfa6fb8e5e88cf119" + integrity sha512-9W9DYFqSORBr1QPSEwpbjnftbU+hfWB/gFtpa4LdzR7VhtQaCdcrinyXFmNUFacQ9czYrmr72bP567Rv6pRUDg== + dependencies: + change-case "^4.1.2" + classnames "^2.3.1" + tslib "~2.6.2" + "@blueprintjs/popover2@^2.0.0": version "2.1.1" resolved "https://registry.yarnpkg.com/@blueprintjs/popover2/-/popover2-2.1.1.tgz#b1e2dee9fd4679f70c7337ab03327d863eec4e42" @@ -11072,7 +11104,7 @@ react-dev-utils@^12.0.1: react-dom@^18.2.0: version "18.2.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" + resolved "https://registry.npmmirror.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== dependencies: loose-envify "^1.1.0" @@ -11417,7 +11449,7 @@ react-uid@^2.3.3: react@^18.2.0: version "18.2.0" - resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" + resolved "https://registry.npmmirror.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== dependencies: loose-envify "^1.1.0" From 91b84e141bd595e6fd3fa66c5f5bfe23551ad5d2 Mon Sep 17 00:00:00 2001 From: Rachel Date: Sun, 31 Mar 2024 17:15:37 +0800 Subject: [PATCH 03/18] delete unnecessary lines --- package.json | 2 +- public/externalLibs/sound/soundToneMatrix.js | 6 +- .../controlBar/ControlBarShareButton.tsx | 56 ++-- src/features/playground/PlaygroundActions.ts | 1 - src/pages/playground/Decoder.tsx | 286 ++++++++++-------- src/pages/playground/Encoder.tsx | 129 ++++---- src/pages/playground/Playground.tsx | 147 ++------- yarn.lock | 2 +- 8 files changed, 269 insertions(+), 360 deletions(-) diff --git a/package.json b/package.json index 44c2aac156..bf4c20a5f8 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prepare": "husky" }, "dependencies": { - "@blueprintjs/core": "^5.10.1", + "@blueprintjs/core": "^5.7.0", "@blueprintjs/datetime2": "^2.2.7", "@blueprintjs/icons": "^5.5.0", "@blueprintjs/popover2": "^2.0.0", diff --git a/public/externalLibs/sound/soundToneMatrix.js b/public/externalLibs/sound/soundToneMatrix.js index 8638a90378..d0246757bf 100644 --- a/public/externalLibs/sound/soundToneMatrix.js +++ b/public/externalLibs/sound/soundToneMatrix.js @@ -36,7 +36,7 @@ var timeout_matrix; // for coloring the matrix accordingly while it's being played var timeout_color; -var timeout_objects = new Array(); +var timeout_objects = []; // vector_to_list returns a list that contains the elements of the argument vector // in the given order. @@ -54,7 +54,7 @@ function vector_to_list(vector) { function x_y_to_row_column(x, y) { var row = Math.floor((y - margin_length) / (square_side_length + distance_between_squares)); var column = Math.floor((x - margin_length) / (square_side_length + distance_between_squares)); - return Array(row, column); + return [row, column]; } // given the row number of a square, return the leftmost coordinate @@ -365,5 +365,5 @@ function clear_all_timeout() { clearTimeout(timeout_objects[i]); } - timeout_objects = new Array(); + timeout_objects = []; } diff --git a/src/commons/controlBar/ControlBarShareButton.tsx b/src/commons/controlBar/ControlBarShareButton.tsx index 79a3de0b29..30e4454dcb 100644 --- a/src/commons/controlBar/ControlBarShareButton.tsx +++ b/src/commons/controlBar/ControlBarShareButton.tsx @@ -20,7 +20,7 @@ type StateProps = { shortURL?: string; key: string; isSicp?: boolean; - programConfig: object + programConfig: object; }; type State = { @@ -52,15 +52,15 @@ export class ControlBarShareButton extends React.PureComponent { if (event.key === 'Enter' && event.ctrlKey) { // console.log('Ctrl+Enter pressed!'); - this.setState({ keyword: "Test" }) + this.setState({ keyword: 'Test' }); this.props.handleShortenURL(this.state.keyword); this.setState({ isLoading: true }); if (this.props.shortURL || this.props.isSicp) { this.selectShareInputText(); - console.log("link created.") + console.log('link created.'); } } - } + }; public render() { const shareButtonPopoverContent = @@ -80,12 +80,8 @@ export class ControlBarShareButton extends React.PureComponent ) : ( <> - {/* check this.props.postSuccess */} - {/* {!this.props.shortURL || this.props.shortURL === 'ERROR' ? ( - !this.state.isLoading || this.props.shortURL === 'ERROR' ? ( */} - {!this.state.isSuccess || this.props.shortURL === 'ERROR' ? ( + {!this.state.isSuccess || this.props.shortURL === 'ERROR' ? ( !this.state.isLoading || this.props.shortURL === 'ERROR' ? ( -
{Constants.urlShortenerBase}  { - return res.json() - }) - .then(resp => { - this.setState({ keyword: resp.uuid }) - console.log(resp) - }) - .catch(err => console.log("Error: ", err)); - - // this.props.handleShortenURL(this.state.keyword); - // console.log("base", this.props.shortURL) + fetch('http://localhost:4000/api/shared_programs', fetchOpts) + .then(res => { + return res.json(); + }) + .then(resp => { + this.setState({ keyword: resp.uuid }); + console.log(resp); + }) + .catch(err => console.log('Error: ', err)); this.setState({ isLoading: true, isSuccess: true }); }} /> @@ -152,15 +145,15 @@ export class ControlBarShareButton extends React.PureComponent - - this.toggleButton()} /> - - + + + this.toggleButton()} /> + + ); } @@ -190,7 +183,4 @@ export class ControlBarShareButton extends React.PureComponent action(GENERATE_LZ_STRING); diff --git a/src/pages/playground/Decoder.tsx b/src/pages/playground/Decoder.tsx index 1352121a7e..92aeb73783 100644 --- a/src/pages/playground/Decoder.tsx +++ b/src/pages/playground/Decoder.tsx @@ -1,131 +1,155 @@ -import { FSModule } from 'browserfs/dist/node/core/FS'; -import { Dispatch } from 'react'; -import { AnyAction } from 'redux'; -import { WorkspaceLocation } from 'src/commons/workspace/WorkspaceTypes'; -import { IParsedQuery, parseQuery } from '../../commons/utils/QueryHelper'; -import { convertParamToBoolean, convertParamToInt } from '../../commons/utils/ParamParseHelper'; -import { - showFullJSWarningOnUrlLoad, - showFulTSWarningOnUrlLoad, - showHTMLDisclaimer - } from 'src/commons/utils/WarningDialogHelper'; -import { decompressFromEncodedURIComponent } from 'lz-string'; -import { getDefaultFilePath, getLanguageConfig } from 'src/commons/application/ApplicationTypes'; -import { overwriteFilesInWorkspace } from 'src/commons/fileSystem/utils'; -import { setFolderMode, removeEditorTabsForDirectory, addEditorTab, updateActiveEditorTabIndex } from 'src/commons/workspace/WorkspaceActions'; -import { playgroundConfigLanguage } from 'src/features/playground/PlaygroundActions'; -import { WORKSPACE_BASE_PATHS } from '../fileSystem/createInBrowserFileSystem'; -import { Chapter, Variant } from 'js-slang/dist/types'; - - -export type programConfig = { - isFolder: string | undefined, - tabs: string | undefined, - tabIdx: string | undefined, - chap: string | undefined, - variant: string | undefined, - ext: string | undefined, - exec: string | undefined, - files: string | undefined, - prgrm: string | undefined -} -/** - * #chap=4 - * exec=1000 - * ext=NONE - * files=KQJgYgDgNghgngcwE4HsCuA7AJqSrkwC2AdAFYDOAvEA - * isFolder=false - * tabIdx=0 - * tabs=PQBwNghgng5gTgewK4DsAmpHwgWwHQBWAzkA - * variant=default - */ -export var Decoder = { - decodeString: function (inputString: string) { - const qs: Partial = parseQuery(inputString); - return { - chap: qs.chap, - exec: qs.exec, - files: qs.files, - isFolder: qs.isFolder, - tabIdx: qs.tabIdx, - tabs: qs.tabs, - variant: qs.variant, - prgrm: qs.prgrm, - ext: qs.ext - }; - }, - - decodeJSON: function (inputJSON: string) { - const jsonObject = JSON.parse(inputJSON); - return jsonObject.data; - } -}; - -export async function resetConfig( - configObj: programConfig, - handlers: { - handleChapterSelect: (chapter: Chapter, variant: Variant) => void; - handleChangeExecTime: (execTime: number) => void; - }, - workspaceLocation: WorkspaceLocation, - dispatch: Dispatch, - fileSystem: FSModule | null -) { - const chapter = convertParamToInt(configObj.chap?.toString()) ?? undefined; - if (chapter === Chapter.FULL_JS) { - showFullJSWarningOnUrlLoad(); - } else if (chapter === Chapter.FULL_TS) { - showFulTSWarningOnUrlLoad(); - } else { - if (chapter === Chapter.HTML) { - const continueToHtml = await showHTMLDisclaimer(); - if (!continueToHtml) { - return; - } - } - - // For backward compatibility with old share links - 'prgrm' is no longer used. - const program = configObj.prgrm === undefined ? '' : decompressFromEncodedURIComponent(configObj.prgrm); - - // By default, create just the default file. - const defaultFilePath = getDefaultFilePath(workspaceLocation); - const files: Record = - configObj.files === undefined - ? { - [defaultFilePath]: program - } - : parseQuery(decompressFromEncodedURIComponent(configObj.files)); - if (fileSystem !== null) { - await overwriteFilesInWorkspace(workspaceLocation, fileSystem, files); - } - - dispatch(setFolderMode(workspaceLocation, false)); - const isFolderModeEnabled = convertParamToBoolean(configObj.isFolder?.toString()) ?? false; - dispatch(setFolderMode(workspaceLocation, isFolderModeEnabled)); - - const editorTabFilePaths = configObj.tabs?.split(',').map(decompressFromEncodedURIComponent) ?? [ - defaultFilePath - ]; - - dispatch( - removeEditorTabsForDirectory(workspaceLocation, WORKSPACE_BASE_PATHS[workspaceLocation]) - ); - - editorTabFilePaths.forEach(filePath => - dispatch(addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) - ); - - const activeEditorTabIndex = convertParamToInt(configObj.tabIdx?.toString()) ?? 0; - dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)); - if (chapter) { - const languageConfig = getLanguageConfig(chapter, configObj.variant as Variant); - handlers.handleChapterSelect(chapter, languageConfig.variant); - dispatch(playgroundConfigLanguage(languageConfig)); - } - - const execTime = Math.max(convertParamToInt(configObj.exec?.toString() || '1000') || 1000, 1000); - if (execTime) { - handlers.handleChangeExecTime(execTime); - } - } -} \ No newline at end of file +import { FSModule } from 'browserfs/dist/node/core/FS'; +import { Chapter, Variant } from 'js-slang/dist/types'; +import { decompressFromEncodedURIComponent } from 'lz-string'; +import { Dispatch } from 'react'; +import { AnyAction } from 'redux'; +import { getDefaultFilePath, getLanguageConfig } from 'src/commons/application/ApplicationTypes'; +import { overwriteFilesInWorkspace } from 'src/commons/fileSystem/utils'; +import { + showFullJSWarningOnUrlLoad, + showFulTSWarningOnUrlLoad, + showHTMLDisclaimer +} from 'src/commons/utils/WarningDialogHelper'; +import { + addEditorTab, + removeEditorTabsForDirectory, + setFolderMode, + updateActiveEditorTabIndex +} from 'src/commons/workspace/WorkspaceActions'; +import { WorkspaceLocation } from 'src/commons/workspace/WorkspaceTypes'; +import { playgroundConfigLanguage } from 'src/features/playground/PlaygroundActions'; + +import { convertParamToBoolean, convertParamToInt } from '../../commons/utils/ParamParseHelper'; +import { IParsedQuery, parseQuery } from '../../commons/utils/QueryHelper'; +import { WORKSPACE_BASE_PATHS } from '../fileSystem/createInBrowserFileSystem'; + +export type programConfig = { + isFolder: string | undefined; + tabs: string | undefined; + tabIdx: string | undefined; + chap: string | undefined; + variant: string | undefined; + ext: string | undefined; + exec: string | undefined; + files: string | undefined; + prgrm: string | undefined; +}; +/** + * #chap=4 + * exec=1000 + * ext=NONE + * files=KQJgYgDgNghgngcwE4HsCuA7AJqSrkwC2AdAFYDOAvEA + * isFolder=false + * tabIdx=0 + * tabs=PQBwNghgng5gTgewK4DsAmpHwgWwHQBWAzkA + * variant=default + */ +export const Decoder = { + decodeString: function (inputString: string) { + const qs: Partial = parseQuery(inputString); + return { + chap: qs.chap, + exec: qs.exec, + files: qs.files, + isFolder: qs.isFolder, + tabIdx: qs.tabIdx, + tabs: qs.tabs, + variant: qs.variant, + prgrm: qs.prgrm, + ext: qs.ext + }; + }, + + decodeJSON: function (inputJSON: string) { + const jsonObject = JSON.parse(inputJSON); + return jsonObject.data; + } +}; + +export async function resetConfig( + configObj: programConfig, + handlers: { + handleChapterSelect: (chapter: Chapter, variant: Variant) => void; + handleChangeExecTime: (execTime: number) => void; + }, + workspaceLocation: WorkspaceLocation, + dispatch: Dispatch, + fileSystem: FSModule | null +) { + const chapter = convertParamToInt(configObj.chap?.toString()) ?? undefined; + if (chapter === Chapter.FULL_JS) { + showFullJSWarningOnUrlLoad(); + } else if (chapter === Chapter.FULL_TS) { + showFulTSWarningOnUrlLoad(); + } else { + if (chapter === Chapter.HTML) { + const continueToHtml = await showHTMLDisclaimer(); + if (!continueToHtml) { + return; + } + } + + // For backward compatibility with old share links - 'prgrm' is no longer used. + const program = + configObj.prgrm === undefined ? '' : decompressFromEncodedURIComponent(configObj.prgrm); + + // By default, create just the default file. + const defaultFilePath = getDefaultFilePath(workspaceLocation); + const files: Record = + configObj.files === undefined + ? { + [defaultFilePath]: program + } + : parseQuery(decompressFromEncodedURIComponent(configObj.files)); + if (fileSystem !== null) { + await overwriteFilesInWorkspace(workspaceLocation, fileSystem, files); + } + + // BrowserFS does not provide a way of listening to changes in the file system, which makes + // updating the file system view troublesome. To force the file system view to re-render + // (and thus display the updated file system), we first disable Folder mode. + dispatch(setFolderMode(workspaceLocation, false)); + const isFolderModeEnabled = convertParamToBoolean(configObj.isFolder?.toString()) ?? false; + + // If Folder mode should be enabled, enabling it after disabling it earlier will cause the + // newly-added files to be shown. Note that this has to take place after the files are + // already added to the file system. + dispatch(setFolderMode(workspaceLocation, isFolderModeEnabled)); + + // By default, open a single editor tab containing the default playground file. + const editorTabFilePaths = configObj.tabs + ?.split(',') + .map(decompressFromEncodedURIComponent) ?? [defaultFilePath]; + + // Remove all editor tabs before populating with the ones from the query string. + dispatch( + removeEditorTabsForDirectory(workspaceLocation, WORKSPACE_BASE_PATHS[workspaceLocation]) + ); + // Add editor tabs from the query string. + editorTabFilePaths.forEach(filePath => + // Fall back on the empty string if the file contents do not exist. + dispatch(addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) + ); + + // By default, use the first editor tab. + const activeEditorTabIndex = convertParamToInt(configObj.tabIdx?.toString()) ?? 0; + dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)); + if (chapter) { + // TODO: To migrate the state logic away from playgroundSourceChapter + // and playgroundSourceVariant into the language config instead + const languageConfig = getLanguageConfig(chapter, configObj.variant as Variant); + handlers.handleChapterSelect(chapter, languageConfig.variant); + // Hardcoded for Playground only for now, while we await workspace refactoring + // to decouple the SicpWorkspace from the Playground. + dispatch(playgroundConfigLanguage(languageConfig)); + } + + const execTime = Math.max( + convertParamToInt(configObj.exec?.toString() || '1000') || 1000, + 1000 + ); + if (execTime) { + handlers.handleChangeExecTime(execTime); + } + } +} diff --git a/src/pages/playground/Encoder.tsx b/src/pages/playground/Encoder.tsx index 0b55ec6c16..6eb1c17e41 100644 --- a/src/pages/playground/Encoder.tsx +++ b/src/pages/playground/Encoder.tsx @@ -1,64 +1,65 @@ -import { OverallState } from 'src/commons/application/ApplicationTypes'; -import { useSelector } from 'react-redux'; -import { Chapter, Variant } from 'js-slang/dist/types'; -import { retrieveFilesInWorkspaceAsRecord } from 'src/commons/fileSystem/utils'; -import { FSModule } from 'browserfs/dist/node/core/FS'; -import { EditorTabState } from 'src/commons/workspace/WorkspaceTypes'; -import qs from 'query-string' -import { compressToEncodedURIComponent } from 'lz-string'; -import { useState } from 'react'; - -export const EncodeURL = () => { - const isFolderModeEnabled: boolean = useSelector( - (state: OverallState) => state.workspaces.playground.isFolderModeEnabled - ); - - const editorTabs: EditorTabState[] = useSelector( - (state: OverallState) => state.workspaces.playground.editorTabs - ); - const editorTabFilePaths = editorTabs - .map((editorTab: EditorTabState) => editorTab.filePath) - .filter((filePath): filePath is string => filePath !== undefined); - const activeEditorTabIndex: number | null = useSelector( - (state: OverallState) => state.workspaces.playground.activeEditorTabIndex - ); - const chapter: Chapter = useSelector( - (state: OverallState) => state.workspaces.playground.context.chapter - ); - const variant: Variant = useSelector( - (state: OverallState) => state.workspaces.playground.context.variant - ); - const execTime: number = useSelector( - (state: OverallState) => state.workspaces.playground.execTime - ); - const fileSystem: FSModule = GetFileSystem(); - - const result: object = { - isFolder: isFolderModeEnabled, - files: GetFile(fileSystem), - tabs: editorTabFilePaths.map(compressToEncodedURIComponent)[0], - tabIdx: activeEditorTabIndex, - chap: chapter, - variant, - ext: "NONE", - exec: execTime - }; - - return result; - } - - const GetFileSystem = () => { - const fileSystem: FSModule | null = useSelector( - (state: OverallState) => state.fileSystem.inBrowserFileSystem - ); - return fileSystem as FSModule; - } - - const GetFile = (fileSystem: FSModule) => { - const [files, setFiles] = useState>(); - retrieveFilesInWorkspaceAsRecord('playground', fileSystem) - .then((result: Record) => { - setFiles(result) - }) - return compressToEncodedURIComponent(qs.stringify(files as Record)) - } \ No newline at end of file +import { FSModule } from 'browserfs/dist/node/core/FS'; +import { Chapter, Variant } from 'js-slang/dist/types'; +import { compressToEncodedURIComponent } from 'lz-string'; +import qs from 'query-string'; +import { useState } from 'react'; +// import { OverallState } from 'src/commons/application/ApplicationTypes'; +import { retrieveFilesInWorkspaceAsRecord } from 'src/commons/fileSystem/utils'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; +import { EditorTabState } from 'src/commons/workspace/WorkspaceTypes'; + +export const EncodeURL = () => { + const isFolderModeEnabled: boolean = useTypedSelector( + (state) => state.workspaces.playground.isFolderModeEnabled + ); + + const editorTabs: EditorTabState[] = useTypedSelector( + (state) => state.workspaces.playground.editorTabs + ); + const editorTabFilePaths = editorTabs + .map((editorTab: EditorTabState) => editorTab.filePath) + .filter((filePath): filePath is string => filePath !== undefined); + const activeEditorTabIndex: number | null = useTypedSelector( + (state) => state.workspaces.playground.activeEditorTabIndex + ); + const chapter: Chapter = useTypedSelector( + (state) => state.workspaces.playground.context.chapter + ); + const variant: Variant = useTypedSelector( + (state) => state.workspaces.playground.context.variant + ); + const execTime: number = useTypedSelector( + (state) => state.workspaces.playground.execTime + ); + const fileSystem: FSModule = GetFileSystem(); + + const result: object = { + isFolder: isFolderModeEnabled, + files: GetFile(fileSystem), + tabs: editorTabFilePaths.map(compressToEncodedURIComponent)[0], + tabIdx: activeEditorTabIndex, + chap: chapter, + variant, + ext: 'NONE', + exec: execTime + }; + + return result; +}; + +const GetFileSystem = () => { + const fileSystem: FSModule | null = useTypedSelector( + (state) => state.fileSystem.inBrowserFileSystem + ); + return fileSystem as FSModule; +}; + +const GetFile = (fileSystem: FSModule) => { + const [files, setFiles] = useState>(); + retrieveFilesInWorkspaceAsRecord('playground', fileSystem).then( + (result: Record) => { + setFiles(result); + } + ); + return compressToEncodedURIComponent(qs.stringify(files as Record)); +}; diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index dd3ed465b8..cae8b35102 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -1,16 +1,14 @@ import { Classes } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; import { Ace, Range } from 'ace-builds'; -import { FSModule } from 'browserfs/dist/node/core/FS'; import classNames from 'classnames'; import { Chapter, Variant } from 'js-slang/dist/types'; -import { isEqual, set } from 'lodash'; -import { decompressFromEncodedURIComponent } from 'lz-string'; +import { isEqual } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; import { useDispatch, useStore } from 'react-redux'; -import { Location, useLocation, useNavigate } from 'react-router'; -import { AnyAction, Dispatch } from 'redux'; +import { useLocation, useNavigate } from 'react-router'; +import { useParams } from 'react-router'; import { beginDebuggerPause, beginInterruptExecution, @@ -34,12 +32,6 @@ import { changeSideContentHeight } from 'src/commons/sideContent/SideContentActi import { useSideContent } from 'src/commons/sideContent/SideContentHelper'; import { useResponsive, useTypedSelector } from 'src/commons/utils/Hooks'; import { - showFullJSWarningOnUrlLoad, - showFulTSWarningOnUrlLoad, - showHTMLDisclaimer -} from 'src/commons/utils/WarningDialogHelper'; -import { - addEditorTab, addHtmlConsoleError, browseReplHistoryDown, browseReplHistoryUp, @@ -52,7 +44,6 @@ import { navigateToDeclaration, promptAutocomplete, removeEditorTab, - removeEditorTabsForDirectory, sendReplInputToOutput, setEditorBreakpoint, setEditorHighlightedLines, @@ -85,7 +76,6 @@ import { } from 'src/features/playground/PlaygroundActions'; import { - getDefaultFilePath, getLanguageConfig, isSourceLanguage, OverallState, @@ -100,7 +90,7 @@ import { ControlBarEvalButton } from '../../commons/controlBar/ControlBarEvalBut import { ControlBarExecutionTime } from '../../commons/controlBar/ControlBarExecutionTime'; import { ControlBarGoogleDriveButtons } from '../../commons/controlBar/ControlBarGoogleDriveButtons'; import { ControlBarSessionButtons } from '../../commons/controlBar/ControlBarSessionButton'; -import { ControlBarShareButton} from '../../commons/controlBar/ControlBarShareButton'; +import { ControlBarShareButton } from '../../commons/controlBar/ControlBarShareButton'; import { ControlBarStepLimit } from '../../commons/controlBar/ControlBarStepLimit'; import { ControlBarToggleFolderModeButton } from '../../commons/controlBar/ControlBarToggleFolderModeButton'; import { ControlBarGitHubButtons } from '../../commons/controlBar/github/ControlBarGitHubButtons'; @@ -109,7 +99,6 @@ import { NormalEditorContainerProps } from '../../commons/editor/EditorContainer'; import { Position } from '../../commons/editor/EditorTypes'; -import { overwriteFilesInWorkspace } from '../../commons/fileSystem/utils'; import FileSystemView from '../../commons/fileSystemView/FileSystemView'; import MobileWorkspace, { MobileWorkspaceProps @@ -118,8 +107,6 @@ import { SideBarTab } from '../../commons/sideBar/SideBar'; import { SideContentTab, SideContentType } from '../../commons/sideContent/SideContentTypes'; import Constants, { Links } from '../../commons/utils/Constants'; import { generateLanguageIntroduction } from '../../commons/utils/IntroductionHelper'; -import { convertParamToBoolean, convertParamToInt } from '../../commons/utils/ParamParseHelper'; -import { IParsedQuery, parseQuery } from '../../commons/utils/QueryHelper'; import Workspace, { WorkspaceProps } from '../../commons/workspace/Workspace'; import { initSession, log } from '../../features/eventLogging'; import { @@ -128,6 +115,8 @@ import { SelectionRange } from '../../features/sourceRecorder/SourceRecorderTypes'; import { WORKSPACE_BASE_PATHS } from '../fileSystem/createInBrowserFileSystem'; +import { Decoder, programConfig, resetConfig } from './Decoder'; +import { EncodeURL } from './Encoder'; import { desktopOnlyTabIds, makeIntroductionTabFrom, @@ -135,9 +124,6 @@ import { makeSubstVisualizerTabFrom, mobileOnlyTabIds } from './PlaygroundTabs'; -import { Decoder, programConfig, resetConfig } from './Decoder'; -import { EncodeURL } from './Encoder'; -import { useParams } from 'react-router'; export type PlaygroundProps = { isSicpEditor?: boolean; @@ -148,91 +134,6 @@ export type PlaygroundProps = { const keyMap = { goGreen: 'h u l k' }; -// export async function handleHash( -// hash: string, -// handlers: { -// handleChapterSelect: (chapter: Chapter, variant: Variant) => void; -// handleChangeExecTime: (execTime: number) => void; -// }, -// workspaceLocation: WorkspaceLocation, -// dispatch: Dispatch, -// fileSystem: FSModule | null -// ) { -// // Make the parsed query string object a Partial because we might access keys which are not set. -// const qs: Partial = parseQuery(hash); - -// const chapter = convertParamToInt(qs.chap) ?? undefined; -// if (chapter === Chapter.FULL_JS) { -// showFullJSWarningOnUrlLoad(); -// } else if (chapter === Chapter.FULL_TS) { -// showFulTSWarningOnUrlLoad(); -// } else { -// if (chapter === Chapter.HTML) { -// const continueToHtml = await showHTMLDisclaimer(); -// if (!continueToHtml) { -// return; -// } -// } - -// // For backward compatibility with old share links - 'prgrm' is no longer used. -// const program = qs.prgrm === undefined ? '' : decompressFromEncodedURIComponent(qs.prgrm); - -// // By default, create just the default file. -// const defaultFilePath = getDefaultFilePath(workspaceLocation); -// const files: Record = -// qs.files === undefined -// ? { -// [defaultFilePath]: program -// } -// : parseQuery(decompressFromEncodedURIComponent(qs.files)); -// if (fileSystem !== null) { -// await overwriteFilesInWorkspace(workspaceLocation, fileSystem, files); -// } - -// // BrowserFS does not provide a way of listening to changes in the file system, which makes -// // updating the file system view troublesome. To force the file system view to re-render -// // (and thus display the updated file system), we first disable Folder mode. -// dispatch(setFolderMode(workspaceLocation, false)); -// const isFolderModeEnabled = convertParamToBoolean(qs.isFolder) ?? false; -// // If Folder mode should be enabled, enabling it after disabling it earlier will cause the -// // newly-added files to be shown. Note that this has to take place after the files are -// // already added to the file system. -// dispatch(setFolderMode(workspaceLocation, isFolderModeEnabled)); - -// // By default, open a single editor tab containing the default playground file. -// const editorTabFilePaths = qs.tabs?.split(',').map(decompressFromEncodedURIComponent) ?? [ -// defaultFilePath -// ]; -// // Remove all editor tabs before populating with the ones from the query string. -// dispatch( -// removeEditorTabsForDirectory(workspaceLocation, WORKSPACE_BASE_PATHS[workspaceLocation]) -// ); -// // Add editor tabs from the query string. -// editorTabFilePaths.forEach(filePath => -// // Fall back on the empty string if the file contents do not exist. -// dispatch(addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) -// ); - -// // By default, use the first editor tab. -// const activeEditorTabIndex = convertParamToInt(qs.tabIdx) ?? 0; -// dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)); -// if (chapter) { -// // TODO: To migrate the state logic away from playgroundSourceChapter -// // and playgroundSourceVariant into the language config instead -// const languageConfig = getLanguageConfig(chapter, qs.variant as Variant); -// handlers.handleChapterSelect(chapter, languageConfig.variant); -// // Hardcoded for Playground only for now, while we await workspace refactoring -// // to decouple the SicpWorkspace from the Playground. -// dispatch(playgroundConfigLanguage(languageConfig)); -// } - -// const execTime = Math.max(convertParamToInt(qs.exec || '1000') || 1000, 1000); -// if (execTime) { -// handlers.handleChangeExecTime(execTime); -// } -// } -// } - const Playground: React.FC = props => { const { isSicpEditor } = props; const workspaceLocation: WorkspaceLocation = isSicpEditor ? 'sicp' : 'playground'; @@ -242,9 +143,10 @@ const Playground: React.FC = props => { // const location = useLocation(); const navigate = useNavigate(); const store = useStore(); - const location = useLocation() + const location = useLocation(); const searchParams = new URLSearchParams(location.search); const shouldAddDevice = searchParams.get('add_device'); + const { uuid } = useParams(); // Selectors and handlers migrated over from deprecated withRouter implementation const { @@ -338,21 +240,20 @@ const Playground: React.FC = props => { ); const handleURL = () => { - const { uuid } = useParams(); if (uuid) { - fetch(`http://localhost:4000/api/shared_programs/${uuid}`) + fetch(`http://localhost:4000/api/shared_programs/${uuid}`) .then(response => response.json()) .then(resp => { // console.log("resp", resp) - const res: programConfig = Decoder.decodeJSON(resp) - console.log("decode", res) + const res: programConfig = Decoder.decodeJSON(resp); + console.log('decode', res); resetConfig( res, { handleChangeExecTime, handleChapterSelect }, workspaceLocation, dispatch, fileSystem - ) + ); }); } else { const config = Decoder.decodeString(location.hash); @@ -361,9 +262,10 @@ const Playground: React.FC = props => { { handleChangeExecTime, handleChapterSelect }, workspaceLocation, dispatch, - fileSystem) - } - } + fileSystem + ); + } + }; useEffect(() => { // When the editor session Id changes, then treat it as a new session. @@ -395,15 +297,9 @@ const Playground: React.FC = props => { dispatch(setFolderMode(workspaceLocation, false)); } return; + } else { + handleURL(); } - // handleHash( - // hash, - // { handleChangeExecTime, handleChapterSelect }, - // workspaceLocation, - // dispatch, - // fileSystem - // ); - handleURL(); }, [ dispatch, fileSystem, @@ -412,7 +308,7 @@ const Playground: React.FC = props => { courseSourceVariant, workspaceLocation, handleChapterSelect, - handleChangeExecTime + handleChangeExecTime, ]); /** @@ -719,9 +615,8 @@ const Playground: React.FC = props => { workspaceLocation ] ); - + const config = EncodeURL(); - // const config = {}; const shareButton = useMemo(() => { const qs = isSicpEditor ? Links.playground + '#' + props.initialEditorValueHash : queryString; @@ -1103,4 +998,4 @@ const Playground: React.FC = props => { export const Component = Playground; Component.displayName = 'Playground'; -export default Playground; \ No newline at end of file +export default Playground; diff --git a/yarn.lock b/yarn.lock index 4e5d2be7d1..910bc34747 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1223,7 +1223,7 @@ dependencies: tslib "~2.6.2" -"@blueprintjs/core@^5.10.1": +"@blueprintjs/core@^5.7.0": version "5.10.1" resolved "https://registry.npmmirror.com/@blueprintjs/core/-/core-5.10.1.tgz#d3df1743258a10841d2885dd2ba712d116a96689" integrity sha512-yE10tC/tA/NWo8zsged3n0PUcjlQzh9GqNKOqMAoSSnsAKGI+RJiHtbS2RERyKU+NailIsuFx1k282ohkJvkOQ== From b6f036b07906236032227123af2313e7933204bf Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Mon, 1 Apr 2024 17:41:50 +0800 Subject: [PATCH 04/18] Fix format errors --- src/pages/playground/Encoder.tsx | 20 +++++++------------- src/pages/playground/Playground.tsx | 2 +- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/pages/playground/Encoder.tsx b/src/pages/playground/Encoder.tsx index 6eb1c17e41..e9e25876b6 100644 --- a/src/pages/playground/Encoder.tsx +++ b/src/pages/playground/Encoder.tsx @@ -10,27 +10,21 @@ import { EditorTabState } from 'src/commons/workspace/WorkspaceTypes'; export const EncodeURL = () => { const isFolderModeEnabled: boolean = useTypedSelector( - (state) => state.workspaces.playground.isFolderModeEnabled + state => state.workspaces.playground.isFolderModeEnabled ); const editorTabs: EditorTabState[] = useTypedSelector( - (state) => state.workspaces.playground.editorTabs + state => state.workspaces.playground.editorTabs ); const editorTabFilePaths = editorTabs .map((editorTab: EditorTabState) => editorTab.filePath) .filter((filePath): filePath is string => filePath !== undefined); const activeEditorTabIndex: number | null = useTypedSelector( - (state) => state.workspaces.playground.activeEditorTabIndex - ); - const chapter: Chapter = useTypedSelector( - (state) => state.workspaces.playground.context.chapter - ); - const variant: Variant = useTypedSelector( - (state) => state.workspaces.playground.context.variant - ); - const execTime: number = useTypedSelector( - (state) => state.workspaces.playground.execTime + state => state.workspaces.playground.activeEditorTabIndex ); + const chapter: Chapter = useTypedSelector(state => state.workspaces.playground.context.chapter); + const variant: Variant = useTypedSelector(state => state.workspaces.playground.context.variant); + const execTime: number = useTypedSelector(state => state.workspaces.playground.execTime); const fileSystem: FSModule = GetFileSystem(); const result: object = { @@ -49,7 +43,7 @@ export const EncodeURL = () => { const GetFileSystem = () => { const fileSystem: FSModule | null = useTypedSelector( - (state) => state.fileSystem.inBrowserFileSystem + state => state.fileSystem.inBrowserFileSystem ); return fileSystem as FSModule; }; diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index cae8b35102..9c669cb525 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -308,7 +308,7 @@ const Playground: React.FC = props => { courseSourceVariant, workspaceLocation, handleChapterSelect, - handleChangeExecTime, + handleChangeExecTime ]); /** From 2315ede100826d1c951f8a9962b366ff62daded6 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Mon, 1 Apr 2024 17:43:23 +0800 Subject: [PATCH 05/18] Revert lockfile change --- yarn.lock | 34 +--------------------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/yarn.lock b/yarn.lock index 64aff6b618..9cf4dc6aa5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1216,30 +1216,7 @@ dependencies: tslib "~2.6.2" -"@blueprintjs/colors@^5.1.1": - version "5.1.1" - resolved "https://registry.npmmirror.com/@blueprintjs/colors/-/colors-5.1.1.tgz#b227d1ce8b95d9266d976441f34cd71d436735db" - integrity sha512-Mni/GgCYLaMf5U5zsCN42skOG49w3U0QmUFuJgFZ/1pv+3cHF/9xR4L4dXcj5DtgJoHkmUbK36PR5mdFB65WEA== - dependencies: - tslib "~2.6.2" - -"@blueprintjs/core@^5.7.0": - version "5.10.1" - resolved "https://registry.npmmirror.com/@blueprintjs/core/-/core-5.10.1.tgz#d3df1743258a10841d2885dd2ba712d116a96689" - integrity sha512-yE10tC/tA/NWo8zsged3n0PUcjlQzh9GqNKOqMAoSSnsAKGI+RJiHtbS2RERyKU+NailIsuFx1k282ohkJvkOQ== - dependencies: - "@blueprintjs/colors" "^5.1.1" - "@blueprintjs/icons" "^5.9.0" - "@popperjs/core" "^2.11.8" - classnames "^2.3.1" - normalize.css "^8.0.1" - react-popper "^2.3.0" - react-transition-group "^4.4.5" - react-uid "^2.3.3" - tslib "~2.6.2" - use-sync-external-store "^1.2.0" - -"@blueprintjs/core@^5.9.1": +"@blueprintjs/core@^5.7.0", "@blueprintjs/core@^5.9.1": version "5.9.1" resolved "https://registry.yarnpkg.com/@blueprintjs/core/-/core-5.9.1.tgz#79e474aa063468aec4c2e82cd9b6e12d0b98b1bc" integrity sha512-/Cq6CAV/GyMhTOGrzgIBDsneduQCFWxzDwlX2f8MMAFZFO7FaeCONPvzbcxpTRAXR+RFkb4HpPdIsAslyZUMzA== @@ -1293,15 +1270,6 @@ classnames "^2.3.1" tslib "~2.6.2" -"@blueprintjs/icons@^5.9.0": - version "5.9.0" - resolved "https://registry.npmmirror.com/@blueprintjs/icons/-/icons-5.9.0.tgz#f1d6ebffa3033bbdd8ea05dbfa6fb8e5e88cf119" - integrity sha512-9W9DYFqSORBr1QPSEwpbjnftbU+hfWB/gFtpa4LdzR7VhtQaCdcrinyXFmNUFacQ9czYrmr72bP567Rv6pRUDg== - dependencies: - change-case "^4.1.2" - classnames "^2.3.1" - tslib "~2.6.2" - "@blueprintjs/popover2@^2.0.0": version "2.1.1" resolved "https://registry.yarnpkg.com/@blueprintjs/popover2/-/popover2-2.1.1.tgz#b1e2dee9fd4679f70c7337ab03327d863eec4e42" From d133925b9794883a0d03613a661c5a09e935e049 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Mon, 1 Apr 2024 17:44:30 +0800 Subject: [PATCH 06/18] Revert TS config change --- tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 4ab5c2ccad..186f9134ca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,6 @@ "strictNullChecks": true, "skipLibCheck": true, "suppressImplicitAnyIndexErrors": true, - "ignoreDeprecations": "5.0", "noUnusedLocals": true, "esModuleInterop": false, "allowSyntheticDefaultImports": true, From 0f0fb61dea36ddbde064707a1a328efa92ed4df6 Mon Sep 17 00:00:00 2001 From: Rachel Date: Wed, 3 Apr 2024 22:42:15 +0800 Subject: [PATCH 07/18] test check --- .../controlBar/ControlBarShareButton.tsx | 4 +- src/pages/playground/Encoder.tsx | 20 ++-- src/pages/playground/Playground.tsx | 110 +++++++++++++++++- 3 files changed, 114 insertions(+), 20 deletions(-) diff --git a/src/commons/controlBar/ControlBarShareButton.tsx b/src/commons/controlBar/ControlBarShareButton.tsx index 30e4454dcb..8382dd1435 100644 --- a/src/commons/controlBar/ControlBarShareButton.tsx +++ b/src/commons/controlBar/ControlBarShareButton.tsx @@ -112,7 +112,9 @@ export class ControlBarShareButton extends React.PureComponent { - this.setState({ keyword: resp.uuid }); + this.setState({ + keyword: 'http://localhost:8000/playground/share/' + resp.uuid + }); console.log(resp); }) .catch(err => console.log('Error: ', err)); diff --git a/src/pages/playground/Encoder.tsx b/src/pages/playground/Encoder.tsx index 6eb1c17e41..e9e25876b6 100644 --- a/src/pages/playground/Encoder.tsx +++ b/src/pages/playground/Encoder.tsx @@ -10,27 +10,21 @@ import { EditorTabState } from 'src/commons/workspace/WorkspaceTypes'; export const EncodeURL = () => { const isFolderModeEnabled: boolean = useTypedSelector( - (state) => state.workspaces.playground.isFolderModeEnabled + state => state.workspaces.playground.isFolderModeEnabled ); const editorTabs: EditorTabState[] = useTypedSelector( - (state) => state.workspaces.playground.editorTabs + state => state.workspaces.playground.editorTabs ); const editorTabFilePaths = editorTabs .map((editorTab: EditorTabState) => editorTab.filePath) .filter((filePath): filePath is string => filePath !== undefined); const activeEditorTabIndex: number | null = useTypedSelector( - (state) => state.workspaces.playground.activeEditorTabIndex - ); - const chapter: Chapter = useTypedSelector( - (state) => state.workspaces.playground.context.chapter - ); - const variant: Variant = useTypedSelector( - (state) => state.workspaces.playground.context.variant - ); - const execTime: number = useTypedSelector( - (state) => state.workspaces.playground.execTime + state => state.workspaces.playground.activeEditorTabIndex ); + const chapter: Chapter = useTypedSelector(state => state.workspaces.playground.context.chapter); + const variant: Variant = useTypedSelector(state => state.workspaces.playground.context.variant); + const execTime: number = useTypedSelector(state => state.workspaces.playground.execTime); const fileSystem: FSModule = GetFileSystem(); const result: object = { @@ -49,7 +43,7 @@ export const EncodeURL = () => { const GetFileSystem = () => { const fileSystem: FSModule | null = useTypedSelector( - (state) => state.fileSystem.inBrowserFileSystem + state => state.fileSystem.inBrowserFileSystem ); return fileSystem as FSModule; }; diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index cae8b35102..4f6135575a 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -4,7 +4,7 @@ import { Ace, Range } from 'ace-builds'; import classNames from 'classnames'; import { Chapter, Variant } from 'js-slang/dist/types'; import { isEqual } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { Dispatch, useCallback, useEffect, useMemo, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; import { useDispatch, useStore } from 'react-redux'; import { useLocation, useNavigate } from 'react-router'; @@ -32,6 +32,7 @@ import { changeSideContentHeight } from 'src/commons/sideContent/SideContentActi import { useSideContent } from 'src/commons/sideContent/SideContentHelper'; import { useResponsive, useTypedSelector } from 'src/commons/utils/Hooks'; import { + addEditorTab, addHtmlConsoleError, browseReplHistoryDown, browseReplHistoryUp, @@ -44,6 +45,7 @@ import { navigateToDeclaration, promptAutocomplete, removeEditorTab, + removeEditorTabsForDirectory, sendReplInputToOutput, setEditorBreakpoint, setEditorHighlightedLines, @@ -76,6 +78,7 @@ import { } from 'src/features/playground/PlaygroundActions'; import { + getDefaultFilePath, getLanguageConfig, isSourceLanguage, OverallState, @@ -124,6 +127,13 @@ import { makeSubstVisualizerTabFrom, mobileOnlyTabIds } from './PlaygroundTabs'; +import { FSModule } from 'browserfs/dist/node/core/FS'; +import { decompressFromEncodedURIComponent } from 'lz-string'; +import { AnyAction } from 'redux'; +import { overwriteFilesInWorkspace } from 'src/commons/fileSystem/utils'; +import { convertParamToInt, convertParamToBoolean } from 'src/commons/utils/ParamParseHelper'; +import { IParsedQuery, parseQuery } from 'src/commons/utils/QueryHelper'; +import { showFullJSWarningOnUrlLoad, showFulTSWarningOnUrlLoad, showHTMLDisclaimer } from 'src/commons/utils/WarningDialogHelper'; export type PlaygroundProps = { isSicpEditor?: boolean; @@ -134,6 +144,91 @@ export type PlaygroundProps = { const keyMap = { goGreen: 'h u l k' }; +export async function handleHash( + hash: string, + handlers: { + handleChapterSelect: (chapter: Chapter, variant: Variant) => void; + handleChangeExecTime: (execTime: number) => void; + }, + workspaceLocation: WorkspaceLocation, + dispatch: Dispatch, + fileSystem: FSModule | null + ) { + // Make the parsed query string object a Partial because we might access keys which are not set. + const qs: Partial = parseQuery(hash); + + const chapter = convertParamToInt(qs.chap) ?? undefined; + if (chapter === Chapter.FULL_JS) { + showFullJSWarningOnUrlLoad(); + } else if (chapter === Chapter.FULL_TS) { + showFulTSWarningOnUrlLoad(); + } else { + if (chapter === Chapter.HTML) { + const continueToHtml = await showHTMLDisclaimer(); + if (!continueToHtml) { + return; + } + } + + // For backward compatibility with old share links - 'prgrm' is no longer used. + const program = qs.prgrm === undefined ? '' : decompressFromEncodedURIComponent(qs.prgrm); + + // By default, create just the default file. + const defaultFilePath = getDefaultFilePath(workspaceLocation); + const files: Record = + qs.files === undefined + ? { + [defaultFilePath]: program + } + : parseQuery(decompressFromEncodedURIComponent(qs.files)); + if (fileSystem !== null) { + await overwriteFilesInWorkspace(workspaceLocation, fileSystem, files); + } + + // BrowserFS does not provide a way of listening to changes in the file system, which makes + // updating the file system view troublesome. To force the file system view to re-render + // (and thus display the updated file system), we first disable Folder mode. + dispatch(setFolderMode(workspaceLocation, false)); + const isFolderModeEnabled = convertParamToBoolean(qs.isFolder) ?? false; + // If Folder mode should be enabled, enabling it after disabling it earlier will cause the + // newly-added files to be shown. Note that this has to take place after the files are + // already added to the file system. + dispatch(setFolderMode(workspaceLocation, isFolderModeEnabled)); + + // By default, open a single editor tab containing the default playground file. + const editorTabFilePaths = qs.tabs?.split(',').map(decompressFromEncodedURIComponent) ?? [ + defaultFilePath + ]; + // Remove all editor tabs before populating with the ones from the query string. + dispatch( + removeEditorTabsForDirectory(workspaceLocation, WORKSPACE_BASE_PATHS[workspaceLocation]) + ); + // Add editor tabs from the query string. + editorTabFilePaths.forEach(filePath => + // Fall back on the empty string if the file contents do not exist. + dispatch(addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) + ); + + // By default, use the first editor tab. + const activeEditorTabIndex = convertParamToInt(qs.tabIdx) ?? 0; + dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)); + if (chapter) { + // TODO: To migrate the state logic away from playgroundSourceChapter + // and playgroundSourceVariant into the language config instead + const languageConfig = getLanguageConfig(chapter, qs.variant as Variant); + handlers.handleChapterSelect(chapter, languageConfig.variant); + // Hardcoded for Playground only for now, while we await workspace refactoring + // to decouple the SicpWorkspace from the Playground. + dispatch(playgroundConfigLanguage(languageConfig)); + } + + const execTime = Math.max(convertParamToInt(qs.exec || '1000') || 1000, 1000); + if (execTime) { + handlers.handleChangeExecTime(execTime); + } + } + } + const Playground: React.FC = props => { const { isSicpEditor } = props; const workspaceLocation: WorkspaceLocation = isSicpEditor ? 'sicp' : 'playground'; @@ -146,7 +241,6 @@ const Playground: React.FC = props => { const location = useLocation(); const searchParams = new URLSearchParams(location.search); const shouldAddDevice = searchParams.get('add_device'); - const { uuid } = useParams(); // Selectors and handlers migrated over from deprecated withRouter implementation const { @@ -239,6 +333,8 @@ const Playground: React.FC = props => { state => state.workspaces.playground.externalLibrary ); + const { uuid } = useParams(); + const handleURL = () => { if (uuid) { fetch(`http://localhost:4000/api/shared_programs/${uuid}`) @@ -296,10 +392,11 @@ const Playground: React.FC = props => { // This is because Folder mode only works in Source 2+. dispatch(setFolderMode(workspaceLocation, false)); } - return; - } else { - handleURL(); } + handleURL(); + return; + + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ dispatch, fileSystem, @@ -308,7 +405,7 @@ const Playground: React.FC = props => { courseSourceVariant, workspaceLocation, handleChapterSelect, - handleChangeExecTime, + handleChangeExecTime ]); /** @@ -632,6 +729,7 @@ const Playground: React.FC = props => { key="share" /> ); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch, isSicpEditor, props.initialEditorValueHash, queryString, shortURL]); const toggleFolderModeButton = useMemo(() => { From 0a356ca11c7efaef4cd593a681833f6248690ad5 Mon Sep 17 00:00:00 2001 From: Rachel Date: Thu, 4 Apr 2024 14:44:42 +0800 Subject: [PATCH 08/18] format --- CONTRIBUTING.md | 2 +- _config.yml | 2 +- craco.config.js | 40 +++---- public/index.html | 62 +++++----- public/manifest.json | 8 +- src/pages/playground/Playground.tsx | 178 ++++++++++++++-------------- 6 files changed, 151 insertions(+), 141 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c32d7e20c..a251aba277 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ To start contributing, create a fork from our repo and send a PR. Refer to [this The frontend comes with an extensive test suite. To run the tests after you made your modifications, run `yarn test`. Regression tests are run automatically when you want to push changes to this repository. -The regression tests are generated using `jest` and stored as snapshots in `src/\_\_tests\_\_`. After modifying the frontend, carefully inspect any failing regression tests reported in red in the command line. If you are convinced that the regression tests and not your changes are at fault, you can update the regression tests by running: +The regression tests are generated using `jest` and stored as snapshots in `src/\_\_tests\_\_`. After modifying the frontend, carefully inspect any failing regression tests reported in red in the command line. If you are convinced that the regression tests and not your changes are at fault, you can update the regression tests by running: ```bash yarn test --updateSnapshot diff --git a/_config.yml b/_config.yml index c4192631f2..277f1f2c51 100644 --- a/_config.yml +++ b/_config.yml @@ -1 +1 @@ -theme: jekyll-theme-cayman \ No newline at end of file +theme: jekyll-theme-cayman diff --git a/craco.config.js b/craco.config.js index eaaa7ed985..ace50051fc 100644 --- a/craco.config.js +++ b/craco.config.js @@ -40,16 +40,16 @@ const cracoConfig = (module.exports = { // Polyfill Node.js core modules. // An empty implementation (false) is provided when there is no browser equivalent. webpackConfig.resolve.fallback = { - 'child_process': false, - 'constants': require.resolve('constants-browserify'), - 'fs': false, - 'http': require.resolve('stream-http'), - 'https': require.resolve('https-browserify'), - 'os': require.resolve('os-browserify/browser'), + child_process: false, + constants: require.resolve('constants-browserify'), + fs: false, + http: require.resolve('stream-http'), + https: require.resolve('https-browserify'), + os: require.resolve('os-browserify/browser'), 'path/posix': require.resolve('path-browserify'), - 'stream': require.resolve('stream-browserify'), - 'timers': require.resolve('timers-browserify'), - 'url': require.resolve('url/') + stream: require.resolve('stream-browserify'), + timers: require.resolve('timers-browserify'), + url: require.resolve('url/') }; // workaround .mjs files by Acorn @@ -59,25 +59,27 @@ const cracoConfig = (module.exports = { type: 'javascript/auto', resolve: { fullySpecified: false - }, + } }); // Ignore warnings for dependencies that do not ship with a source map. // This is because we cannot do anything about our dependencies. - webpackConfig.ignoreWarnings = [{ - module: /node_modules/, - message: /Failed to parse source map/ - }]; + webpackConfig.ignoreWarnings = [ + { + module: /node_modules/, + message: /Failed to parse source map/ + } + ]; webpackConfig.plugins = [ ...webpackConfig.plugins, // Make environment variables available in the browser by polyfilling the 'process' Node.js module. new webpack.ProvidePlugin({ - process: 'process/browser', + process: 'process/browser' }), // Make the 'buffer' Node.js module available in the browser. new webpack.ProvidePlugin({ - Buffer: ['buffer', 'Buffer'], + Buffer: ['buffer', 'Buffer'] }) ]; @@ -132,7 +134,7 @@ const cracoConfig = (module.exports = { 'decode-uri-component', 'split-on-first', 'filter-obj', - '@sourceacademy/c-slang', + '@sourceacademy/c-slang' ), '^.+\\.module\\.(css|sass|scss)$' ]; @@ -149,9 +151,7 @@ const cracoConfig = (module.exports = { } }, babel: { - presets: [ - ['@babel/preset-typescript'] - ] + presets: [['@babel/preset-typescript']] } }); diff --git a/public/index.html b/public/index.html index bf37250c90..90dabb55e9 100644 --- a/public/index.html +++ b/public/index.html @@ -1,23 +1,30 @@ - + - - - - - - - - - - - - - - - - Source Academy - - + Source Academy + + - - -
- - - + --> diff --git a/public/manifest.json b/public/manifest.json index b799aebed9..891708851b 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -13,16 +13,16 @@ "type": "image/png" }, { - "src": "icons/android-chrome-256x256.png", - "sizes": "256x256", - "type": "image/png" + "src": "icons/android-chrome-256x256.png", + "sizes": "256x256", + "type": "image/png" }, { "src": "icons/maskable.png", "sizes": "196x196", "type": "image/png", "purpose": "maskable" - } + } ], "start_url": "./", "display": "standalone", diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 4f6135575a..2c11352c2a 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -1,14 +1,17 @@ import { Classes } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; import { Ace, Range } from 'ace-builds'; +import { FSModule } from 'browserfs/dist/node/core/FS'; import classNames from 'classnames'; import { Chapter, Variant } from 'js-slang/dist/types'; import { isEqual } from 'lodash'; +import { decompressFromEncodedURIComponent } from 'lz-string'; import React, { Dispatch, useCallback, useEffect, useMemo, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; import { useDispatch, useStore } from 'react-redux'; import { useLocation, useNavigate } from 'react-router'; import { useParams } from 'react-router'; +import { AnyAction } from 'redux'; import { beginDebuggerPause, beginInterruptExecution, @@ -25,12 +28,20 @@ import { setSessionDetails, setSharedbConnected } from 'src/commons/collabEditing/CollabEditingActions'; +import { overwriteFilesInWorkspace } from 'src/commons/fileSystem/utils'; 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 { changeSideContentHeight } from 'src/commons/sideContent/SideContentActions'; import { useSideContent } from 'src/commons/sideContent/SideContentHelper'; import { useResponsive, useTypedSelector } from 'src/commons/utils/Hooks'; +import { convertParamToBoolean, convertParamToInt } from 'src/commons/utils/ParamParseHelper'; +import { IParsedQuery, parseQuery } from 'src/commons/utils/QueryHelper'; +import { + showFullJSWarningOnUrlLoad, + showFulTSWarningOnUrlLoad, + showHTMLDisclaimer +} from 'src/commons/utils/WarningDialogHelper'; import { addEditorTab, addHtmlConsoleError, @@ -127,13 +138,6 @@ import { makeSubstVisualizerTabFrom, mobileOnlyTabIds } from './PlaygroundTabs'; -import { FSModule } from 'browserfs/dist/node/core/FS'; -import { decompressFromEncodedURIComponent } from 'lz-string'; -import { AnyAction } from 'redux'; -import { overwriteFilesInWorkspace } from 'src/commons/fileSystem/utils'; -import { convertParamToInt, convertParamToBoolean } from 'src/commons/utils/ParamParseHelper'; -import { IParsedQuery, parseQuery } from 'src/commons/utils/QueryHelper'; -import { showFullJSWarningOnUrlLoad, showFulTSWarningOnUrlLoad, showHTMLDisclaimer } from 'src/commons/utils/WarningDialogHelper'; export type PlaygroundProps = { isSicpEditor?: boolean; @@ -145,89 +149,89 @@ export type PlaygroundProps = { const keyMap = { goGreen: 'h u l k' }; export async function handleHash( - hash: string, - handlers: { - handleChapterSelect: (chapter: Chapter, variant: Variant) => void; - handleChangeExecTime: (execTime: number) => void; - }, - workspaceLocation: WorkspaceLocation, - dispatch: Dispatch, - fileSystem: FSModule | null - ) { - // Make the parsed query string object a Partial because we might access keys which are not set. - const qs: Partial = parseQuery(hash); - - const chapter = convertParamToInt(qs.chap) ?? undefined; - if (chapter === Chapter.FULL_JS) { - showFullJSWarningOnUrlLoad(); - } else if (chapter === Chapter.FULL_TS) { - showFulTSWarningOnUrlLoad(); - } else { - if (chapter === Chapter.HTML) { - const continueToHtml = await showHTMLDisclaimer(); - if (!continueToHtml) { - return; - } - } - - // For backward compatibility with old share links - 'prgrm' is no longer used. - const program = qs.prgrm === undefined ? '' : decompressFromEncodedURIComponent(qs.prgrm); - - // By default, create just the default file. - const defaultFilePath = getDefaultFilePath(workspaceLocation); - const files: Record = - qs.files === undefined - ? { - [defaultFilePath]: program - } - : parseQuery(decompressFromEncodedURIComponent(qs.files)); - if (fileSystem !== null) { - await overwriteFilesInWorkspace(workspaceLocation, fileSystem, files); - } - - // BrowserFS does not provide a way of listening to changes in the file system, which makes - // updating the file system view troublesome. To force the file system view to re-render - // (and thus display the updated file system), we first disable Folder mode. - dispatch(setFolderMode(workspaceLocation, false)); - const isFolderModeEnabled = convertParamToBoolean(qs.isFolder) ?? false; - // If Folder mode should be enabled, enabling it after disabling it earlier will cause the - // newly-added files to be shown. Note that this has to take place after the files are - // already added to the file system. - dispatch(setFolderMode(workspaceLocation, isFolderModeEnabled)); - - // By default, open a single editor tab containing the default playground file. - const editorTabFilePaths = qs.tabs?.split(',').map(decompressFromEncodedURIComponent) ?? [ - defaultFilePath - ]; - // Remove all editor tabs before populating with the ones from the query string. - dispatch( - removeEditorTabsForDirectory(workspaceLocation, WORKSPACE_BASE_PATHS[workspaceLocation]) - ); - // Add editor tabs from the query string. - editorTabFilePaths.forEach(filePath => - // Fall back on the empty string if the file contents do not exist. - dispatch(addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) - ); - - // By default, use the first editor tab. - const activeEditorTabIndex = convertParamToInt(qs.tabIdx) ?? 0; - dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)); - if (chapter) { - // TODO: To migrate the state logic away from playgroundSourceChapter - // and playgroundSourceVariant into the language config instead - const languageConfig = getLanguageConfig(chapter, qs.variant as Variant); - handlers.handleChapterSelect(chapter, languageConfig.variant); - // Hardcoded for Playground only for now, while we await workspace refactoring - // to decouple the SicpWorkspace from the Playground. - dispatch(playgroundConfigLanguage(languageConfig)); - } - - const execTime = Math.max(convertParamToInt(qs.exec || '1000') || 1000, 1000); - if (execTime) { - handlers.handleChangeExecTime(execTime); + hash: string, + handlers: { + handleChapterSelect: (chapter: Chapter, variant: Variant) => void; + handleChangeExecTime: (execTime: number) => void; + }, + workspaceLocation: WorkspaceLocation, + dispatch: Dispatch, + fileSystem: FSModule | null +) { + // Make the parsed query string object a Partial because we might access keys which are not set. + const qs: Partial = parseQuery(hash); + + const chapter = convertParamToInt(qs.chap) ?? undefined; + if (chapter === Chapter.FULL_JS) { + showFullJSWarningOnUrlLoad(); + } else if (chapter === Chapter.FULL_TS) { + showFulTSWarningOnUrlLoad(); + } else { + if (chapter === Chapter.HTML) { + const continueToHtml = await showHTMLDisclaimer(); + if (!continueToHtml) { + return; } } + + // For backward compatibility with old share links - 'prgrm' is no longer used. + const program = qs.prgrm === undefined ? '' : decompressFromEncodedURIComponent(qs.prgrm); + + // By default, create just the default file. + const defaultFilePath = getDefaultFilePath(workspaceLocation); + const files: Record = + qs.files === undefined + ? { + [defaultFilePath]: program + } + : parseQuery(decompressFromEncodedURIComponent(qs.files)); + if (fileSystem !== null) { + await overwriteFilesInWorkspace(workspaceLocation, fileSystem, files); + } + + // BrowserFS does not provide a way of listening to changes in the file system, which makes + // updating the file system view troublesome. To force the file system view to re-render + // (and thus display the updated file system), we first disable Folder mode. + dispatch(setFolderMode(workspaceLocation, false)); + const isFolderModeEnabled = convertParamToBoolean(qs.isFolder) ?? false; + // If Folder mode should be enabled, enabling it after disabling it earlier will cause the + // newly-added files to be shown. Note that this has to take place after the files are + // already added to the file system. + dispatch(setFolderMode(workspaceLocation, isFolderModeEnabled)); + + // By default, open a single editor tab containing the default playground file. + const editorTabFilePaths = qs.tabs?.split(',').map(decompressFromEncodedURIComponent) ?? [ + defaultFilePath + ]; + // Remove all editor tabs before populating with the ones from the query string. + dispatch( + removeEditorTabsForDirectory(workspaceLocation, WORKSPACE_BASE_PATHS[workspaceLocation]) + ); + // Add editor tabs from the query string. + editorTabFilePaths.forEach(filePath => + // Fall back on the empty string if the file contents do not exist. + dispatch(addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) + ); + + // By default, use the first editor tab. + const activeEditorTabIndex = convertParamToInt(qs.tabIdx) ?? 0; + dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)); + if (chapter) { + // TODO: To migrate the state logic away from playgroundSourceChapter + // and playgroundSourceVariant into the language config instead + const languageConfig = getLanguageConfig(chapter, qs.variant as Variant); + handlers.handleChapterSelect(chapter, languageConfig.variant); + // Hardcoded for Playground only for now, while we await workspace refactoring + // to decouple the SicpWorkspace from the Playground. + dispatch(playgroundConfigLanguage(languageConfig)); + } + + const execTime = Math.max(convertParamToInt(qs.exec || '1000') || 1000, 1000); + if (execTime) { + handlers.handleChangeExecTime(execTime); + } } +} const Playground: React.FC = props => { const { isSicpEditor } = props; From 55c81a8927825f5c226ae59e9535a7c8454543e7 Mon Sep 17 00:00:00 2001 From: Rachel Date: Thu, 4 Apr 2024 15:00:25 +0800 Subject: [PATCH 09/18] format --- src/pages/playground/Playground.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 2c11352c2a..55a4791474 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -2,16 +2,19 @@ import { Classes } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; import { Ace, Range } from 'ace-builds'; import { FSModule } from 'browserfs/dist/node/core/FS'; +import { FSModule } from 'browserfs/dist/node/core/FS'; import classNames from 'classnames'; import { Chapter, Variant } from 'js-slang/dist/types'; import { isEqual } from 'lodash'; import { decompressFromEncodedURIComponent } from 'lz-string'; +import { decompressFromEncodedURIComponent } from 'lz-string'; import React, { Dispatch, useCallback, useEffect, useMemo, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; import { useDispatch, useStore } from 'react-redux'; import { useLocation, useNavigate } from 'react-router'; import { useParams } from 'react-router'; import { AnyAction } from 'redux'; +import { AnyAction } from 'redux'; import { beginDebuggerPause, beginInterruptExecution, @@ -29,6 +32,7 @@ import { setSharedbConnected } from 'src/commons/collabEditing/CollabEditingActions'; import { overwriteFilesInWorkspace } from 'src/commons/fileSystem/utils'; +import { overwriteFilesInWorkspace } from 'src/commons/fileSystem/utils'; import makeCseMachineTabFrom from 'src/commons/sideContent/content/SideContentCseMachine'; import makeDataVisualizerTabFrom from 'src/commons/sideContent/content/SideContentDataVisualizer'; import makeHtmlDisplayTabFrom from 'src/commons/sideContent/content/SideContentHtmlDisplay'; @@ -42,6 +46,9 @@ import { showFulTSWarningOnUrlLoad, showHTMLDisclaimer } from 'src/commons/utils/WarningDialogHelper'; +import { convertParamToBoolean,convertParamToInt } from 'src/commons/utils/ParamParseHelper'; +import { IParsedQuery, parseQuery } from 'src/commons/utils/QueryHelper'; +import { showFullJSWarningOnUrlLoad, showFulTSWarningOnUrlLoad, showHTMLDisclaimer } from 'src/commons/utils/WarningDialogHelper'; import { addEditorTab, addHtmlConsoleError, From 557d23a3785d06b7b98ad80ad00124113123c641 Mon Sep 17 00:00:00 2001 From: Rachel Date: Thu, 4 Apr 2024 15:18:07 +0800 Subject: [PATCH 10/18] format --- CONTRIBUTING.md | 2 +- _config.yml | 2 +- craco.config.js | 40 +++++++++---------- public/index.html | 62 +++++++++++++---------------- public/manifest.json | 8 ++-- src/pages/playground/Playground.tsx | 7 ---- 6 files changed, 54 insertions(+), 67 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a251aba277..6c32d7e20c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ To start contributing, create a fork from our repo and send a PR. Refer to [this The frontend comes with an extensive test suite. To run the tests after you made your modifications, run `yarn test`. Regression tests are run automatically when you want to push changes to this repository. -The regression tests are generated using `jest` and stored as snapshots in `src/\_\_tests\_\_`. After modifying the frontend, carefully inspect any failing regression tests reported in red in the command line. If you are convinced that the regression tests and not your changes are at fault, you can update the regression tests by running: +The regression tests are generated using `jest` and stored as snapshots in `src/\_\_tests\_\_`. After modifying the frontend, carefully inspect any failing regression tests reported in red in the command line. If you are convinced that the regression tests and not your changes are at fault, you can update the regression tests by running: ```bash yarn test --updateSnapshot diff --git a/_config.yml b/_config.yml index 277f1f2c51..c4192631f2 100644 --- a/_config.yml +++ b/_config.yml @@ -1 +1 @@ -theme: jekyll-theme-cayman +theme: jekyll-theme-cayman \ No newline at end of file diff --git a/craco.config.js b/craco.config.js index ace50051fc..eaaa7ed985 100644 --- a/craco.config.js +++ b/craco.config.js @@ -40,16 +40,16 @@ const cracoConfig = (module.exports = { // Polyfill Node.js core modules. // An empty implementation (false) is provided when there is no browser equivalent. webpackConfig.resolve.fallback = { - child_process: false, - constants: require.resolve('constants-browserify'), - fs: false, - http: require.resolve('stream-http'), - https: require.resolve('https-browserify'), - os: require.resolve('os-browserify/browser'), + 'child_process': false, + 'constants': require.resolve('constants-browserify'), + 'fs': false, + 'http': require.resolve('stream-http'), + 'https': require.resolve('https-browserify'), + 'os': require.resolve('os-browserify/browser'), 'path/posix': require.resolve('path-browserify'), - stream: require.resolve('stream-browserify'), - timers: require.resolve('timers-browserify'), - url: require.resolve('url/') + 'stream': require.resolve('stream-browserify'), + 'timers': require.resolve('timers-browserify'), + 'url': require.resolve('url/') }; // workaround .mjs files by Acorn @@ -59,27 +59,25 @@ const cracoConfig = (module.exports = { type: 'javascript/auto', resolve: { fullySpecified: false - } + }, }); // Ignore warnings for dependencies that do not ship with a source map. // This is because we cannot do anything about our dependencies. - webpackConfig.ignoreWarnings = [ - { - module: /node_modules/, - message: /Failed to parse source map/ - } - ]; + webpackConfig.ignoreWarnings = [{ + module: /node_modules/, + message: /Failed to parse source map/ + }]; webpackConfig.plugins = [ ...webpackConfig.plugins, // Make environment variables available in the browser by polyfilling the 'process' Node.js module. new webpack.ProvidePlugin({ - process: 'process/browser' + process: 'process/browser', }), // Make the 'buffer' Node.js module available in the browser. new webpack.ProvidePlugin({ - Buffer: ['buffer', 'Buffer'] + Buffer: ['buffer', 'Buffer'], }) ]; @@ -134,7 +132,7 @@ const cracoConfig = (module.exports = { 'decode-uri-component', 'split-on-first', 'filter-obj', - '@sourceacademy/c-slang' + '@sourceacademy/c-slang', ), '^.+\\.module\\.(css|sass|scss)$' ]; @@ -151,7 +149,9 @@ const cracoConfig = (module.exports = { } }, babel: { - presets: [['@babel/preset-typescript']] + presets: [ + ['@babel/preset-typescript'] + ] } }); diff --git a/public/index.html b/public/index.html index 90dabb55e9..bf37250c90 100644 --- a/public/index.html +++ b/public/index.html @@ -1,30 +1,23 @@ - + - - - - - - - - - - - - - - - Source Academy - - + Source Academy + + - - -
- + --> + + diff --git a/public/manifest.json b/public/manifest.json index 891708851b..b799aebed9 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -13,16 +13,16 @@ "type": "image/png" }, { - "src": "icons/android-chrome-256x256.png", - "sizes": "256x256", - "type": "image/png" + "src": "icons/android-chrome-256x256.png", + "sizes": "256x256", + "type": "image/png" }, { "src": "icons/maskable.png", "sizes": "196x196", "type": "image/png", "purpose": "maskable" - } + } ], "start_url": "./", "display": "standalone", diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 55a4791474..2c11352c2a 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -2,19 +2,16 @@ import { Classes } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; import { Ace, Range } from 'ace-builds'; import { FSModule } from 'browserfs/dist/node/core/FS'; -import { FSModule } from 'browserfs/dist/node/core/FS'; import classNames from 'classnames'; import { Chapter, Variant } from 'js-slang/dist/types'; import { isEqual } from 'lodash'; import { decompressFromEncodedURIComponent } from 'lz-string'; -import { decompressFromEncodedURIComponent } from 'lz-string'; import React, { Dispatch, useCallback, useEffect, useMemo, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; import { useDispatch, useStore } from 'react-redux'; import { useLocation, useNavigate } from 'react-router'; import { useParams } from 'react-router'; import { AnyAction } from 'redux'; -import { AnyAction } from 'redux'; import { beginDebuggerPause, beginInterruptExecution, @@ -32,7 +29,6 @@ import { setSharedbConnected } from 'src/commons/collabEditing/CollabEditingActions'; import { overwriteFilesInWorkspace } from 'src/commons/fileSystem/utils'; -import { overwriteFilesInWorkspace } from 'src/commons/fileSystem/utils'; import makeCseMachineTabFrom from 'src/commons/sideContent/content/SideContentCseMachine'; import makeDataVisualizerTabFrom from 'src/commons/sideContent/content/SideContentDataVisualizer'; import makeHtmlDisplayTabFrom from 'src/commons/sideContent/content/SideContentHtmlDisplay'; @@ -46,9 +42,6 @@ import { showFulTSWarningOnUrlLoad, showHTMLDisclaimer } from 'src/commons/utils/WarningDialogHelper'; -import { convertParamToBoolean,convertParamToInt } from 'src/commons/utils/ParamParseHelper'; -import { IParsedQuery, parseQuery } from 'src/commons/utils/QueryHelper'; -import { showFullJSWarningOnUrlLoad, showFulTSWarningOnUrlLoad, showHTMLDisclaimer } from 'src/commons/utils/WarningDialogHelper'; import { addEditorTab, addHtmlConsoleError, From 91a0a1ebda2b5f386323a57e076c8459fba7cfb4 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Sat, 6 Apr 2024 17:31:41 +0800 Subject: [PATCH 11/18] Fix incorrect merge resolution --- public/externalLibs/sound/soundToneMatrix.js | 6 +-- src/features/playground/PlaygroundActions.ts | 39 ++++++++++++-------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/public/externalLibs/sound/soundToneMatrix.js b/public/externalLibs/sound/soundToneMatrix.js index d0246757bf..8638a90378 100644 --- a/public/externalLibs/sound/soundToneMatrix.js +++ b/public/externalLibs/sound/soundToneMatrix.js @@ -36,7 +36,7 @@ var timeout_matrix; // for coloring the matrix accordingly while it's being played var timeout_color; -var timeout_objects = []; +var timeout_objects = new Array(); // vector_to_list returns a list that contains the elements of the argument vector // in the given order. @@ -54,7 +54,7 @@ function vector_to_list(vector) { function x_y_to_row_column(x, y) { var row = Math.floor((y - margin_length) / (square_side_length + distance_between_squares)); var column = Math.floor((x - margin_length) / (square_side_length + distance_between_squares)); - return [row, column]; + return Array(row, column); } // given the row number of a square, return the leftmost coordinate @@ -365,5 +365,5 @@ function clear_all_timeout() { clearTimeout(timeout_objects[i]); } - timeout_objects = []; + timeout_objects = new Array(); } diff --git a/src/features/playground/PlaygroundActions.ts b/src/features/playground/PlaygroundActions.ts index 2d590f334e..0c9b42e8e5 100644 --- a/src/features/playground/PlaygroundActions.ts +++ b/src/features/playground/PlaygroundActions.ts @@ -1,5 +1,5 @@ +import { createAction } from '@reduxjs/toolkit'; import { SALanguage } from 'src/commons/application/ApplicationTypes'; -import { action } from 'typesafe-actions'; import { PersistenceFile } from '../persistence/PersistenceTypes'; import { @@ -12,24 +12,31 @@ import { UPDATE_SHORT_URL } from './PlaygroundTypes'; -export const generateLzString = () => action(GENERATE_LZ_STRING); +export const generateLzString = createAction(GENERATE_LZ_STRING, () => ({ payload: {} })); -export const shortenURL = (keyword: string) => action(SHORTEN_URL, keyword); +export const shortenURL = createAction(SHORTEN_URL, (keyword: string) => ({ payload: keyword })); -export const updateShortURL = (shortURL: string) => action(UPDATE_SHORT_URL, shortURL); +export const updateShortURL = createAction(UPDATE_SHORT_URL, (shortURL: string) => ({ + payload: shortURL +})); -export const changeQueryString = (queryString: string) => action(CHANGE_QUERY_STRING, queryString); +export const changeQueryString = createAction(CHANGE_QUERY_STRING, (queryString: string) => ({ + payload: queryString +})); -// export const changeProgramConfig = (config: programConfig) => action(CHANGE_PROGRAM_CONFIG, config); - -export const playgroundUpdatePersistenceFile = (file?: PersistenceFile) => - action(PLAYGROUND_UPDATE_PERSISTENCE_FILE, file); +export const playgroundUpdatePersistenceFile = createAction( + PLAYGROUND_UPDATE_PERSISTENCE_FILE, + (file?: PersistenceFile) => ({ payload: file }) +); -export const playgroundUpdateGitHubSaveInfo = ( - repoName: string, - filePath: string, - lastSaved: Date -) => action(PLAYGROUND_UPDATE_GITHUB_SAVE_INFO, { repoName, filePath, lastSaved }); +export const playgroundUpdateGitHubSaveInfo = createAction( + PLAYGROUND_UPDATE_GITHUB_SAVE_INFO, + (repoName: string, filePath: string, lastSaved: Date) => ({ + payload: { repoName, filePath, lastSaved } + }) +); -export const playgroundConfigLanguage = (languageConfig: SALanguage) => - action(PLAYGROUND_UPDATE_LANGUAGE_CONFIG, languageConfig); +export const playgroundConfigLanguage = createAction( + PLAYGROUND_UPDATE_LANGUAGE_CONFIG, + (languageConfig: SALanguage) => ({ payload: languageConfig }) +); From 8518e6530c2d0788837c8b67073134786b1aa4da Mon Sep 17 00:00:00 2001 From: En Rong <53928333+chownces@users.noreply.github.com> Date: Sun, 7 Apr 2024 23:39:54 +0800 Subject: [PATCH 12/18] Add OOP-oriented implementation of encoding and decoding of share link state --- .../playground/shareLinks/ShareLinkState.ts | 17 +++++++++++++ .../playground/shareLinks/decoder/Decoder.ts | 19 +++++++++++++++ .../decoder/delegates/DecoderDelegate.ts | 7 ++++++ .../decoder/delegates/JsonDecoderDelegate.ts | 10 ++++++++ .../delegates/UrlParamsDecoderDelegate.ts | 10 ++++++++ .../playground/shareLinks/encoder/Encoder.ts | 24 +++++++++++++++++++ .../encoder/delegates/EncoderDelegate.ts | 7 ++++++ .../encoder/delegates/JsonEncoderDelegate.ts | 10 ++++++++ .../delegates/UrlParamsEncoderDelegate.ts | 12 ++++++++++ 9 files changed, 116 insertions(+) create mode 100644 src/features/playground/shareLinks/ShareLinkState.ts create mode 100644 src/features/playground/shareLinks/decoder/Decoder.ts create mode 100644 src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts create mode 100644 src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts create mode 100644 src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts create mode 100644 src/features/playground/shareLinks/encoder/Encoder.ts create mode 100644 src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.ts create mode 100644 src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.ts create mode 100644 src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts diff --git a/src/features/playground/shareLinks/ShareLinkState.ts b/src/features/playground/shareLinks/ShareLinkState.ts new file mode 100644 index 0000000000..8c300c4ff6 --- /dev/null +++ b/src/features/playground/shareLinks/ShareLinkState.ts @@ -0,0 +1,17 @@ +import { Chapter, Variant } from "js-slang/dist/types"; + +import { ExternalLibraryName } from "../../../commons/application/types/ExternalTypes"; + +type ShareLinkState = { + // TODO: Double check + isFolder: boolean; + files: string; + tabs: string[]; + tabIdx: number | null; + chap: Chapter; + variant: Variant; + ext: ExternalLibraryName; + exec: number; +} + +export default ShareLinkState; diff --git a/src/features/playground/shareLinks/decoder/Decoder.ts b/src/features/playground/shareLinks/decoder/Decoder.ts new file mode 100644 index 0000000000..53ff0d20e9 --- /dev/null +++ b/src/features/playground/shareLinks/decoder/Decoder.ts @@ -0,0 +1,19 @@ +import ShareLinkState from "../ShareLinkState"; +import DecoderDelegate from "./delegates/DecoderDelegate"; + +/** + * Decodes the given encodedString with the specified decoder in `decodeWith`. + */ +class ShareLinkStateDecoder { + encodedString: string; + + constructor(encodedString: string) { + this.encodedString = encodedString; + } + + decodeWith(decoderDelegate: DecoderDelegate): ShareLinkState { + return decoderDelegate.decode(this.encodedString); + } +} + +export default ShareLinkStateDecoder; diff --git a/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts b/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts new file mode 100644 index 0000000000..5e01e2cbe7 --- /dev/null +++ b/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts @@ -0,0 +1,7 @@ +import ShareLinkState from "../../ShareLinkState"; + +interface DecoderDelegate { + decode(str: string): ShareLinkState; +} + +export default DecoderDelegate; diff --git a/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts b/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts new file mode 100644 index 0000000000..14442e60f6 --- /dev/null +++ b/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts @@ -0,0 +1,10 @@ +import ShareLinkState from "../../ShareLinkState"; +import DecoderDelegate from "./DecoderDelegate"; + +class JsonDecoderDelegate implements DecoderDelegate { + decode(str: string): ShareLinkState { + // TODO: Implement + } +} + +export default JsonDecoderDelegate; diff --git a/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts b/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts new file mode 100644 index 0000000000..5e79cc9b11 --- /dev/null +++ b/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts @@ -0,0 +1,10 @@ +import ShareLinkState from "../../ShareLinkState"; +import DecoderDelegate from "./DecoderDelegate"; + +class UrlParamsDecoderDelegate implements DecoderDelegate { + decode(str: string): ShareLinkState { + // TODO: Implement + } +} + +export default UrlParamsDecoderDelegate; diff --git a/src/features/playground/shareLinks/encoder/Encoder.ts b/src/features/playground/shareLinks/encoder/Encoder.ts new file mode 100644 index 0000000000..6eba3cfeb5 --- /dev/null +++ b/src/features/playground/shareLinks/encoder/Encoder.ts @@ -0,0 +1,24 @@ +import ShareLinkState from "../ShareLinkState"; +import EncoderDelegate from "./delegates/EncoderDelegate"; + +/** + * Creates a snapshot of the current ShareLinkState during instantiation. + * Use `encodeWith(encoderDelegate)` to output an encoding of this state snapshot. + */ +class ShareLinkStateEncoder { + state: ShareLinkState; + + constructor() { + this.state = this.getState(); + } + + encodeWith(encoderDelegate: EncoderDelegate): string { + return encoderDelegate.encode(this.state); + } + + private getState(): ShareLinkState { + // TODO: Implement + } +} + +export default ShareLinkStateEncoder; diff --git a/src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.ts b/src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.ts new file mode 100644 index 0000000000..8c5f42792a --- /dev/null +++ b/src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.ts @@ -0,0 +1,7 @@ +import ShareLinkState from "../../ShareLinkState"; + +interface EncoderDelegate { + encode(state: ShareLinkState): string; +} + +export default EncoderDelegate; diff --git a/src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.ts b/src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.ts new file mode 100644 index 0000000000..68c309963d --- /dev/null +++ b/src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.ts @@ -0,0 +1,10 @@ +import ShareLinkState from "../../ShareLinkState"; +import EncoderDelegate from "./EncoderDelegate"; + +class JsonEncoderDelegate implements EncoderDelegate { + encode(state: ShareLinkState): string { + return JSON.stringify(state); + } +} + +export default JsonEncoderDelegate; diff --git a/src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts b/src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts new file mode 100644 index 0000000000..9c38ab0599 --- /dev/null +++ b/src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts @@ -0,0 +1,12 @@ +import qs from 'query-string'; + +import ShareLinkState from "../../ShareLinkState"; +import EncoderDelegate from "./EncoderDelegate"; + +class UrlParamsEncoderDelegate implements EncoderDelegate { + encode(state: ShareLinkState): string { + return qs.stringify(state); + } +} + +export default UrlParamsEncoderDelegate; From 433c81eead53d1841356ddeeb08e09690864cb0e Mon Sep 17 00:00:00 2001 From: Rachel Date: Fri, 12 Apr 2024 21:58:30 +0800 Subject: [PATCH 13/18] debug and add decoder oop --- package.json | 1 - .../controlBar/ControlBarShareButton.tsx | 64 +++---- .../playground/shareLinks/ShareLinkState.tsx | 13 ++ .../playground/shareLinks/decoder/Decoder.ts | 19 ++ .../decoder/delegates/DecoderDelegate.ts | 7 + .../decoder/delegates/JsonDecoderDelegate.ts | 11 ++ .../delegates/UrlParamsDecoderDelegate.ts | 23 +++ .../playground/shareLinks/encoder/Encoder.ts | 25 +++ .../shareLinks/encoder/URLEncoder.tsx | 53 ++++++ .../encoder/delegates/EncoderDelegate.tsx | 7 + .../encoder/delegates/JsonEncoderDelegate.tsx | 10 + .../delegates/UrlParamsEncoderDelegate.ts | 12 ++ src/pages/playground/Decoder.tsx | 155 ---------------- src/pages/playground/Encoder.tsx | 59 ------ src/pages/playground/Playground.tsx | 173 ++++++++++++++---- 15 files changed, 345 insertions(+), 287 deletions(-) create mode 100644 src/features/playground/shareLinks/ShareLinkState.tsx create mode 100644 src/features/playground/shareLinks/decoder/Decoder.ts create mode 100644 src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts create mode 100644 src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts create mode 100644 src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts create mode 100644 src/features/playground/shareLinks/encoder/Encoder.ts create mode 100644 src/features/playground/shareLinks/encoder/URLEncoder.tsx create mode 100644 src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.tsx create mode 100644 src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.tsx create mode 100644 src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts delete mode 100644 src/pages/playground/Decoder.tsx delete mode 100644 src/pages/playground/Encoder.tsx diff --git a/package.json b/package.json index fec6924f3f..841cb80bba 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "react-draggable": "^4.4.5", "react-dropzone": "^14.2.3", "react-hotkeys": "^2.0.0", - "react-hotkeys-hook": "^4.4.4", "react-konva": "^18.2.10", "react-latex-next": "^2.1.0", "react-mde": "^11.5.0", diff --git a/src/commons/controlBar/ControlBarShareButton.tsx b/src/commons/controlBar/ControlBarShareButton.tsx index 8382dd1435..7a8d33f62f 100644 --- a/src/commons/controlBar/ControlBarShareButton.tsx +++ b/src/commons/controlBar/ControlBarShareButton.tsx @@ -3,9 +3,11 @@ import { IconNames } from '@blueprintjs/icons'; import { Popover2, Tooltip2 } from '@blueprintjs/popover2'; import React from 'react'; import * as CopyToClipboard from 'react-copy-to-clipboard'; +import ShareLinkState from 'src/features/playground/shareLinks/ShareLinkState'; import ControlButton from '../ControlButton'; import Constants from '../utils/Constants'; +import { showWarningMessage } from '../utils/notifications/NotificationsHelper'; type ControlBarShareButtonProps = DispatchProps & StateProps; @@ -20,7 +22,7 @@ type StateProps = { shortURL?: string; key: string; isSicp?: boolean; - programConfig: object; + programConfig: Partial; }; type State = { @@ -89,37 +91,11 @@ export class ControlBarShareButton extends React.PureComponent - <>{console.log(this.props.programConfig)} { - // post request to backend, set keyword as return uuid - const requestBody = { - shared_program: { - data: this.props.programConfig - } - }; - const fetchOpts: RequestInit = { - method: 'POST', - body: JSON.stringify(requestBody), - headers: { - 'Content-Type': 'application/json' - } - }; - fetch('http://localhost:4000/api/shared_programs', fetchOpts) - .then(res => { - return res.json(); - }) - .then(resp => { - this.setState({ - keyword: 'http://localhost:8000/playground/share/' + resp.uuid - }); - console.log(resp); - }) - .catch(err => console.log('Error: ', err)); - this.setState({ isLoading: true, isSuccess: true }); - }} + // post request to backend, set keyword as return uuid + onClick={this.fetchUUID} />
) : ( @@ -131,12 +107,9 @@ export class ControlBarShareButton extends React.PureComponent ) ) : ( - //
- {/* */} - {/* */} @@ -185,4 +158,31 @@ export class ControlBarShareButton extends React.PureComponent { + return res.json(); + }) + .then(resp => { + this.setState({ + // seems like there's no frontend url env variavle, should be replaced by frontend server accordingly + keyword: `http://localhost:8000/playground/share/` + resp.uuid + }); + this.setState({ isLoading: true, isSuccess: true }); + }) + .catch(err => showWarningMessage('fail to generate url!' + err)); + } } diff --git a/src/features/playground/shareLinks/ShareLinkState.tsx b/src/features/playground/shareLinks/ShareLinkState.tsx new file mode 100644 index 0000000000..70e476d99e --- /dev/null +++ b/src/features/playground/shareLinks/ShareLinkState.tsx @@ -0,0 +1,13 @@ +type ShareLinkState = { + isFolder: string; + tabs: string; + tabIdx: string; + chap: string; + variant: string; + ext: string; + exec: string; + files: string; + prgrm: string; +}; + +export default ShareLinkState; diff --git a/src/features/playground/shareLinks/decoder/Decoder.ts b/src/features/playground/shareLinks/decoder/Decoder.ts new file mode 100644 index 0000000000..b7831257f2 --- /dev/null +++ b/src/features/playground/shareLinks/decoder/Decoder.ts @@ -0,0 +1,19 @@ +import ShareLinkState from '../ShareLinkState'; +import DecoderDelegate from './delegates/DecoderDelegate'; + +/** + * Decodes the given encodedString with the specified decoder in `decodeWith`. + */ +class ShareLinkStateDecoder { + encodedString: string; + + constructor(encodedString: string) { + this.encodedString = encodedString; + } + + decodeWith(decoderDelegate: DecoderDelegate): Partial { + return decoderDelegate.decode(this.encodedString); + } +} + +export default ShareLinkStateDecoder; diff --git a/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts b/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts new file mode 100644 index 0000000000..37dafed84a --- /dev/null +++ b/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts @@ -0,0 +1,7 @@ +import ShareLinkState from '../../ShareLinkState'; + +interface DecoderDelegate { + decode(str: string): Partial; +} + +export default DecoderDelegate; diff --git a/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts b/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts new file mode 100644 index 0000000000..c3d0375b90 --- /dev/null +++ b/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts @@ -0,0 +1,11 @@ +import ShareLinkState from '../../ShareLinkState'; +import DecoderDelegate from './DecoderDelegate'; + +class JsonDecoderDelegate implements DecoderDelegate { + decode(str: string): Partial { + const jsonObject = JSON.parse(str); + return jsonObject.data; + } +} + +export default JsonDecoderDelegate; diff --git a/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts b/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts new file mode 100644 index 0000000000..3d7e41992c --- /dev/null +++ b/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts @@ -0,0 +1,23 @@ +import { IParsedQuery, parseQuery } from 'src/commons/utils/QueryHelper'; + +import ShareLinkState from '../../ShareLinkState'; +import DecoderDelegate from './DecoderDelegate'; + +class UrlParamsDecoderDelegate implements DecoderDelegate { + decode(str: string): Partial { + const qs: Partial = parseQuery(str); + return { + chap: qs.chap, + exec: qs.exec, + files: qs.files, + isFolder: qs.isFolder, + tabIdx: qs.tabIdx, + tabs: qs.tabs, + variant: qs.variant, + prgrm: qs.prgrm, + ext: qs.ext + }; + } +} + +export default UrlParamsDecoderDelegate; diff --git a/src/features/playground/shareLinks/encoder/Encoder.ts b/src/features/playground/shareLinks/encoder/Encoder.ts new file mode 100644 index 0000000000..70f4506921 --- /dev/null +++ b/src/features/playground/shareLinks/encoder/Encoder.ts @@ -0,0 +1,25 @@ +import ShareLinkState from '../ShareLinkState'; +import EncoderDelegate from './delegates/EncoderDelegate'; + +/** + * Creates a snapshot of the current ShareLinkState during instantiation. + * Use `encodeWith(encoderDelegate)` to output an encoding of this state snapshot. + */ +class ShareLinkStateEncoder { + state: Partial; + + constructor() { + this.state = this.getState(); + } + + encodeWith(encoderDelegate: EncoderDelegate): string { + return encoderDelegate.encode(this.state); + } + + private getState(): Partial { + // TODO: Implement + return {}; + } +} + +export default ShareLinkStateEncoder; diff --git a/src/features/playground/shareLinks/encoder/URLEncoder.tsx b/src/features/playground/shareLinks/encoder/URLEncoder.tsx new file mode 100644 index 0000000000..e422a78e36 --- /dev/null +++ b/src/features/playground/shareLinks/encoder/URLEncoder.tsx @@ -0,0 +1,53 @@ +import { FSModule } from 'browserfs/dist/node/core/FS'; +import { compressToEncodedURIComponent } from 'lz-string'; +import qs from 'query-string'; +import { useState } from 'react'; +import { retrieveFilesInWorkspaceAsRecord } from 'src/commons/fileSystem/utils'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; +import { EditorTabState } from 'src/commons/workspace/WorkspaceTypes'; + +import ShareLinkState from '../ShareLinkState'; + +export const useURLEncoder = () => { + const isFolderModeEnabled = useTypedSelector( + state => state.workspaces.playground.isFolderModeEnabled + ); + + const editorTabs = useTypedSelector(state => state.workspaces.playground.editorTabs); + const editorTabFilePaths = editorTabs + .map((editorTab: EditorTabState) => editorTab.filePath) + .filter((filePath): filePath is string => filePath !== undefined); + const activeEditorTabIndex: number | null = useTypedSelector( + state => state.workspaces.playground.activeEditorTabIndex + ); + const chapter = useTypedSelector(state => state.workspaces.playground.context.chapter); + const variant = useTypedSelector(state => state.workspaces.playground.context.variant); + const execTime = useTypedSelector(state => state.workspaces.playground.execTime); + const fileSystem = useGetFileSystem(); + + const result: Partial = { + isFolder: isFolderModeEnabled.toString(), + files: useGetFile(fileSystem).toString(), + tabs: editorTabFilePaths.map(compressToEncodedURIComponent)[0], + tabIdx: activeEditorTabIndex?.toString(), + chap: chapter.toString(), + variant, + ext: 'NONE', + exec: execTime.toString() + }; + + return result; +}; + +const useGetFileSystem = () => { + const fileSystem = useTypedSelector(state => state.fileSystem.inBrowserFileSystem); + return fileSystem as FSModule; +}; + +const useGetFile = (fileSystem: FSModule) => { + const [files, setFiles] = useState>({}); + retrieveFilesInWorkspaceAsRecord('playground', fileSystem).then(result => { + setFiles(result); + }); + return compressToEncodedURIComponent(qs.stringify(files)); +}; diff --git a/src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.tsx b/src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.tsx new file mode 100644 index 0000000000..9027f359ed --- /dev/null +++ b/src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.tsx @@ -0,0 +1,7 @@ +import ShareLinkState from '../../ShareLinkState'; + +interface EncoderDelegate { + encode(state: Partial): string; +} + +export default EncoderDelegate; diff --git a/src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.tsx b/src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.tsx new file mode 100644 index 0000000000..936d9e7565 --- /dev/null +++ b/src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.tsx @@ -0,0 +1,10 @@ +import ShareLinkState from '../../ShareLinkState'; +import EncoderDelegate from './EncoderDelegate'; + +class JsonEncoderDelegate implements EncoderDelegate { + encode(state: Partial): string { + return JSON.stringify(state); + } +} + +export default JsonEncoderDelegate; diff --git a/src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts b/src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts new file mode 100644 index 0000000000..e6f90c6747 --- /dev/null +++ b/src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts @@ -0,0 +1,12 @@ +import qs from 'query-string'; + +import ShareLinkState from '../../ShareLinkState'; +import EncoderDelegate from './EncoderDelegate'; + +class UrlParamsEncoderDelegate implements EncoderDelegate { + encode(state: ShareLinkState): string { + return qs.stringify(state); + } +} + +export default UrlParamsEncoderDelegate; diff --git a/src/pages/playground/Decoder.tsx b/src/pages/playground/Decoder.tsx deleted file mode 100644 index 92aeb73783..0000000000 --- a/src/pages/playground/Decoder.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { FSModule } from 'browserfs/dist/node/core/FS'; -import { Chapter, Variant } from 'js-slang/dist/types'; -import { decompressFromEncodedURIComponent } from 'lz-string'; -import { Dispatch } from 'react'; -import { AnyAction } from 'redux'; -import { getDefaultFilePath, getLanguageConfig } from 'src/commons/application/ApplicationTypes'; -import { overwriteFilesInWorkspace } from 'src/commons/fileSystem/utils'; -import { - showFullJSWarningOnUrlLoad, - showFulTSWarningOnUrlLoad, - showHTMLDisclaimer -} from 'src/commons/utils/WarningDialogHelper'; -import { - addEditorTab, - removeEditorTabsForDirectory, - setFolderMode, - updateActiveEditorTabIndex -} from 'src/commons/workspace/WorkspaceActions'; -import { WorkspaceLocation } from 'src/commons/workspace/WorkspaceTypes'; -import { playgroundConfigLanguage } from 'src/features/playground/PlaygroundActions'; - -import { convertParamToBoolean, convertParamToInt } from '../../commons/utils/ParamParseHelper'; -import { IParsedQuery, parseQuery } from '../../commons/utils/QueryHelper'; -import { WORKSPACE_BASE_PATHS } from '../fileSystem/createInBrowserFileSystem'; - -export type programConfig = { - isFolder: string | undefined; - tabs: string | undefined; - tabIdx: string | undefined; - chap: string | undefined; - variant: string | undefined; - ext: string | undefined; - exec: string | undefined; - files: string | undefined; - prgrm: string | undefined; -}; -/** - * #chap=4 - * exec=1000 - * ext=NONE - * files=KQJgYgDgNghgngcwE4HsCuA7AJqSrkwC2AdAFYDOAvEA - * isFolder=false - * tabIdx=0 - * tabs=PQBwNghgng5gTgewK4DsAmpHwgWwHQBWAzkA - * variant=default - */ -export const Decoder = { - decodeString: function (inputString: string) { - const qs: Partial = parseQuery(inputString); - return { - chap: qs.chap, - exec: qs.exec, - files: qs.files, - isFolder: qs.isFolder, - tabIdx: qs.tabIdx, - tabs: qs.tabs, - variant: qs.variant, - prgrm: qs.prgrm, - ext: qs.ext - }; - }, - - decodeJSON: function (inputJSON: string) { - const jsonObject = JSON.parse(inputJSON); - return jsonObject.data; - } -}; - -export async function resetConfig( - configObj: programConfig, - handlers: { - handleChapterSelect: (chapter: Chapter, variant: Variant) => void; - handleChangeExecTime: (execTime: number) => void; - }, - workspaceLocation: WorkspaceLocation, - dispatch: Dispatch, - fileSystem: FSModule | null -) { - const chapter = convertParamToInt(configObj.chap?.toString()) ?? undefined; - if (chapter === Chapter.FULL_JS) { - showFullJSWarningOnUrlLoad(); - } else if (chapter === Chapter.FULL_TS) { - showFulTSWarningOnUrlLoad(); - } else { - if (chapter === Chapter.HTML) { - const continueToHtml = await showHTMLDisclaimer(); - if (!continueToHtml) { - return; - } - } - - // For backward compatibility with old share links - 'prgrm' is no longer used. - const program = - configObj.prgrm === undefined ? '' : decompressFromEncodedURIComponent(configObj.prgrm); - - // By default, create just the default file. - const defaultFilePath = getDefaultFilePath(workspaceLocation); - const files: Record = - configObj.files === undefined - ? { - [defaultFilePath]: program - } - : parseQuery(decompressFromEncodedURIComponent(configObj.files)); - if (fileSystem !== null) { - await overwriteFilesInWorkspace(workspaceLocation, fileSystem, files); - } - - // BrowserFS does not provide a way of listening to changes in the file system, which makes - // updating the file system view troublesome. To force the file system view to re-render - // (and thus display the updated file system), we first disable Folder mode. - dispatch(setFolderMode(workspaceLocation, false)); - const isFolderModeEnabled = convertParamToBoolean(configObj.isFolder?.toString()) ?? false; - - // If Folder mode should be enabled, enabling it after disabling it earlier will cause the - // newly-added files to be shown. Note that this has to take place after the files are - // already added to the file system. - dispatch(setFolderMode(workspaceLocation, isFolderModeEnabled)); - - // By default, open a single editor tab containing the default playground file. - const editorTabFilePaths = configObj.tabs - ?.split(',') - .map(decompressFromEncodedURIComponent) ?? [defaultFilePath]; - - // Remove all editor tabs before populating with the ones from the query string. - dispatch( - removeEditorTabsForDirectory(workspaceLocation, WORKSPACE_BASE_PATHS[workspaceLocation]) - ); - // Add editor tabs from the query string. - editorTabFilePaths.forEach(filePath => - // Fall back on the empty string if the file contents do not exist. - dispatch(addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) - ); - - // By default, use the first editor tab. - const activeEditorTabIndex = convertParamToInt(configObj.tabIdx?.toString()) ?? 0; - dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)); - if (chapter) { - // TODO: To migrate the state logic away from playgroundSourceChapter - // and playgroundSourceVariant into the language config instead - const languageConfig = getLanguageConfig(chapter, configObj.variant as Variant); - handlers.handleChapterSelect(chapter, languageConfig.variant); - // Hardcoded for Playground only for now, while we await workspace refactoring - // to decouple the SicpWorkspace from the Playground. - dispatch(playgroundConfigLanguage(languageConfig)); - } - - const execTime = Math.max( - convertParamToInt(configObj.exec?.toString() || '1000') || 1000, - 1000 - ); - if (execTime) { - handlers.handleChangeExecTime(execTime); - } - } -} diff --git a/src/pages/playground/Encoder.tsx b/src/pages/playground/Encoder.tsx deleted file mode 100644 index e9e25876b6..0000000000 --- a/src/pages/playground/Encoder.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { FSModule } from 'browserfs/dist/node/core/FS'; -import { Chapter, Variant } from 'js-slang/dist/types'; -import { compressToEncodedURIComponent } from 'lz-string'; -import qs from 'query-string'; -import { useState } from 'react'; -// import { OverallState } from 'src/commons/application/ApplicationTypes'; -import { retrieveFilesInWorkspaceAsRecord } from 'src/commons/fileSystem/utils'; -import { useTypedSelector } from 'src/commons/utils/Hooks'; -import { EditorTabState } from 'src/commons/workspace/WorkspaceTypes'; - -export const EncodeURL = () => { - const isFolderModeEnabled: boolean = useTypedSelector( - state => state.workspaces.playground.isFolderModeEnabled - ); - - const editorTabs: EditorTabState[] = useTypedSelector( - state => state.workspaces.playground.editorTabs - ); - const editorTabFilePaths = editorTabs - .map((editorTab: EditorTabState) => editorTab.filePath) - .filter((filePath): filePath is string => filePath !== undefined); - const activeEditorTabIndex: number | null = useTypedSelector( - state => state.workspaces.playground.activeEditorTabIndex - ); - const chapter: Chapter = useTypedSelector(state => state.workspaces.playground.context.chapter); - const variant: Variant = useTypedSelector(state => state.workspaces.playground.context.variant); - const execTime: number = useTypedSelector(state => state.workspaces.playground.execTime); - const fileSystem: FSModule = GetFileSystem(); - - const result: object = { - isFolder: isFolderModeEnabled, - files: GetFile(fileSystem), - tabs: editorTabFilePaths.map(compressToEncodedURIComponent)[0], - tabIdx: activeEditorTabIndex, - chap: chapter, - variant, - ext: 'NONE', - exec: execTime - }; - - return result; -}; - -const GetFileSystem = () => { - const fileSystem: FSModule | null = useTypedSelector( - state => state.fileSystem.inBrowserFileSystem - ); - return fileSystem as FSModule; -}; - -const GetFile = (fileSystem: FSModule) => { - const [files, setFiles] = useState>(); - retrieveFilesInWorkspaceAsRecord('playground', fileSystem).then( - (result: Record) => { - setFiles(result); - } - ); - return compressToEncodedURIComponent(qs.stringify(files as Record)); -}; diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 2c11352c2a..16bb54902e 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -87,6 +87,11 @@ import { shortenURL, updateShortURL } from 'src/features/playground/PlaygroundActions'; +import ShareLinkStateDecoder from 'src/features/playground/shareLinks/decoder/Decoder'; +import JsonDecoderDelegate from 'src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate'; +import UrlParamsDecoderDelegate from 'src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate'; +import { useURLEncoder } from 'src/features/playground/shareLinks/encoder/URLEncoder'; +import ShareLinkState from 'src/features/playground/shareLinks/ShareLinkState'; import { getDefaultFilePath, @@ -129,8 +134,6 @@ import { SelectionRange } from '../../features/sourceRecorder/SourceRecorderTypes'; import { WORKSPACE_BASE_PATHS } from '../fileSystem/createInBrowserFileSystem'; -import { Decoder, programConfig, resetConfig } from './Decoder'; -import { EncodeURL } from './Encoder'; import { desktopOnlyTabIds, makeIntroductionTabFrom, @@ -233,13 +236,100 @@ export async function handleHash( } } +export async function resetConfig( + configObj: Partial, + handlers: { + handleChapterSelect: (chapter: Chapter, variant: Variant) => void; + handleChangeExecTime: (execTime: number) => void; + }, + workspaceLocation: WorkspaceLocation, + dispatch: Dispatch, + fileSystem: FSModule | null +) { + const chapter = convertParamToInt(configObj.chap?.toString()) ?? undefined; + if (chapter === Chapter.FULL_JS) { + showFullJSWarningOnUrlLoad(); + } else if (chapter === Chapter.FULL_TS) { + showFulTSWarningOnUrlLoad(); + } else { + if (chapter === Chapter.HTML) { + const continueToHtml = await showHTMLDisclaimer(); + if (!continueToHtml) { + return; + } + } + + // For backward compatibility with old share links - 'prgrm' is no longer used. + const program = + configObj.prgrm === undefined ? '' : decompressFromEncodedURIComponent(configObj.prgrm); + + // By default, create just the default file. + const defaultFilePath = getDefaultFilePath(workspaceLocation); + const files: Record = + configObj.files === undefined + ? { + [defaultFilePath]: program + } + : parseQuery(decompressFromEncodedURIComponent(configObj.files)); + if (fileSystem !== null) { + await overwriteFilesInWorkspace(workspaceLocation, fileSystem, files); + } + + // BrowserFS does not provide a way of listening to changes in the file system, which makes + // updating the file system view troublesome. To force the file system view to re-render + // (and thus display the updated file system), we first disable Folder mode. + dispatch(setFolderMode(workspaceLocation, false)); + const isFolderModeEnabled = convertParamToBoolean(configObj.isFolder?.toString()) ?? false; + + // If Folder mode should be enabled, enabling it after disabling it earlier will cause the + // newly-added files to be shown. Note that this has to take place after the files are + // already added to the file system. + dispatch(setFolderMode(workspaceLocation, isFolderModeEnabled)); + + // By default, open a single editor tab containing the default playground file. + const editorTabFilePaths = configObj.tabs + ?.split(',') + .map(decompressFromEncodedURIComponent) ?? [defaultFilePath]; + + // Remove all editor tabs before populating with the ones from the query string. + dispatch( + removeEditorTabsForDirectory(workspaceLocation, WORKSPACE_BASE_PATHS[workspaceLocation]) + ); + // Add editor tabs from the query string. + editorTabFilePaths.forEach(filePath => + // Fall back on the empty string if the file contents do not exist. + dispatch(addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) + ); + + // By default, use the first editor tab. + const activeEditorTabIndex = convertParamToInt(configObj.tabIdx?.toString()) ?? 0; + dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)); + if (chapter) { + // TODO: To migrate the state logic away from playgroundSourceChapter + // and playgroundSourceVariant into the language config instead + const languageConfig = getLanguageConfig(chapter, configObj.variant as Variant); + handlers.handleChapterSelect(chapter, languageConfig.variant); + // Hardcoded for Playground only for now, while we await workspace refactoring + // to decouple the SicpWorkspace from the Playground. + dispatch(playgroundConfigLanguage(languageConfig)); + } + + const execTime = Math.max( + convertParamToInt(configObj.exec?.toString() || '1000') || 1000, + 1000 + ); + if (execTime) { + handlers.handleChangeExecTime(execTime); + } + } +} + const Playground: React.FC = props => { const { isSicpEditor } = props; const workspaceLocation: WorkspaceLocation = isSicpEditor ? 'sicp' : 'playground'; const { isMobileBreakpoint } = useResponsive(); const [deviceSecret, setDeviceSecret] = useState(); - // const location = useLocation(); const navigate = useNavigate(); const store = useStore(); const location = useLocation(); @@ -337,36 +427,6 @@ const Playground: React.FC = props => { state => state.workspaces.playground.externalLibrary ); - const { uuid } = useParams(); - - const handleURL = () => { - if (uuid) { - fetch(`http://localhost:4000/api/shared_programs/${uuid}`) - .then(response => response.json()) - .then(resp => { - // console.log("resp", resp) - const res: programConfig = Decoder.decodeJSON(resp); - console.log('decode', res); - resetConfig( - res, - { handleChangeExecTime, handleChapterSelect }, - workspaceLocation, - dispatch, - fileSystem - ); - }); - } else { - const config = Decoder.decodeString(location.hash); - resetConfig( - config, - { handleChangeExecTime, handleChapterSelect }, - workspaceLocation, - dispatch, - fileSystem - ); - } - }; - useEffect(() => { // When the editor session Id changes, then treat it as a new session. setSessionId( @@ -380,9 +440,42 @@ const Playground: React.FC = props => { }, [editorSessionId]); const hash = isSicpEditor ? props.initialEditorValueHash : location.hash; + const { uuid } = useParams(); useEffect(() => { - if (!hash) { + const handleURL = (uuid: string | undefined) => { + if (uuid !== undefined) { + fetch(`${Constants.backendUrl}/api/shared_programs/${uuid}`) + .then(response => response.json()) + .then(resp => { + // console.log("resp", resp) + const res: Partial = new ShareLinkStateDecoder(resp).decodeWith( + new JsonDecoderDelegate() + ); + // console.log('decode', res); + resetConfig( + res, + { handleChangeExecTime, handleChapterSelect }, + workspaceLocation, + dispatch, + fileSystem + ); + }); + } else { + const config = new ShareLinkStateDecoder(location.hash).decodeWith( + new UrlParamsDecoderDelegate() + ); + resetConfig( + config, + { handleChangeExecTime, handleChapterSelect }, + workspaceLocation, + dispatch, + fileSystem + ); + } + }; + + if (!hash && uuid === undefined) { // If not a accessing via shared link, use the Source chapter and variant in the current course if (courseSourceChapter && courseSourceVariant) { handleChapterSelect(courseSourceChapter, courseSourceVariant); @@ -396,15 +489,16 @@ const Playground: React.FC = props => { // This is because Folder mode only works in Source 2+. dispatch(setFolderMode(workspaceLocation, false)); } + } else { + handleURL(uuid); } - handleURL(); return; - - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ dispatch, fileSystem, hash, + location.hash, + uuid, courseSourceChapter, courseSourceVariant, workspaceLocation, @@ -717,7 +811,7 @@ const Playground: React.FC = props => { ] ); - const config = EncodeURL(); + const config = useURLEncoder(); const shareButton = useMemo(() => { const qs = isSicpEditor ? Links.playground + '#' + props.initialEditorValueHash : queryString; @@ -733,8 +827,7 @@ const Playground: React.FC = props => { key="share" /> ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dispatch, isSicpEditor, props.initialEditorValueHash, queryString, shortURL]); + }, [dispatch, isSicpEditor, props.initialEditorValueHash, queryString, shortURL, config]); const toggleFolderModeButton = useMemo(() => { return ( From 6e7dcc4d0c81f80611769a3bbb28ac45a3b87943 Mon Sep 17 00:00:00 2001 From: Rachel Date: Sat, 13 Apr 2024 16:15:41 +0800 Subject: [PATCH 14/18] remove decoder oop and fix bugs --- .../controlBar/ControlBarShareButton.tsx | 10 +++--- .../{ShareLinkState.tsx => ShareLinkState.ts} | 4 +-- .../playground/shareLinks/decoder/Decoder.ts | 2 +- .../decoder/delegates/DecoderDelegate.ts | 2 +- .../decoder/delegates/JsonDecoderDelegate.ts | 2 +- .../delegates/UrlParamsDecoderDelegate.ts | 2 +- .../playground/shareLinks/encoder/Encoder.ts | 25 -------------- .../encoder/{URLEncoder.tsx => Encoder.tsx} | 16 ++++----- .../encoder/delegates/EncoderDelegate.tsx | 7 ---- .../encoder/delegates/JsonEncoderDelegate.tsx | 10 ------ .../delegates/UrlParamsEncoderDelegate.ts | 12 ------- src/pages/playground/Playground.tsx | 34 ++++++++++++------- yarn.lock | 5 --- 13 files changed, 38 insertions(+), 93 deletions(-) rename src/features/playground/shareLinks/{ShareLinkState.tsx => ShareLinkState.ts} (83%) delete mode 100644 src/features/playground/shareLinks/encoder/Encoder.ts rename src/features/playground/shareLinks/encoder/{URLEncoder.tsx => Encoder.tsx} (82%) delete mode 100644 src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.tsx delete mode 100644 src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.tsx delete mode 100644 src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts diff --git a/src/commons/controlBar/ControlBarShareButton.tsx b/src/commons/controlBar/ControlBarShareButton.tsx index 7a8d33f62f..d3f1df3168 100644 --- a/src/commons/controlBar/ControlBarShareButton.tsx +++ b/src/commons/controlBar/ControlBarShareButton.tsx @@ -22,7 +22,7 @@ type StateProps = { shortURL?: string; key: string; isSicp?: boolean; - programConfig: Partial; + programConfig: ShareLinkState; }; type State = { @@ -39,6 +39,7 @@ export class ControlBarShareButton extends React.PureComponent { if (event.key === 'Enter' && event.ctrlKey) { - // console.log('Ctrl+Enter pressed!'); + // press Ctrl+Enter to generate and copy new share link directly this.setState({ keyword: 'Test' }); this.props.handleShortenURL(this.state.keyword); this.setState({ isLoading: true }); @@ -144,7 +145,6 @@ export class ControlBarShareButton extends React.PureComponent { this.setState({ - // seems like there's no frontend url env variavle, should be replaced by frontend server accordingly + // seems like there's no frontend url env variable, should be replaced by frontend server accordingly keyword: `http://localhost:8000/playground/share/` + resp.uuid }); this.setState({ isLoading: true, isSuccess: true }); }) - .catch(err => showWarningMessage('fail to generate url!' + err)); + .catch(err => showWarningMessage('Fail to generate url! Error: ' + err)); } } diff --git a/src/features/playground/shareLinks/ShareLinkState.tsx b/src/features/playground/shareLinks/ShareLinkState.ts similarity index 83% rename from src/features/playground/shareLinks/ShareLinkState.tsx rename to src/features/playground/shareLinks/ShareLinkState.ts index 70e476d99e..1e6142af97 100644 --- a/src/features/playground/shareLinks/ShareLinkState.tsx +++ b/src/features/playground/shareLinks/ShareLinkState.ts @@ -1,4 +1,4 @@ -type ShareLinkState = { +type ShareLinkState = Partial<{ isFolder: string; tabs: string; tabIdx: string; @@ -8,6 +8,6 @@ type ShareLinkState = { exec: string; files: string; prgrm: string; -}; +}>; export default ShareLinkState; diff --git a/src/features/playground/shareLinks/decoder/Decoder.ts b/src/features/playground/shareLinks/decoder/Decoder.ts index b7831257f2..b4cf21e311 100644 --- a/src/features/playground/shareLinks/decoder/Decoder.ts +++ b/src/features/playground/shareLinks/decoder/Decoder.ts @@ -11,7 +11,7 @@ class ShareLinkStateDecoder { this.encodedString = encodedString; } - decodeWith(decoderDelegate: DecoderDelegate): Partial { + decodeWith(decoderDelegate: DecoderDelegate): ShareLinkState { return decoderDelegate.decode(this.encodedString); } } diff --git a/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts b/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts index 37dafed84a..722a829e78 100644 --- a/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts +++ b/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts @@ -1,7 +1,7 @@ import ShareLinkState from '../../ShareLinkState'; interface DecoderDelegate { - decode(str: string): Partial; + decode(str: string): ShareLinkState; } export default DecoderDelegate; diff --git a/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts b/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts index c3d0375b90..24c021ca67 100644 --- a/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts +++ b/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts @@ -2,7 +2,7 @@ import ShareLinkState from '../../ShareLinkState'; import DecoderDelegate from './DecoderDelegate'; class JsonDecoderDelegate implements DecoderDelegate { - decode(str: string): Partial { + decode(str: string): ShareLinkState { const jsonObject = JSON.parse(str); return jsonObject.data; } diff --git a/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts b/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts index 3d7e41992c..02641b17c1 100644 --- a/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts +++ b/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts @@ -4,7 +4,7 @@ import ShareLinkState from '../../ShareLinkState'; import DecoderDelegate from './DecoderDelegate'; class UrlParamsDecoderDelegate implements DecoderDelegate { - decode(str: string): Partial { + decode(str: string): ShareLinkState { const qs: Partial = parseQuery(str); return { chap: qs.chap, diff --git a/src/features/playground/shareLinks/encoder/Encoder.ts b/src/features/playground/shareLinks/encoder/Encoder.ts deleted file mode 100644 index 70f4506921..0000000000 --- a/src/features/playground/shareLinks/encoder/Encoder.ts +++ /dev/null @@ -1,25 +0,0 @@ -import ShareLinkState from '../ShareLinkState'; -import EncoderDelegate from './delegates/EncoderDelegate'; - -/** - * Creates a snapshot of the current ShareLinkState during instantiation. - * Use `encodeWith(encoderDelegate)` to output an encoding of this state snapshot. - */ -class ShareLinkStateEncoder { - state: Partial; - - constructor() { - this.state = this.getState(); - } - - encodeWith(encoderDelegate: EncoderDelegate): string { - return encoderDelegate.encode(this.state); - } - - private getState(): Partial { - // TODO: Implement - return {}; - } -} - -export default ShareLinkStateEncoder; diff --git a/src/features/playground/shareLinks/encoder/URLEncoder.tsx b/src/features/playground/shareLinks/encoder/Encoder.tsx similarity index 82% rename from src/features/playground/shareLinks/encoder/URLEncoder.tsx rename to src/features/playground/shareLinks/encoder/Encoder.tsx index e422a78e36..9a901295db 100644 --- a/src/features/playground/shareLinks/encoder/URLEncoder.tsx +++ b/src/features/playground/shareLinks/encoder/Encoder.tsx @@ -8,7 +8,7 @@ import { EditorTabState } from 'src/commons/workspace/WorkspaceTypes'; import ShareLinkState from '../ShareLinkState'; -export const useURLEncoder = () => { +export const useUrlEncoder = () => { const isFolderModeEnabled = useTypedSelector( state => state.workspaces.playground.isFolderModeEnabled ); @@ -23,11 +23,11 @@ export const useURLEncoder = () => { const chapter = useTypedSelector(state => state.workspaces.playground.context.chapter); const variant = useTypedSelector(state => state.workspaces.playground.context.variant); const execTime = useTypedSelector(state => state.workspaces.playground.execTime); - const fileSystem = useGetFileSystem(); + const files = useGetFile(); - const result: Partial = { + const result: ShareLinkState = { isFolder: isFolderModeEnabled.toString(), - files: useGetFile(fileSystem).toString(), + files: files.toString(), tabs: editorTabFilePaths.map(compressToEncodedURIComponent)[0], tabIdx: activeEditorTabIndex?.toString(), chap: chapter.toString(), @@ -39,14 +39,10 @@ export const useURLEncoder = () => { return result; }; -const useGetFileSystem = () => { +const useGetFile = () => { const fileSystem = useTypedSelector(state => state.fileSystem.inBrowserFileSystem); - return fileSystem as FSModule; -}; - -const useGetFile = (fileSystem: FSModule) => { const [files, setFiles] = useState>({}); - retrieveFilesInWorkspaceAsRecord('playground', fileSystem).then(result => { + retrieveFilesInWorkspaceAsRecord('playground', fileSystem as FSModule).then(result => { setFiles(result); }); return compressToEncodedURIComponent(qs.stringify(files)); diff --git a/src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.tsx b/src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.tsx deleted file mode 100644 index 9027f359ed..0000000000 --- a/src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import ShareLinkState from '../../ShareLinkState'; - -interface EncoderDelegate { - encode(state: Partial): string; -} - -export default EncoderDelegate; diff --git a/src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.tsx b/src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.tsx deleted file mode 100644 index 936d9e7565..0000000000 --- a/src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import ShareLinkState from '../../ShareLinkState'; -import EncoderDelegate from './EncoderDelegate'; - -class JsonEncoderDelegate implements EncoderDelegate { - encode(state: Partial): string { - return JSON.stringify(state); - } -} - -export default JsonEncoderDelegate; diff --git a/src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts b/src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts deleted file mode 100644 index e6f90c6747..0000000000 --- a/src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts +++ /dev/null @@ -1,12 +0,0 @@ -import qs from 'query-string'; - -import ShareLinkState from '../../ShareLinkState'; -import EncoderDelegate from './EncoderDelegate'; - -class UrlParamsEncoderDelegate implements EncoderDelegate { - encode(state: ShareLinkState): string { - return qs.stringify(state); - } -} - -export default UrlParamsEncoderDelegate; diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 16bb54902e..8e74284e38 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -90,7 +90,7 @@ import { import ShareLinkStateDecoder from 'src/features/playground/shareLinks/decoder/Decoder'; import JsonDecoderDelegate from 'src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate'; import UrlParamsDecoderDelegate from 'src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate'; -import { useURLEncoder } from 'src/features/playground/shareLinks/encoder/URLEncoder'; +import { useUrlEncoder } from 'src/features/playground/shareLinks/encoder/Encoder'; import ShareLinkState from 'src/features/playground/shareLinks/ShareLinkState'; import { @@ -237,7 +237,7 @@ export async function handleHash( } export async function resetConfig( - configObj: Partial, + configObj: ShareLinkState, handlers: { handleChapterSelect: (chapter: Chapter, variant: Variant) => void; handleChangeExecTime: (execTime: number) => void; @@ -330,9 +330,9 @@ const Playground: React.FC = props => { const { isMobileBreakpoint } = useResponsive(); const [deviceSecret, setDeviceSecret] = useState(); + const location = useLocation(); const navigate = useNavigate(); const store = useStore(); - const location = useLocation(); const searchParams = new URLSearchParams(location.search); const shouldAddDevice = searchParams.get('add_device'); @@ -440,19 +440,17 @@ const Playground: React.FC = props => { }, [editorSessionId]); const hash = isSicpEditor ? props.initialEditorValueHash : location.hash; - const { uuid } = useParams(); + const { uuid } = useParams(); - useEffect(() => { - const handleURL = (uuid: string | undefined) => { + const handleURL = useCallback( + (uuid: string | undefined) => { if (uuid !== undefined) { fetch(`${Constants.backendUrl}/api/shared_programs/${uuid}`) .then(response => response.json()) .then(resp => { - // console.log("resp", resp) - const res: Partial = new ShareLinkStateDecoder(resp).decodeWith( + const res: ShareLinkState = new ShareLinkStateDecoder(resp).decodeWith( new JsonDecoderDelegate() ); - // console.log('decode', res); resetConfig( res, { handleChangeExecTime, handleChapterSelect }, @@ -473,8 +471,18 @@ const Playground: React.FC = props => { fileSystem ); } - }; + }, + [ + dispatch, + fileSystem, + handleChangeExecTime, + handleChapterSelect, + location.hash, + workspaceLocation + ] + ); + useEffect(() => { if (!hash && uuid === undefined) { // If not a accessing via shared link, use the Source chapter and variant in the current course if (courseSourceChapter && courseSourceVariant) { @@ -497,13 +505,13 @@ const Playground: React.FC = props => { dispatch, fileSystem, hash, - location.hash, uuid, courseSourceChapter, courseSourceVariant, workspaceLocation, handleChapterSelect, - handleChangeExecTime + handleChangeExecTime, + handleURL ]); /** @@ -811,7 +819,7 @@ const Playground: React.FC = props => { ] ); - const config = useURLEncoder(); + const config = useUrlEncoder(); const shareButton = useMemo(() => { const qs = isSicpEditor ? Links.playground + '#' + props.initialEditorValueHash : queryString; diff --git a/yarn.lock b/yarn.lock index 1feb31987f..a1fe05c23e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11148,11 +11148,6 @@ react-fast-compare@^3.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== -react-hotkeys-hook@^4.4.4: - version "4.4.4" - resolved "https://registry.npmmirror.com/react-hotkeys-hook/-/react-hotkeys-hook-4.4.4.tgz#5f055f39113218fe5e23f8723db68ccf99d155ab" - integrity sha512-wzZmqb/Obr0ds9Myc1sIFPJ52GA/Eeg/vXBWV0HA1LvHlVAW5Va3KB0q6EZNlNSHQWscWZ2K8+6w0GYSie2o7A== - react-hotkeys@^2.0.0: version "2.0.0" resolved "https://registry.npmmirror.com/react-hotkeys/-/react-hotkeys-2.0.0.tgz#a7719c7340cbba888b0e9184f806a9ec0ac2c53f" From cc412f0c999edda9c551c9e061ff6cdaa1f00491 Mon Sep 17 00:00:00 2001 From: Rachel Date: Sat, 13 Apr 2024 20:27:32 +0800 Subject: [PATCH 15/18] change request method and fix bugs --- .../controlBar/ControlBarShareButton.tsx | 54 +++++++++++------ src/pages/playground/Playground.tsx | 59 ++++++++++++------- 2 files changed, 73 insertions(+), 40 deletions(-) diff --git a/src/commons/controlBar/ControlBarShareButton.tsx b/src/commons/controlBar/ControlBarShareButton.tsx index d3f1df3168..7467851ea0 100644 --- a/src/commons/controlBar/ControlBarShareButton.tsx +++ b/src/commons/controlBar/ControlBarShareButton.tsx @@ -8,6 +8,8 @@ import ShareLinkState from 'src/features/playground/shareLinks/ShareLinkState'; import ControlButton from '../ControlButton'; import Constants from '../utils/Constants'; import { showWarningMessage } from '../utils/notifications/NotificationsHelper'; +import { request } from '../utils/RequestHelper'; +import { RemoveLast } from '../utils/TypeHelper'; type ControlBarShareButtonProps = DispatchProps & StateProps; @@ -23,6 +25,7 @@ type StateProps = { key: string; isSicp?: boolean; programConfig: ShareLinkState; + token: Tokens; }; type State = { @@ -31,6 +34,20 @@ type State = { isSuccess: boolean; }; +type ShareLinkRequestHelperParams = RemoveLast>; + +export type Tokens = { + accessToken: string | undefined; + refreshToken: string | undefined; +}; + +export const requestToShareProgram = async ( + ...[path, method, opts]: ShareLinkRequestHelperParams +) => { + const resp = await request(path, method, opts); + return resp; +}; + export class ControlBarShareButton extends React.PureComponent { private shareInputElem: React.RefObject; @@ -96,7 +113,7 @@ export class ControlBarShareButton extends React.PureComponent this.fetchUUID(this.props.token)} />
) : ( @@ -159,30 +176,29 @@ export class ControlBarShareButton extends React.PureComponent { + const resp = await requestToShareProgram(`shared_programs`, 'POST', { + body: requestBody, + ...tokens + }); + if (!resp) { + return showWarningMessage('Fail to generate url!'); } + const respJson = await resp.json(); + this.setState({ + keyword: `${window.location.host}/playground/share/` + respJson.uuid + }); + this.setState({ isLoading: true, isSuccess: true }); + return; }; - fetch(`${Constants.backendUrl}/api/shared_programs`, fetchOpts) - .then(res => { - return res.json(); - }) - .then(resp => { - this.setState({ - // seems like there's no frontend url env variable, should be replaced by frontend server accordingly - keyword: `http://localhost:8000/playground/share/` + resp.uuid - }); - this.setState({ isLoading: true, isSuccess: true }); - }) - .catch(err => showWarningMessage('Fail to generate url! Error: ' + err)); + + getProgramUrl(); } } diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 8e74284e38..442f4f52d0 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -35,6 +35,7 @@ import makeHtmlDisplayTabFrom from 'src/commons/sideContent/content/SideContentH import { changeSideContentHeight } from 'src/commons/sideContent/SideContentActions'; import { useSideContent } from 'src/commons/sideContent/SideContentHelper'; import { useResponsive, useTypedSelector } from 'src/commons/utils/Hooks'; +import { showWarningMessage } from 'src/commons/utils/notifications/NotificationsHelper'; import { convertParamToBoolean, convertParamToInt } from 'src/commons/utils/ParamParseHelper'; import { IParsedQuery, parseQuery } from 'src/commons/utils/QueryHelper'; import { @@ -109,7 +110,10 @@ import { ControlBarEvalButton } from '../../commons/controlBar/ControlBarEvalBut import { ControlBarExecutionTime } from '../../commons/controlBar/ControlBarExecutionTime'; import { ControlBarGoogleDriveButtons } from '../../commons/controlBar/ControlBarGoogleDriveButtons'; import { ControlBarSessionButtons } from '../../commons/controlBar/ControlBarSessionButton'; -import { ControlBarShareButton } from '../../commons/controlBar/ControlBarShareButton'; +import { + ControlBarShareButton, + requestToShareProgram +} from '../../commons/controlBar/ControlBarShareButton'; import { ControlBarStepLimit } from '../../commons/controlBar/ControlBarStepLimit'; import { ControlBarToggleFolderModeButton } from '../../commons/controlBar/ControlBarToggleFolderModeButton'; import { ControlBarGitHubButtons } from '../../commons/controlBar/github/ControlBarGitHubButtons'; @@ -440,25 +444,34 @@ const Playground: React.FC = props => { }, [editorSessionId]); const hash = isSicpEditor ? props.initialEditorValueHash : location.hash; - const { uuid } = useParams(); + const { uuid } = useParams<{ uuid: string }>(); + const config = useUrlEncoder(); + const tokens = useTypedSelector((state: OverallState) => ({ + accessToken: state.session.accessToken, + refreshToken: state.session.refreshToken + })); const handleURL = useCallback( - (uuid: string | undefined) => { + async (uuid: string | undefined) => { if (uuid !== undefined) { - fetch(`${Constants.backendUrl}/api/shared_programs/${uuid}`) - .then(response => response.json()) - .then(resp => { - const res: ShareLinkState = new ShareLinkStateDecoder(resp).decodeWith( - new JsonDecoderDelegate() - ); - resetConfig( - res, - { handleChangeExecTime, handleChapterSelect }, - workspaceLocation, - dispatch, - fileSystem - ); - }); + const resp = await requestToShareProgram(`shared_programs/${uuid}`, 'GET', { + ...tokens + }); + if (!resp) { + return showWarningMessage('Invalid share program link! '); + } + const respJson = await resp.json(); + const res: ShareLinkState = new ShareLinkStateDecoder(respJson).decodeWith( + new JsonDecoderDelegate() + ); + resetConfig( + res, + { handleChangeExecTime, handleChapterSelect }, + workspaceLocation, + dispatch, + fileSystem + ); + return; } else { const config = new ShareLinkStateDecoder(location.hash).decodeWith( new UrlParamsDecoderDelegate() @@ -470,15 +483,20 @@ const Playground: React.FC = props => { dispatch, fileSystem ); + return; } }, + // disabled eslint here since tokens are checked separately, checking single object cause infinite rerender. + // eslint-disable-next-line [ dispatch, fileSystem, handleChangeExecTime, handleChapterSelect, location.hash, - workspaceLocation + workspaceLocation, + tokens.accessToken, + tokens.refreshToken ] ); @@ -819,8 +837,6 @@ const Playground: React.FC = props => { ] ); - const config = useUrlEncoder(); - const shareButton = useMemo(() => { const qs = isSicpEditor ? Links.playground + '#' + props.initialEditorValueHash : queryString; return ( @@ -830,12 +846,13 @@ const Playground: React.FC = props => { handleUpdateShortURL={s => dispatch(updateShortURL(s))} queryString={qs} programConfig={config} + token={tokens} shortURL={shortURL} isSicp={isSicpEditor} key="share" /> ); - }, [dispatch, isSicpEditor, props.initialEditorValueHash, queryString, shortURL, config]); + }, [dispatch, isSicpEditor, props.initialEditorValueHash, queryString, shortURL, config, tokens]); const toggleFolderModeButton = useMemo(() => { return ( From 5618a03988b65b0f2209e13205586842eac84b7e Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Sat, 13 Apr 2024 20:38:48 +0800 Subject: [PATCH 16/18] Revert lockfile changes --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3580864091..021ca1a104 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11107,7 +11107,7 @@ react-dev-utils@^12.0.1: react-dom@^18.2.0: version "18.2.0" - resolved "https://registry.npmmirror.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== dependencies: loose-envify "^1.1.0" @@ -11150,7 +11150,7 @@ react-fast-compare@^3.0.1: react-hotkeys@^2.0.0: version "2.0.0" - resolved "https://registry.npmmirror.com/react-hotkeys/-/react-hotkeys-2.0.0.tgz#a7719c7340cbba888b0e9184f806a9ec0ac2c53f" + resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-2.0.0.tgz#a7719c7340cbba888b0e9184f806a9ec0ac2c53f" integrity sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q== dependencies: prop-types "^15.6.1" @@ -11447,7 +11447,7 @@ react-uid@^2.3.3: react@^18.2.0: version "18.2.0" - resolved "https://registry.npmmirror.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" + resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== dependencies: loose-envify "^1.1.0" From cd4760e32d5ddb7bb2d45cb0a24b50047e060f68 Mon Sep 17 00:00:00 2001 From: En Rong <53928333+chownces@users.noreply.github.com> Date: Mon, 15 Apr 2024 17:02:05 +0800 Subject: [PATCH 17/18] Shared links frontend refactor (#2937) * Refactor ControlBarShareButton to functional react * Update hotkeys implementation * Re-add playground configuration encoder * Migrate external URL shortener request out of sagas * Implement retrieval of playground configuration from backend UUID and reinstate configuration by hash parameters * Update test suite * Mock BrowserFS in nodejs test environment * Fix mock file error * Update hotkeys binding * Update usage of uuid decoder * Update tests --- .../application/types/ShareLinkTypes.ts | 3 + .../controlBar/ControlBarShareButton.tsx | 322 +++++------- src/commons/mocks/RequestMock.ts | 31 ++ src/commons/sagas/PlaygroundSaga.ts | 132 ++--- src/commons/sagas/RequestsSaga.ts | 48 ++ src/commons/sagas/__tests__/PlaygroundSaga.ts | 483 +++--------------- src/commons/sagas/__tests__/RequestsSaga.ts | 64 +++ src/features/playground/PlaygroundActions.ts | 18 +- src/features/playground/PlaygroundReducer.ts | 14 +- src/features/playground/PlaygroundTypes.ts | 6 - .../playground/__tests__/PlaygroundActions.ts | 19 - .../playground/__tests__/PlaygroundReducer.ts | 14 - .../playground/shareLinks/ShareLinkState.ts | 29 +- .../playground/shareLinks/decoder/Decoder.ts | 39 +- .../decoder/delegates/DecoderDelegate.ts | 4 +- .../decoder/delegates/JsonDecoderDelegate.ts | 7 +- .../delegates/UrlParamsDecoderDelegate.ts | 14 +- .../playground/shareLinks/encoder/Encoder.ts | 28 + .../encoder/{Encoder.tsx => EncoderHooks.ts} | 25 +- .../encoder/delegates/EncoderDelegate.ts | 7 + .../encoder/delegates/JsonEncoderDelegate.ts | 10 + .../delegates/UrlParamsEncoderDelegate.ts | 12 + src/pages/playground/Playground.tsx | 278 ++-------- src/pages/playground/__tests__/Playground.tsx | 56 +- .../__snapshots__/Playground.tsx.snap | 18 +- src/routes/routerConfig.tsx | 9 +- 26 files changed, 614 insertions(+), 1076 deletions(-) create mode 100644 src/commons/application/types/ShareLinkTypes.ts create mode 100644 src/commons/mocks/RequestMock.ts create mode 100644 src/commons/sagas/__tests__/RequestsSaga.ts delete mode 100644 src/features/playground/__tests__/PlaygroundActions.ts delete mode 100644 src/features/playground/__tests__/PlaygroundReducer.ts create mode 100644 src/features/playground/shareLinks/encoder/Encoder.ts rename src/features/playground/shareLinks/encoder/{Encoder.tsx => EncoderHooks.ts} (73%) create mode 100644 src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.ts create mode 100644 src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.ts create mode 100644 src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts diff --git a/src/commons/application/types/ShareLinkTypes.ts b/src/commons/application/types/ShareLinkTypes.ts new file mode 100644 index 0000000000..7ce4c6c075 --- /dev/null +++ b/src/commons/application/types/ShareLinkTypes.ts @@ -0,0 +1,3 @@ +export type ShareLinkShortenedUrlResponse = { + shortenedUrl: string; +}; diff --git a/src/commons/controlBar/ControlBarShareButton.tsx b/src/commons/controlBar/ControlBarShareButton.tsx index 46e29a6391..83cf9b47b3 100644 --- a/src/commons/controlBar/ControlBarShareButton.tsx +++ b/src/commons/controlBar/ControlBarShareButton.tsx @@ -1,211 +1,143 @@ -import { - NonIdealState, - Popover, - Position, - Spinner, - SpinnerSize, - Text, - Tooltip -} from '@blueprintjs/core'; +import { NonIdealState, Popover, Position, Spinner, SpinnerSize, Tooltip } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import React from 'react'; +import { useHotkeys } from '@mantine/hooks'; +import React, { useRef, useState } from 'react'; import * as CopyToClipboard from 'react-copy-to-clipboard'; -import ShareLinkState from 'src/features/playground/shareLinks/ShareLinkState'; +import JsonEncoderDelegate from 'src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate'; +import UrlParamsEncoderDelegate from 'src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate'; +import { usePlaygroundConfigurationEncoder } from 'src/features/playground/shareLinks/encoder/EncoderHooks'; import ControlButton from '../ControlButton'; -import Constants from '../utils/Constants'; -import { showWarningMessage } from '../utils/notifications/NotificationsHelper'; -import { request } from '../utils/RequestHelper'; -import { RemoveLast } from '../utils/TypeHelper'; +import { externalUrlShortenerRequest } from '../sagas/PlaygroundSaga'; +import { postSharedProgram } from '../sagas/RequestsSaga'; +import Constants, { Links } from '../utils/Constants'; +import { showSuccessMessage, showWarningMessage } from '../utils/notifications/NotificationsHelper'; -type ControlBarShareButtonProps = DispatchProps & StateProps; - -type DispatchProps = { - handleGenerateLz?: () => void; - handleShortenURL: (s: string) => void; - handleUpdateShortURL: (s: string) => void; -}; - -type StateProps = { - queryString?: string; - shortURL?: string; - key: string; +type ControlBarShareButtonProps = { isSicp?: boolean; - programConfig: ShareLinkState; - token: Tokens; -}; - -type State = { - keyword: string; - isLoading: boolean; - isSuccess: boolean; }; -type ShareLinkRequestHelperParams = RemoveLast>; - -export type Tokens = { - accessToken: string | undefined; - refreshToken: string | undefined; -}; - -export const requestToShareProgram = async ( - ...[path, method, opts]: ShareLinkRequestHelperParams -) => { - const resp = await request(path, method, opts); - return resp; -}; +/** + * Generates the share link for programs in the Playground. + * + * For playground-only (no backend) deployments: + * - Generate a URL with playground configuration encoded as hash parameters + * - URL sent to external URL shortener service + * - Shortened URL displayed to user + * - (note: SICP CodeSnippets use these hash parameters) + * + * For 'with backend' deployments: + * - Send the playground configuration to the backend + * - Backend stores configuration and assigns a UUID + * - Backend pings the external URL shortener service with UUID link + * - Shortened URL returned to Frontend and displayed to user + */ +export const ControlBarShareButton: React.FC = props => { + const shareInputElem = useRef(null); + const [isLoading, setIsLoading] = useState(false); + const [shortenedUrl, setShortenedUrl] = useState(''); + const [customStringKeyword, setCustomStringKeyword] = useState(''); + const playgroundConfiguration = usePlaygroundConfigurationEncoder(); + + const generateLinkBackend = () => { + setIsLoading(true); + + customStringKeyword; + + const configuration = playgroundConfiguration.encodeWith(new JsonEncoderDelegate()); + + return postSharedProgram(configuration) + .then(({ shortenedUrl }) => setShortenedUrl(shortenedUrl)) + .catch(err => showWarningMessage(err.toString())) + .finally(() => setIsLoading(false)); + }; -export class ControlBarShareButton extends React.PureComponent { - private shareInputElem: React.RefObject; - - constructor(props: ControlBarShareButtonProps) { - super(props); - this.selectShareInputText = this.selectShareInputText.bind(this); - this.handleChange = this.handleChange.bind(this); - this.toggleButton = this.toggleButton.bind(this); - this.fetchUUID = this.fetchUUID.bind(this); - this.shareInputElem = React.createRef(); - this.state = { keyword: '', isLoading: false, isSuccess: false }; - } - - componentDidMount() { - document.addEventListener('keydown', this.handleKeyDown); - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.handleKeyDown); - } - - handleKeyDown = (event: any) => { - if (event.key === 'Enter' && event.ctrlKey) { - // press Ctrl+Enter to generate and copy new share link directly - this.setState({ keyword: 'Test' }); - this.props.handleShortenURL(this.state.keyword); - this.setState({ isLoading: true }); - if (this.props.shortURL || this.props.isSicp) { - this.selectShareInputText(); - console.log('link created.'); - } - } + const generateLinkPlaygroundOnly = () => { + const hash = playgroundConfiguration.encodeWith(new UrlParamsEncoderDelegate()); + setIsLoading(true); + + return externalUrlShortenerRequest(hash, customStringKeyword) + .then(({ shortenedUrl, message }) => { + setShortenedUrl(shortenedUrl); + if (message) showSuccessMessage(message); + }) + .catch(err => showWarningMessage(err.toString())) + .finally(() => setIsLoading(false)); }; - public render() { - const shareButtonPopoverContent = - this.props.queryString === undefined ? ( - - Share your programs! Type something into the editor (left), then click on this button - again. - - ) : this.props.isSicp ? ( -
- - - - - - -
- ) : ( - <> - {!this.state.isSuccess || this.props.shortURL === 'ERROR' ? ( - !this.state.isLoading || this.props.shortURL === 'ERROR' ? ( -
- {Constants.urlShortenerBase}  - - this.fetchUUID(this.props.token)} - /> -
- ) : ( -
- } - /> -
- ) - ) : ( -
- - - - - - -
- )} - - ); - - return ( - - - this.toggleButton()} /> - - - ); - } - - public componentDidUpdate(prevProps: ControlBarShareButtonProps) { - if (this.props.shortURL !== prevProps.shortURL) { - this.setState({ keyword: '', isLoading: false }); - } - } + const generateLinkSicp = () => { + const hash = playgroundConfiguration.encodeWith(new UrlParamsEncoderDelegate()); + const shortenedUrl = `${Links.playground}#${hash}`; + setShortenedUrl(shortenedUrl); + }; - private toggleButton() { - if (this.props.handleGenerateLz) { - this.props.handleGenerateLz(); - } + const generateLink = props.isSicp + ? generateLinkSicp + : Constants.playgroundOnly + ? generateLinkPlaygroundOnly + : generateLinkBackend; - // reset state - this.setState({ keyword: '', isLoading: false, isSuccess: false }); - } + useHotkeys([['ctrl+e', generateLink]], []); - private handleChange(event: React.FormEvent) { - this.setState({ keyword: event.currentTarget.value }); - } + const handleCustomStringChange = (event: React.FormEvent) => { + setCustomStringKeyword(event.currentTarget.value); + }; - private selectShareInputText() { - if (this.shareInputElem.current !== null) { - this.shareInputElem.current.focus(); - this.shareInputElem.current.select(); + // For visual effect of highlighting the text field on copy + const selectShareInputText = () => { + if (shareInputElem.current !== null) { + shareInputElem.current.focus(); + shareInputElem.current.select(); } - } - - private fetchUUID(tokens: Tokens) { - const requestBody = { - shared_program: { - data: this.props.programConfig - } - }; - - const getProgramUrl = async () => { - const resp = await requestToShareProgram(`shared_programs`, 'POST', { - body: requestBody, - ...tokens - }); - if (!resp) { - return showWarningMessage('Fail to generate url!'); - } - const respJson = await resp.json(); - this.setState({ - keyword: `${window.location.host}/playground/share/` + respJson.uuid - }); - this.setState({ isLoading: true, isSuccess: true }); - return; - }; - - getProgramUrl(); - } -} + }; + + const generateLinkPopoverContent = ( +
+ {Constants.urlShortenerBase}  + + +
+ ); + + const generatingLinkPopoverContent = ( +
+ } + /> +
+ ); + + const copyLinkPopoverContent = ( +
+ + + + + + +
+ ); + + const shareButtonPopoverContent = isLoading + ? generatingLinkPopoverContent + : shortenedUrl + ? copyLinkPopoverContent + : generateLinkPopoverContent; + + return ( + + + + + + ); +}; diff --git a/src/commons/mocks/RequestMock.ts b/src/commons/mocks/RequestMock.ts new file mode 100644 index 0000000000..ab2e2dd160 --- /dev/null +++ b/src/commons/mocks/RequestMock.ts @@ -0,0 +1,31 @@ +import * as RequestsSaga from '../utils/RequestHelper'; + +export class RequestMock { + static noResponse(): typeof RequestsSaga.request { + return () => Promise.resolve(null); + } + + static nonOk(textMockFn: jest.Mock = jest.fn()): typeof RequestsSaga.request { + const resp = { + text: textMockFn, + ok: false + } as unknown as Response; + + return () => Promise.resolve(resp); + } + + static success( + jsonMockFn: jest.Mock = jest.fn(), + textMockFn: jest.Mock = jest.fn() + ): typeof RequestsSaga.request { + const resp = { + json: jsonMockFn, + text: textMockFn, + ok: true + } as unknown as Response; + + return () => Promise.resolve(resp); + } +} + +export const mockTokens = { accessToken: 'access', refreshToken: 'refresherOrb' }; diff --git a/src/commons/sagas/PlaygroundSaga.ts b/src/commons/sagas/PlaygroundSaga.ts index e7acf4fe01..5b165d59fc 100644 --- a/src/commons/sagas/PlaygroundSaga.ts +++ b/src/commons/sagas/PlaygroundSaga.ts @@ -1,25 +1,13 @@ -import { FSModule } from 'browserfs/dist/node/core/FS'; -import { Chapter, Variant } from 'js-slang/dist/types'; -import { compressToEncodedURIComponent } from 'lz-string'; -import qs from 'query-string'; +import { Chapter } from 'js-slang/dist/types'; import { SagaIterator } from 'redux-saga'; -import { call, delay, put, race, select } from 'redux-saga/effects'; +import { call, put, select } from 'redux-saga/effects'; import CseMachine from 'src/features/cseMachine/CseMachine'; import { CseMachine as JavaCseMachine } from 'src/features/cseMachine/java/CseMachine'; -import { - changeQueryString, - shortenURL, - updateShortURL -} from '../../features/playground/PlaygroundActions'; -import { GENERATE_LZ_STRING, SHORTEN_URL } from '../../features/playground/PlaygroundTypes'; import { isSourceLanguage, OverallState } from '../application/ApplicationTypes'; -import { ExternalLibraryName } from '../application/types/ExternalTypes'; -import { retrieveFilesInWorkspaceAsRecord } from '../fileSystem/utils'; import { visitSideContent } from '../sideContent/SideContentActions'; import { SideContentType, VISIT_SIDE_CONTENT } from '../sideContent/SideContentTypes'; import Constants from '../utils/Constants'; -import { showSuccessMessage, showWarningMessage } from '../utils/notifications/NotificationsHelper'; import { clearReplOutput, setEditorHighlightedLines, @@ -29,46 +17,10 @@ import { updateCurrentStep, updateStepsTotal } from '../workspace/WorkspaceActions'; -import { EditorTabState, PlaygroundWorkspaceState } from '../workspace/WorkspaceTypes'; +import { PlaygroundWorkspaceState } from '../workspace/WorkspaceTypes'; import { safeTakeEvery as takeEvery } from './SafeEffects'; export default function* PlaygroundSaga(): SagaIterator { - yield takeEvery(GENERATE_LZ_STRING, updateQueryString); - - yield takeEvery(SHORTEN_URL, function* (action: ReturnType): any { - const queryString = yield select((state: OverallState) => state.playground.queryString); - const keyword = action.payload; - const errorMsg = 'ERROR'; - - let resp, timeout; - - //we catch and move on if there are errors (plus have a timeout in case) - try { - const { result, hasTimedOut } = yield race({ - result: call(shortenURLRequest, queryString, keyword), - hasTimedOut: delay(10000) - }); - - resp = result; - timeout = hasTimedOut; - } catch (_) {} - - if (!resp || timeout) { - yield put(updateShortURL(errorMsg)); - return yield call(showWarningMessage, 'Something went wrong trying to create the link.'); - } - - if (resp.status !== 'success' && !resp.shorturl) { - yield put(updateShortURL(errorMsg)); - return yield call(showWarningMessage, resp.message); - } - - if (resp.status !== 'success') { - yield call(showSuccessMessage, resp.message); - } - yield put(updateShortURL(Constants.urlShortenerBase + resp.url.keyword)); - }); - yield takeEvery( VISIT_SIDE_CONTENT, function* ({ @@ -126,60 +78,30 @@ export default function* PlaygroundSaga(): SagaIterator { ); } -function* updateQueryString() { - const isFolderModeEnabled: boolean = yield select( - (state: OverallState) => state.workspaces.playground.isFolderModeEnabled - ); - const fileSystem: FSModule = yield select( - (state: OverallState) => state.fileSystem.inBrowserFileSystem - ); - const files: Record = yield call( - retrieveFilesInWorkspaceAsRecord, - 'playground', - fileSystem - ); - const editorTabs: EditorTabState[] = yield select( - (state: OverallState) => state.workspaces.playground.editorTabs - ); - const editorTabFilePaths = editorTabs - .map((editorTab: EditorTabState) => editorTab.filePath) - .filter((filePath): filePath is string => filePath !== undefined); - const activeEditorTabIndex: number | null = yield select( - (state: OverallState) => state.workspaces.playground.activeEditorTabIndex - ); - const chapter: Chapter = yield select( - (state: OverallState) => state.workspaces.playground.context.chapter - ); - const variant: Variant = yield select( - (state: OverallState) => state.workspaces.playground.context.variant - ); - const external: ExternalLibraryName = yield select( - (state: OverallState) => state.workspaces.playground.externalLibrary - ); - const execTime: number = yield select( - (state: OverallState) => state.workspaces.playground.execTime - ); - const newQueryString = qs.stringify({ - isFolder: isFolderModeEnabled, - files: compressToEncodedURIComponent(qs.stringify(files)), - tabs: editorTabFilePaths.map(compressToEncodedURIComponent), - tabIdx: activeEditorTabIndex, - chap: chapter, - variant, - ext: external, - exec: execTime - }); - yield put(changeQueryString(newQueryString)); -} - +type UrlShortenerResponse = { + status: string; + code: string; + url: { + keyword: string; + url: string; + title: string; + date: string; + ip: string; + clicks: string; + }; + message: string; + title: string; + shorturl: string; + statusCode: number; +}; /** * Gets short url from microservice * @returns {(Response|null)} Response if successful, otherwise null. */ -export async function shortenURLRequest( +export async function externalUrlShortenerRequest( queryString: string, keyword: string -): Promise { +): Promise<{ shortenedUrl: string; message: string }> { const url = `${window.location.protocol}//${window.location.host}/playground#${queryString}`; const params = { @@ -199,9 +121,15 @@ export async function shortenURLRequest( const resp = await fetch(`${Constants.urlShortenerBase}yourls-api.php`, fetchOpts); if (!resp || !resp.ok) { - return null; + throw new Error('Something went wrong trying to create the link.'); + } + + const res: UrlShortenerResponse = await resp.json(); + if (res.status !== 'success' && !res.shorturl) { + throw new Error(res.message); } - const res = await resp.json(); - return res; + const message = res.status !== 'success' ? res.message : ''; + const shortenedUrl = Constants.urlShortenerBase + res.url.keyword; + return { shortenedUrl, message }; } diff --git a/src/commons/sagas/RequestsSaga.ts b/src/commons/sagas/RequestsSaga.ts index 6c7a1ee68b..02500a6b65 100644 --- a/src/commons/sagas/RequestsSaga.ts +++ b/src/commons/sagas/RequestsSaga.ts @@ -44,6 +44,7 @@ import { UpdateCourseConfiguration, User } from '../application/types/SessionTypes'; +import { ShareLinkShortenedUrlResponse } from '../application/types/ShareLinkTypes'; import { Assessment, AssessmentConfiguration, @@ -1660,6 +1661,53 @@ export async function deleteDevice(device: Pick, tokens?: Tokens): return true; } +/** + * GET /shared_programs/:uuid + */ +export async function getSharedProgram(uuid: string, tokens?: Tokens): Promise { + tokens = fillTokens(tokens); + const resp = await request(`shared_programs/${uuid}`, 'GET', { + ...tokens + }); + + if (!resp) { + throw new Error('Failed to fetch program from shared link!'); + } + + if (!resp.ok) { + throw new Error('Invalid shared link!'); + } + + return resp.text(); +} + +/** + * POST /shared_programs + */ +export async function postSharedProgram( + programConfig: string, + tokens?: Tokens +): Promise { + tokens = fillTokens(tokens); + const resp = await request(`shared_programs`, 'POST', { + body: { + configuration: programConfig + }, + ...tokens + }); + + if (!resp) { + throw new Error('Failed to generate shortened URL!'); + } + + if (!resp.ok) { + const message = await resp.text(); + throw new Error(`Failed to generate shortened URL: ${message}`); + } + + return resp.json(); +} + function fillTokens(tokens?: Tokens): Tokens { tokens = tokens || getTokensFromStore(); if (!tokens) { diff --git a/src/commons/sagas/__tests__/PlaygroundSaga.ts b/src/commons/sagas/__tests__/PlaygroundSaga.ts index 56fa53175c..59b5f5cb0a 100644 --- a/src/commons/sagas/__tests__/PlaygroundSaga.ts +++ b/src/commons/sagas/__tests__/PlaygroundSaga.ts @@ -1,30 +1,9 @@ -import { Chapter, Variant } from 'js-slang/dist/types'; -import { compressToEncodedURIComponent } from 'lz-string'; -import qs from 'query-string'; -import { call } from 'redux-saga/effects'; -import { expectSaga } from 'redux-saga-test-plan'; - -import { updateShortURL } from '../../../features/playground/PlaygroundActions'; -import { SHORTEN_URL } from '../../../features/playground/PlaygroundTypes'; -import { - createDefaultWorkspace, - defaultState, - defaultWorkspaceManager, - getDefaultFilePath, - OverallState -} from '../../application/ApplicationTypes'; -import { ExternalLibraryName } from '../../application/types/ExternalTypes'; +import { RequestMock } from '../../mocks/RequestMock'; import Constants from '../../utils/Constants'; -import { - showSuccessMessage, - showWarningMessage -} from '../../utils/notifications/NotificationsHelper'; -import PlaygroundSaga, { shortenURLRequest } from '../PlaygroundSaga'; +import { externalUrlShortenerRequest } from '../PlaygroundSaga'; describe('Playground saga tests', () => { Constants.urlShortenerBase = 'http://url-shortener.com/'; - const errMsg = 'Something went wrong trying to create the link.'; - const defaultPlaygroundFilePath = getDefaultFilePath('playground'); // This test relies on BrowserFS which works in browser environments and not Node.js. // FIXME: Uncomment this test if BrowserFS adds support for running in Node.js. @@ -62,398 +41,70 @@ describe('Playground saga tests', () => { // .silentRun(); // }); - test('puts updateShortURL with correct params when shorten request is successful', () => { - const dummyFiles: Record = { - [defaultPlaygroundFilePath]: '1 + 1;' - }; - const defaultPlaygroundState = createDefaultWorkspace('playground'); - const dummyState: OverallState = { - ...defaultState, - workspaces: { - ...defaultWorkspaceManager, - playground: { - ...defaultPlaygroundState, - externalLibrary: ExternalLibraryName.NONE, - editorTabs: [ - { - filePath: defaultPlaygroundFilePath, - value: dummyFiles[defaultPlaygroundFilePath], - breakpoints: [], - highlightedLines: [] - } - ], - usingSubst: false, - usingCse: false, - updateCse: true, - currentStep: -1, - stepsTotal: 0, - breakpointSteps: [], - changepointSteps: [] - } - } - }; - const queryString = createQueryString(dummyFiles, dummyState); - const nxState: OverallState = { - ...dummyState, - playground: { - queryString, - ...dummyState.playground - } - }; - - // a fake response that looks like the real one - const mockResp = { - url: { - keyword: 't', - url: 'https://www.google.com', - title: 'Google', - date: '2020-05-21 10:51:59', - ip: '11.11.11.11' - }, - status: 'success', - message: 'https://www.google.com added to database', - title: 'Google', - shorturl: 'http://url-shortener.com/t', - statusCode: 200 - }; - - return expectSaga(PlaygroundSaga) - .withState(nxState) - .dispatch({ - type: SHORTEN_URL, - payload: '' - }) - .provide([[call(shortenURLRequest, queryString, ''), mockResp]]) - .not.call(showWarningMessage, errMsg) - .not.call(showSuccessMessage, mockResp.message) - .put(updateShortURL(mockResp.shorturl)) - .silentRun(); - }); - - test('puts updateShortURL with correct params when shorten request with keyword is successful', () => { - const dummyFiles: Record = { - [defaultPlaygroundFilePath]: '1 + 1;' - }; - const defaultPlaygroundState = createDefaultWorkspace('playground'); - const dummyState: OverallState = { - ...defaultState, - workspaces: { - ...defaultWorkspaceManager, - playground: { - ...defaultPlaygroundState, - externalLibrary: ExternalLibraryName.NONE, - editorTabs: [ - { - filePath: defaultPlaygroundFilePath, - value: dummyFiles[defaultPlaygroundFilePath], - breakpoints: [], - highlightedLines: [] - } - ], - usingSubst: false, - usingCse: false, - updateCse: true, - currentStep: -1, - stepsTotal: 0, - breakpointSteps: [], - changepointSteps: [] - } - } - }; - const queryString = createQueryString(dummyFiles, dummyState); - const nxState: OverallState = { - ...dummyState, - playground: { - queryString, - ...dummyState.playground - } - }; - - // a fake response that looks like the real one - const mockResp = { - url: { - keyword: 't', - url: 'https://www.google.com', - title: 'Google', - date: '2020-05-21 10:51:59', - ip: '11.11.11.11' - }, - status: 'success', - message: 'https://www.google.com added to database', - title: 'Google', - shorturl: 'http://url-shortener.com/t', - statusCode: 200 - }; - - return expectSaga(PlaygroundSaga) - .withState(nxState) - .dispatch({ - type: SHORTEN_URL, - payload: 'tester' - }) - .provide([[call(shortenURLRequest, queryString, 'tester'), mockResp]]) - .not.call(showWarningMessage, errMsg) - .not.call(showSuccessMessage, mockResp.message) - .put(updateShortURL(mockResp.shorturl)) - .silentRun(); - }); - - test('shows warning message when shorten request failed', () => { - const dummyFiles: Record = { - [defaultPlaygroundFilePath]: '1 + 1;' - }; - const defaultPlaygroundState = createDefaultWorkspace('playground'); - const dummyState: OverallState = { - ...defaultState, - workspaces: { - ...defaultWorkspaceManager, - playground: { - ...defaultPlaygroundState, - externalLibrary: ExternalLibraryName.NONE, - editorTabs: [ - { - filePath: defaultPlaygroundFilePath, - value: dummyFiles[defaultPlaygroundFilePath], - breakpoints: [], - highlightedLines: [] - } - ], - usingSubst: false, - usingCse: false, - updateCse: true, - currentStep: -1, - stepsTotal: 0, - breakpointSteps: [], - changepointSteps: [] - } - } - }; - const queryString = createQueryString(dummyFiles, dummyState); - const nxState: OverallState = { - ...dummyState, - playground: { - queryString, - ...dummyState.playground - } - }; - - return expectSaga(PlaygroundSaga) - .withState(nxState) - .dispatch({ - type: SHORTEN_URL, - payload: '' - }) - .provide([[call(shortenURLRequest, queryString, ''), null]]) - .call(showWarningMessage, errMsg) - .put(updateShortURL('ERROR')) - .silentRun(); - }); - - test('shows message and gives url when shorten request returns duplicate error', () => { - const dummyFiles: Record = { - [defaultPlaygroundFilePath]: '1 + 1;' - }; - const defaultPlaygroundState = createDefaultWorkspace('playground'); - const dummyState: OverallState = { - ...defaultState, - workspaces: { - ...defaultWorkspaceManager, - playground: { - ...defaultPlaygroundState, - externalLibrary: ExternalLibraryName.NONE, - editorTabs: [ - { - filePath: defaultPlaygroundFilePath, - value: dummyFiles[defaultPlaygroundFilePath], - breakpoints: [], - highlightedLines: [] - } - ], - usingSubst: false, - usingCse: false, - updateCse: true, - currentStep: -1, - stepsTotal: 0, - breakpointSteps: [], - changepointSteps: [] - } - } - }; - const queryString = createQueryString(dummyFiles, dummyState); - const nxState: OverallState = { - ...dummyState, - playground: { - queryString, - ...dummyState.playground - } - }; - - // a fake response that looks like the real one - const mockResp = { - status: 'fail', - code: 'error:url', - url: { - keyword: 't', - url: 'https://www.google.com', - title: 'Google', - date: '2020-05-21 10:51:59', - ip: '11.11.11.11', - clicks: '0' - }, - message: 'https://www.google.com already exists in database', - title: 'Google', - shorturl: 'http://url-shortener.com/t', - statusCode: 200 - }; - - return expectSaga(PlaygroundSaga) - .withState(nxState) - .dispatch({ - type: SHORTEN_URL, - payload: '' - }) - .provide([[call(shortenURLRequest, queryString, ''), mockResp]]) - .call(showSuccessMessage, mockResp.message) - .not.call(showWarningMessage, errMsg) - .put(updateShortURL(mockResp.shorturl)) - .silentRun(); - }); - - test('shows warning when shorten request returns some error without url', () => { - const dummyFiles: Record = { - [defaultPlaygroundFilePath]: '1 + 1;' - }; - const defaultPlaygroundState = createDefaultWorkspace('playground'); - const dummyState: OverallState = { - ...defaultState, - workspaces: { - ...defaultWorkspaceManager, - playground: { - ...defaultPlaygroundState, - externalLibrary: ExternalLibraryName.NONE, - editorTabs: [ - { - filePath: defaultPlaygroundFilePath, - value: dummyFiles[defaultPlaygroundFilePath], - breakpoints: [], - highlightedLines: [] - } - ], - usingSubst: false, - usingCse: false, - updateCse: true, - currentStep: -1, - stepsTotal: 0, - breakpointSteps: [], - changepointSteps: [] - } - } - }; - const queryString = createQueryString(dummyFiles, dummyState); - const nxState: OverallState = { - ...dummyState, - playground: { - queryString, - ...dummyState.playground - } - }; - - // a fake response that looks like the real one - const mockResp = { - status: 'fail', - code: 'error:keyword', - message: 'Short URL t already exists in database or is reserved', - statusCode: 200 - }; - - return expectSaga(PlaygroundSaga) - .withState(nxState) - .dispatch({ - type: SHORTEN_URL, - payload: '' - }) - .provide([[call(shortenURLRequest, queryString, ''), mockResp]]) - .call(showWarningMessage, mockResp.message) - .put(updateShortURL('ERROR')) - .silentRun(); - }); - - test('returns errMsg when API call timesout', () => { - const dummyFiles: Record = { - [defaultPlaygroundFilePath]: '1 + 1;' - }; - const defaultPlaygroundState = createDefaultWorkspace('playground'); - const dummyState: OverallState = { - ...defaultState, - workspaces: { - ...defaultWorkspaceManager, - playground: { - ...defaultPlaygroundState, - externalLibrary: ExternalLibraryName.NONE, - editorTabs: [ - { - filePath: defaultPlaygroundFilePath, - value: dummyFiles[defaultPlaygroundFilePath], - breakpoints: [], - highlightedLines: [] - } - ], - usingSubst: false, - usingCse: false, - updateCse: true, - currentStep: -1, - stepsTotal: 0, - breakpointSteps: [], - changepointSteps: [] - } - } - }; - const queryString = createQueryString(dummyFiles, dummyState); - const nxState: OverallState = { - ...dummyState, - playground: { - queryString, - ...dummyState.playground - } - }; - - return expectSaga(PlaygroundSaga) - .withState(nxState) - .dispatch({ - type: SHORTEN_URL, - payload: '' - }) - .provide({ - race: () => ({ - result: undefined, - hasTimedOut: true - }) - }) - .call(showWarningMessage, errMsg) - .put(updateShortURL('ERROR')) - .silentRun(); + describe('externalUrlShortenerRequest', () => { + const mockFetch = jest.spyOn(global, 'fetch'); + const mockJsonFn = jest.fn(); + + beforeEach(() => { + mockJsonFn.mockReset(); + }); + + test('200 with success status', async () => { + const keyword = 'abcde'; + mockJsonFn.mockResolvedValue({ + shorturl: 'shorturl', + status: 'success', + url: { keyword } + }); + mockFetch.mockImplementationOnce(RequestMock.success(mockJsonFn) as unknown as typeof fetch); + const result = await externalUrlShortenerRequest('queryString', keyword); + + const shortenedUrl = Constants.urlShortenerBase + keyword; + const message = ''; + expect(result).toStrictEqual({ shortenedUrl, message }); + }); + + test('200 with non-success status (due to duplicate URL), returns message', async () => { + const keyword = 'abcde'; + const message = 'Link already exists in database!'; + mockJsonFn.mockResolvedValue({ + shorturl: 'shorturl', + status: 'fail', + url: { keyword }, + message + }); + mockFetch.mockImplementationOnce(RequestMock.success(mockJsonFn) as unknown as typeof fetch); + const result = await externalUrlShortenerRequest('queryString', keyword); + + const shortenedUrl = Constants.urlShortenerBase + keyword; + expect(result).toStrictEqual({ shortenedUrl, message }); + }); + + test('200 with non-success status and no shorturl', async () => { + const message = 'Unable to generate shortlink'; + mockJsonFn.mockResolvedValue({ + status: 'fail', + message + }); + mockFetch.mockImplementationOnce(RequestMock.success(mockJsonFn) as unknown as typeof fetch); + + await expect(externalUrlShortenerRequest('queryString', 'keyword')).rejects.toThrow(message); + }); + + test('No response', async () => { + mockFetch.mockImplementationOnce(RequestMock.noResponse() as unknown as typeof fetch); + + await expect(externalUrlShortenerRequest('queryString', 'keyword')).rejects.toThrow( + 'Something went wrong trying to create the link.' + ); + }); + + test('Non-ok response', async () => { + mockFetch.mockImplementationOnce(RequestMock.nonOk() as unknown as typeof fetch); + + await expect(externalUrlShortenerRequest('queryString', 'keyword')).rejects.toThrow( + 'Something went wrong trying to create the link.' + ); + }); }); }); - -function createQueryString(files: Record, state: OverallState): string { - const isFolderModeEnabled: boolean = state.workspaces.playground.isFolderModeEnabled; - const editorTabFilePaths: string[] = state.workspaces.playground.editorTabs - .map(editorTab => editorTab.filePath) - .filter((filePath): filePath is string => filePath !== undefined); - const activeEditorTabIndex: number | null = state.workspaces.playground.activeEditorTabIndex; - const chapter: Chapter = state.workspaces.playground.context.chapter; - const variant: Variant = state.workspaces.playground.context.variant; - const external: ExternalLibraryName = state.workspaces.playground.externalLibrary; - const execTime: number = state.workspaces.playground.execTime; - const newQueryString: string = qs.stringify({ - isFolder: isFolderModeEnabled, - files: compressToEncodedURIComponent(qs.stringify(files)), - tabs: editorTabFilePaths.map(compressToEncodedURIComponent), - tabIdx: activeEditorTabIndex, - chap: chapter, - variant, - ext: external, - exec: execTime - }); - return newQueryString; -} diff --git a/src/commons/sagas/__tests__/RequestsSaga.ts b/src/commons/sagas/__tests__/RequestsSaga.ts new file mode 100644 index 0000000000..a3c55800db --- /dev/null +++ b/src/commons/sagas/__tests__/RequestsSaga.ts @@ -0,0 +1,64 @@ +import { mockTokens, RequestMock } from '../../mocks/RequestMock'; +import * as RequestsSaga from '../../utils/RequestHelper'; +import { getSharedProgram, postSharedProgram } from '../RequestsSaga'; + +describe('RequestsSaga tests', () => { + const request = jest.spyOn(RequestsSaga, 'request'); + const mockJsonFn = jest.fn(); + const mockTextFn = jest.fn(); + + beforeEach(() => { + mockJsonFn.mockReset(); + mockTextFn.mockReset(); + }); + + describe('GET /shared_programs/:uuid', () => { + test('Success', async () => { + request.mockImplementationOnce(RequestMock.success(undefined, mockTextFn)); + await getSharedProgram('uuid', mockTokens); + + expect(mockTextFn).toHaveBeenCalledTimes(1); + }); + + test('No response', async () => { + request.mockImplementationOnce(RequestMock.noResponse()); + + await expect(getSharedProgram('uuid', mockTokens)).rejects.toThrow( + 'Failed to fetch program from shared link!' + ); + }); + + test('Non ok', async () => { + request.mockImplementationOnce(RequestMock.nonOk()); + + await expect(getSharedProgram('uuid', mockTokens)).rejects.toThrow('Invalid shared link!'); + }); + }); + + describe('POST /shared_programs', () => { + test('Success', async () => { + request.mockImplementationOnce(RequestMock.success(mockJsonFn)); + await postSharedProgram('programConfiguration', mockTokens); + + expect(mockJsonFn).toHaveBeenCalledTimes(1); + }); + + test('No response', async () => { + request.mockImplementationOnce(RequestMock.noResponse()); + + await expect(postSharedProgram('programConfiguration', mockTokens)).rejects.toThrow( + 'Failed to generate shortened URL!' + ); + }); + + test('Non ok', async () => { + const customMessage = 'custom-message'; + mockTextFn.mockReturnValue(customMessage); + request.mockImplementationOnce(RequestMock.nonOk(mockTextFn)); + + await expect(postSharedProgram('programConfiguration', mockTokens)).rejects.toThrow( + `Failed to generate shortened URL: ${customMessage}` + ); + }); + }); +}); diff --git a/src/features/playground/PlaygroundActions.ts b/src/features/playground/PlaygroundActions.ts index 0c9b42e8e5..48a3e45da9 100644 --- a/src/features/playground/PlaygroundActions.ts +++ b/src/features/playground/PlaygroundActions.ts @@ -3,27 +3,11 @@ import { SALanguage } from 'src/commons/application/ApplicationTypes'; import { PersistenceFile } from '../persistence/PersistenceTypes'; import { - CHANGE_QUERY_STRING, - GENERATE_LZ_STRING, PLAYGROUND_UPDATE_GITHUB_SAVE_INFO, PLAYGROUND_UPDATE_LANGUAGE_CONFIG, - PLAYGROUND_UPDATE_PERSISTENCE_FILE, - SHORTEN_URL, - UPDATE_SHORT_URL + PLAYGROUND_UPDATE_PERSISTENCE_FILE } from './PlaygroundTypes'; -export const generateLzString = createAction(GENERATE_LZ_STRING, () => ({ payload: {} })); - -export const shortenURL = createAction(SHORTEN_URL, (keyword: string) => ({ payload: keyword })); - -export const updateShortURL = createAction(UPDATE_SHORT_URL, (shortURL: string) => ({ - payload: shortURL -})); - -export const changeQueryString = createAction(CHANGE_QUERY_STRING, (queryString: string) => ({ - payload: queryString -})); - export const playgroundUpdatePersistenceFile = createAction( PLAYGROUND_UPDATE_PERSISTENCE_FILE, (file?: PersistenceFile) => ({ payload: file }) diff --git a/src/features/playground/PlaygroundReducer.ts b/src/features/playground/PlaygroundReducer.ts index 29f4677113..379c981678 100644 --- a/src/features/playground/PlaygroundReducer.ts +++ b/src/features/playground/PlaygroundReducer.ts @@ -3,12 +3,10 @@ import { Reducer } from 'redux'; import { defaultPlayground } from '../../commons/application/ApplicationTypes'; import { SourceActionType } from '../../commons/utils/ActionsHelper'; import { - CHANGE_QUERY_STRING, PLAYGROUND_UPDATE_GITHUB_SAVE_INFO, PLAYGROUND_UPDATE_LANGUAGE_CONFIG, PLAYGROUND_UPDATE_PERSISTENCE_FILE, - PlaygroundState, - UPDATE_SHORT_URL + PlaygroundState } from './PlaygroundTypes'; export const PlaygroundReducer: Reducer = ( @@ -16,16 +14,6 @@ export const PlaygroundReducer: Reducer = ( action ) => { switch (action.type) { - case CHANGE_QUERY_STRING: - return { - ...state, - queryString: action.payload - }; - case UPDATE_SHORT_URL: - return { - ...state, - shortURL: action.payload - }; case PLAYGROUND_UPDATE_GITHUB_SAVE_INFO: return { ...state, diff --git a/src/features/playground/PlaygroundTypes.ts b/src/features/playground/PlaygroundTypes.ts index 69655a64b3..b26aa0f4dc 100644 --- a/src/features/playground/PlaygroundTypes.ts +++ b/src/features/playground/PlaygroundTypes.ts @@ -3,17 +3,11 @@ import { SALanguage } from 'src/commons/application/ApplicationTypes'; import { GitHubSaveInfo } from '../github/GitHubTypes'; import { PersistenceFile } from '../persistence/PersistenceTypes'; -export const CHANGE_QUERY_STRING = 'CHANGE_QUERY_STRING'; -export const GENERATE_LZ_STRING = 'GENERATE_LZ_STRING'; -export const SHORTEN_URL = 'SHORTEN_URL'; -export const UPDATE_SHORT_URL = 'UPDATE_SHORT_URL'; export const PLAYGROUND_UPDATE_GITHUB_SAVE_INFO = 'PLAYGROUND_UPDATE_GITHUB_SAVE_INFO'; export const PLAYGROUND_UPDATE_PERSISTENCE_FILE = 'PLAYGROUND_UPDATE_PERSISTENCE_FILE'; export const PLAYGROUND_UPDATE_LANGUAGE_CONFIG = 'PLAYGROUND_UPDATE_LANGUAGE_CONFIG'; export type PlaygroundState = { - readonly queryString?: string; - readonly shortURL?: string; readonly persistenceFile?: PersistenceFile; readonly githubSaveInfo: GitHubSaveInfo; readonly languageConfig: SALanguage; diff --git a/src/features/playground/__tests__/PlaygroundActions.ts b/src/features/playground/__tests__/PlaygroundActions.ts deleted file mode 100644 index de1008a573..0000000000 --- a/src/features/playground/__tests__/PlaygroundActions.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { changeQueryString, generateLzString } from '../PlaygroundActions'; -import { CHANGE_QUERY_STRING, GENERATE_LZ_STRING } from '../PlaygroundTypes'; - -test('generateLzString generates correct action object', () => { - const action = generateLzString(); - expect(action).toEqual({ - type: GENERATE_LZ_STRING, - payload: {} - }); -}); - -test('changeQueryString generates correct action object', () => { - const queryString = 'test-query-string'; - const action = changeQueryString(queryString); - expect(action).toEqual({ - type: CHANGE_QUERY_STRING, - payload: queryString - }); -}); diff --git a/src/features/playground/__tests__/PlaygroundReducer.ts b/src/features/playground/__tests__/PlaygroundReducer.ts deleted file mode 100644 index 65a93c2cfa..0000000000 --- a/src/features/playground/__tests__/PlaygroundReducer.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defaultPlayground } from '../../../commons/application/ApplicationTypes'; -import { PlaygroundReducer } from '../PlaygroundReducer'; -import { CHANGE_QUERY_STRING } from '../PlaygroundTypes'; - -test('CHANGE_QUERY_STRING sets queryString correctly ', () => { - const action = { - type: CHANGE_QUERY_STRING, - payload: 'hello world' - } as const; - expect(PlaygroundReducer(defaultPlayground, action)).toEqual({ - ...defaultPlayground, - queryString: action.payload - }); -}); diff --git a/src/features/playground/shareLinks/ShareLinkState.ts b/src/features/playground/shareLinks/ShareLinkState.ts index 1e6142af97..e717980963 100644 --- a/src/features/playground/shareLinks/ShareLinkState.ts +++ b/src/features/playground/shareLinks/ShareLinkState.ts @@ -1,13 +1,22 @@ -type ShareLinkState = Partial<{ - isFolder: string; - tabs: string; - tabIdx: string; +import { Chapter, Variant } from 'js-slang/dist/types'; + +export type ShareLinkState = { + isFolder: boolean; + files: Record; + tabs: string[]; + tabIdx: number | null; + chap: Chapter; + variant: Variant; + exec: number; +}; + +export type ParsedIntermediateShareLinkState = { + isFolder?: string; + files?: string; + tabs?: string[]; + tabIdx?: string; chap: string; variant: string; - ext: string; exec: string; - files: string; - prgrm: string; -}>; - -export default ShareLinkState; + prgrm?: string; // for backwards compatibility of old hash parameter shared links +}; diff --git a/src/features/playground/shareLinks/decoder/Decoder.ts b/src/features/playground/shareLinks/decoder/Decoder.ts index b4cf21e311..5e4fcf20a4 100644 --- a/src/features/playground/shareLinks/decoder/Decoder.ts +++ b/src/features/playground/shareLinks/decoder/Decoder.ts @@ -1,4 +1,11 @@ -import ShareLinkState from '../ShareLinkState'; +import { Chapter, Variant } from 'js-slang/dist/types'; +import { decompressFromEncodedURIComponent } from 'lz-string'; +import { getDefaultFilePath } from 'src/commons/application/ApplicationTypes'; +import { convertParamToBoolean, convertParamToInt } from 'src/commons/utils/ParamParseHelper'; +import { parseQuery } from 'src/commons/utils/QueryHelper'; +import { WorkspaceLocation } from 'src/commons/workspace/WorkspaceTypes'; + +import { ShareLinkState } from '../ShareLinkState'; import DecoderDelegate from './delegates/DecoderDelegate'; /** @@ -11,8 +18,34 @@ class ShareLinkStateDecoder { this.encodedString = encodedString; } - decodeWith(decoderDelegate: DecoderDelegate): ShareLinkState { - return decoderDelegate.decode(this.encodedString); + decodeWith( + decoderDelegate: DecoderDelegate, + workspaceLocation: WorkspaceLocation + ): ShareLinkState { + const parsedObject = decoderDelegate.decode(this.encodedString); + + // For backward compatibility with old share links - 'prgrm' is no longer used. + const program = + parsedObject.prgrm === undefined ? '' : decompressFromEncodedURIComponent(parsedObject.prgrm); + + // By default, create just the default file. + const defaultFilePath = getDefaultFilePath(workspaceLocation); + const filesObject: Record = + parsedObject.files === undefined + ? { + [defaultFilePath]: program + } + : parseQuery(decompressFromEncodedURIComponent(parsedObject.files)); + + return { + chap: convertParamToInt(parsedObject.chap) ?? Chapter.SOURCE_1, + exec: Math.max(convertParamToInt(parsedObject.exec) || 1000, 1000), + files: filesObject, + isFolder: convertParamToBoolean(parsedObject.isFolder) ?? false, + tabIdx: convertParamToInt(parsedObject.tabIdx) ?? 0, // By default, use the first editor tab. + tabs: parsedObject.tabs?.map(decompressFromEncodedURIComponent) ?? [defaultFilePath], // By default, open a single editor tab containing the default playground file. + variant: parsedObject.variant as Variant + }; } } diff --git a/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts b/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts index 722a829e78..e758ef8e3f 100644 --- a/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts +++ b/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts @@ -1,7 +1,7 @@ -import ShareLinkState from '../../ShareLinkState'; +import { ParsedIntermediateShareLinkState } from '../../ShareLinkState'; interface DecoderDelegate { - decode(str: string): ShareLinkState; + decode(str: string): ParsedIntermediateShareLinkState; } export default DecoderDelegate; diff --git a/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts b/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts index 24c021ca67..04b72dea6f 100644 --- a/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts +++ b/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts @@ -1,10 +1,9 @@ -import ShareLinkState from '../../ShareLinkState'; +import { ParsedIntermediateShareLinkState } from '../../ShareLinkState'; import DecoderDelegate from './DecoderDelegate'; class JsonDecoderDelegate implements DecoderDelegate { - decode(str: string): ShareLinkState { - const jsonObject = JSON.parse(str); - return jsonObject.data; + decode(str: string): ParsedIntermediateShareLinkState { + return JSON.parse(str); } } diff --git a/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts b/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts index 02641b17c1..686fcfc94e 100644 --- a/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts +++ b/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts @@ -1,21 +1,21 @@ -import { IParsedQuery, parseQuery } from 'src/commons/utils/QueryHelper'; +import { parseQuery } from 'src/commons/utils/QueryHelper'; -import ShareLinkState from '../../ShareLinkState'; +import { ParsedIntermediateShareLinkState } from '../../ShareLinkState'; import DecoderDelegate from './DecoderDelegate'; class UrlParamsDecoderDelegate implements DecoderDelegate { - decode(str: string): ShareLinkState { - const qs: Partial = parseQuery(str); + decode(str: string): ParsedIntermediateShareLinkState { + const qs = parseQuery(str); + return { chap: qs.chap, exec: qs.exec, files: qs.files, isFolder: qs.isFolder, tabIdx: qs.tabIdx, - tabs: qs.tabs, + tabs: qs.tabs?.split(','), variant: qs.variant, - prgrm: qs.prgrm, - ext: qs.ext + prgrm: qs.prgrm }; } } diff --git a/src/features/playground/shareLinks/encoder/Encoder.ts b/src/features/playground/shareLinks/encoder/Encoder.ts new file mode 100644 index 0000000000..d7e285593c --- /dev/null +++ b/src/features/playground/shareLinks/encoder/Encoder.ts @@ -0,0 +1,28 @@ +import { compressToEncodedURIComponent } from 'lz-string'; +import qs from 'query-string'; + +import { ParsedIntermediateShareLinkState, ShareLinkState } from '../ShareLinkState'; +import EncoderDelegate from './delegates/EncoderDelegate'; + +class ShareLinkStateEncoder { + state: ShareLinkState; + + constructor(state: ShareLinkState) { + this.state = state; + } + + encodeWith(encoderDelegate: EncoderDelegate): string { + const processedState: ParsedIntermediateShareLinkState = { + isFolder: this.state.isFolder.toString(), + tabIdx: this.state.tabIdx?.toString() ?? '', + chap: this.state.chap.toString(), + variant: this.state.variant, + exec: this.state.exec.toString(), + tabs: this.state.tabs.map(compressToEncodedURIComponent), + files: compressToEncodedURIComponent(qs.stringify(this.state.files)) + }; + return encoderDelegate.encode(processedState); + } +} + +export default ShareLinkStateEncoder; diff --git a/src/features/playground/shareLinks/encoder/Encoder.tsx b/src/features/playground/shareLinks/encoder/EncoderHooks.ts similarity index 73% rename from src/features/playground/shareLinks/encoder/Encoder.tsx rename to src/features/playground/shareLinks/encoder/EncoderHooks.ts index 9a901295db..b1cbc9fc19 100644 --- a/src/features/playground/shareLinks/encoder/Encoder.tsx +++ b/src/features/playground/shareLinks/encoder/EncoderHooks.ts @@ -1,18 +1,16 @@ import { FSModule } from 'browserfs/dist/node/core/FS'; -import { compressToEncodedURIComponent } from 'lz-string'; -import qs from 'query-string'; import { useState } from 'react'; import { retrieveFilesInWorkspaceAsRecord } from 'src/commons/fileSystem/utils'; import { useTypedSelector } from 'src/commons/utils/Hooks'; import { EditorTabState } from 'src/commons/workspace/WorkspaceTypes'; -import ShareLinkState from '../ShareLinkState'; +import { ShareLinkState } from '../ShareLinkState'; +import ShareLinkStateEncoder from './Encoder'; -export const useUrlEncoder = () => { +export const usePlaygroundConfigurationEncoder = (): ShareLinkStateEncoder => { const isFolderModeEnabled = useTypedSelector( state => state.workspaces.playground.isFolderModeEnabled ); - const editorTabs = useTypedSelector(state => state.workspaces.playground.editorTabs); const editorTabFilePaths = editorTabs .map((editorTab: EditorTabState) => editorTab.filePath) @@ -26,17 +24,16 @@ export const useUrlEncoder = () => { const files = useGetFile(); const result: ShareLinkState = { - isFolder: isFolderModeEnabled.toString(), - files: files.toString(), - tabs: editorTabFilePaths.map(compressToEncodedURIComponent)[0], - tabIdx: activeEditorTabIndex?.toString(), - chap: chapter.toString(), + isFolder: isFolderModeEnabled, + files: files, + tabs: editorTabFilePaths, + tabIdx: activeEditorTabIndex, + chap: chapter, variant, - ext: 'NONE', - exec: execTime.toString() + exec: execTime }; - return result; + return new ShareLinkStateEncoder(result); }; const useGetFile = () => { @@ -45,5 +42,5 @@ const useGetFile = () => { retrieveFilesInWorkspaceAsRecord('playground', fileSystem as FSModule).then(result => { setFiles(result); }); - return compressToEncodedURIComponent(qs.stringify(files)); + return files; }; diff --git a/src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.ts b/src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.ts new file mode 100644 index 0000000000..edac77da83 --- /dev/null +++ b/src/features/playground/shareLinks/encoder/delegates/EncoderDelegate.ts @@ -0,0 +1,7 @@ +import { ParsedIntermediateShareLinkState } from '../../ShareLinkState'; + +interface EncoderDelegate { + encode(state: ParsedIntermediateShareLinkState): string; +} + +export default EncoderDelegate; diff --git a/src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.ts b/src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.ts new file mode 100644 index 0000000000..84bfd5b6c1 --- /dev/null +++ b/src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate.ts @@ -0,0 +1,10 @@ +import { ParsedIntermediateShareLinkState } from '../../ShareLinkState'; +import EncoderDelegate from './EncoderDelegate'; + +class JsonEncoderDelegate implements EncoderDelegate { + encode(state: ParsedIntermediateShareLinkState) { + return JSON.stringify(state); + } +} + +export default JsonEncoderDelegate; diff --git a/src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts b/src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts new file mode 100644 index 0000000000..3682c25d3c --- /dev/null +++ b/src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate.ts @@ -0,0 +1,12 @@ +import qs from 'query-string'; + +import { ParsedIntermediateShareLinkState } from '../../ShareLinkState'; +import EncoderDelegate from './EncoderDelegate'; + +class UrlParamsEncoderDelegate implements EncoderDelegate { + encode(state: ParsedIntermediateShareLinkState): string { + return qs.stringify(state); + } +} + +export default UrlParamsEncoderDelegate; diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 12681e7e59..d0b0041376 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -5,7 +5,6 @@ import { FSModule } from 'browserfs/dist/node/core/FS'; import classNames from 'classnames'; import { Chapter, Variant } from 'js-slang/dist/types'; import { isEqual } from 'lodash'; -import { decompressFromEncodedURIComponent } from 'lz-string'; import React, { Dispatch, useCallback, useEffect, useMemo, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; import { useDispatch, useStore } from 'react-redux'; @@ -29,6 +28,7 @@ import { setSharedbConnected } from 'src/commons/collabEditing/CollabEditingActions'; import { overwriteFilesInWorkspace } from 'src/commons/fileSystem/utils'; +import { getSharedProgram } from 'src/commons/sagas/RequestsSaga'; import makeCseMachineTabFrom from 'src/commons/sideContent/content/SideContentCseMachine'; import makeDataVisualizerTabFrom from 'src/commons/sideContent/content/SideContentDataVisualizer'; import makeHtmlDisplayTabFrom from 'src/commons/sideContent/content/SideContentHtmlDisplay'; @@ -36,8 +36,6 @@ import { changeSideContentHeight } from 'src/commons/sideContent/SideContentActi import { useSideContent } from 'src/commons/sideContent/SideContentHelper'; import { useResponsive, useTypedSelector } from 'src/commons/utils/Hooks'; import { showWarningMessage } from 'src/commons/utils/notifications/NotificationsHelper'; -import { convertParamToBoolean, convertParamToInt } from 'src/commons/utils/ParamParseHelper'; -import { IParsedQuery, parseQuery } from 'src/commons/utils/QueryHelper'; import { showFullJSWarningOnUrlLoad, showFulTSWarningOnUrlLoad, @@ -82,20 +80,13 @@ import { persistenceSaveFile, persistenceSaveFileAs } from 'src/features/persistence/PersistenceActions'; -import { - generateLzString, - playgroundConfigLanguage, - shortenURL, - updateShortURL -} from 'src/features/playground/PlaygroundActions'; +import { playgroundConfigLanguage } from 'src/features/playground/PlaygroundActions'; import ShareLinkStateDecoder from 'src/features/playground/shareLinks/decoder/Decoder'; import JsonDecoderDelegate from 'src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate'; import UrlParamsDecoderDelegate from 'src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate'; -import { useUrlEncoder } from 'src/features/playground/shareLinks/encoder/Encoder'; -import ShareLinkState from 'src/features/playground/shareLinks/ShareLinkState'; +import { ShareLinkState } from 'src/features/playground/shareLinks/ShareLinkState'; import { - getDefaultFilePath, getLanguageConfig, isSourceLanguage, OverallState, @@ -110,10 +101,7 @@ import { ControlBarEvalButton } from '../../commons/controlBar/ControlBarEvalBut import { ControlBarExecutionTime } from '../../commons/controlBar/ControlBarExecutionTime'; import { ControlBarGoogleDriveButtons } from '../../commons/controlBar/ControlBarGoogleDriveButtons'; import { ControlBarSessionButtons } from '../../commons/controlBar/ControlBarSessionButton'; -import { - ControlBarShareButton, - requestToShareProgram -} from '../../commons/controlBar/ControlBarShareButton'; +import { ControlBarShareButton } from '../../commons/controlBar/ControlBarShareButton'; import { ControlBarStepLimit } from '../../commons/controlBar/ControlBarStepLimit'; import { ControlBarToggleFolderModeButton } from '../../commons/controlBar/ControlBarToggleFolderModeButton'; import { ControlBarGitHubButtons } from '../../commons/controlBar/github/ControlBarGitHubButtons'; @@ -128,7 +116,7 @@ import MobileWorkspace, { } from '../../commons/mobileWorkspace/MobileWorkspace'; import { SideBarTab } from '../../commons/sideBar/SideBar'; import { SideContentTab, SideContentType } from '../../commons/sideContent/SideContentTypes'; -import Constants, { Links } from '../../commons/utils/Constants'; +import Constants from '../../commons/utils/Constants'; import { generateLanguageIntroduction } from '../../commons/utils/IntroductionHelper'; import Workspace, { WorkspaceProps } from '../../commons/workspace/Workspace'; import { initSession, log } from '../../features/eventLogging'; @@ -155,92 +143,7 @@ export type PlaygroundProps = { const keyMap = { goGreen: 'h u l k' }; -export async function handleHash( - hash: string, - handlers: { - handleChapterSelect: (chapter: Chapter, variant: Variant) => void; - handleChangeExecTime: (execTime: number) => void; - }, - workspaceLocation: WorkspaceLocation, - dispatch: Dispatch, - fileSystem: FSModule | null -) { - // Make the parsed query string object a Partial because we might access keys which are not set. - const qs: Partial = parseQuery(hash); - - const chapter = convertParamToInt(qs.chap) ?? undefined; - if (chapter === Chapter.FULL_JS) { - showFullJSWarningOnUrlLoad(); - } else if (chapter === Chapter.FULL_TS) { - showFulTSWarningOnUrlLoad(); - } else { - if (chapter === Chapter.HTML) { - const continueToHtml = await showHTMLDisclaimer(); - if (!continueToHtml) { - return; - } - } - - // For backward compatibility with old share links - 'prgrm' is no longer used. - const program = qs.prgrm === undefined ? '' : decompressFromEncodedURIComponent(qs.prgrm); - - // By default, create just the default file. - const defaultFilePath = getDefaultFilePath(workspaceLocation); - const files: Record = - qs.files === undefined - ? { - [defaultFilePath]: program - } - : parseQuery(decompressFromEncodedURIComponent(qs.files)); - if (fileSystem !== null) { - await overwriteFilesInWorkspace(workspaceLocation, fileSystem, files); - } - - // BrowserFS does not provide a way of listening to changes in the file system, which makes - // updating the file system view troublesome. To force the file system view to re-render - // (and thus display the updated file system), we first disable Folder mode. - dispatch(setFolderMode(workspaceLocation, false)); - const isFolderModeEnabled = convertParamToBoolean(qs.isFolder) ?? false; - // If Folder mode should be enabled, enabling it after disabling it earlier will cause the - // newly-added files to be shown. Note that this has to take place after the files are - // already added to the file system. - dispatch(setFolderMode(workspaceLocation, isFolderModeEnabled)); - - // By default, open a single editor tab containing the default playground file. - const editorTabFilePaths = qs.tabs?.split(',').map(decompressFromEncodedURIComponent) ?? [ - defaultFilePath - ]; - // Remove all editor tabs before populating with the ones from the query string. - dispatch( - removeEditorTabsForDirectory(workspaceLocation, WORKSPACE_BASE_PATHS[workspaceLocation]) - ); - // Add editor tabs from the query string. - editorTabFilePaths.forEach(filePath => - // Fall back on the empty string if the file contents do not exist. - dispatch(addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) - ); - - // By default, use the first editor tab. - const activeEditorTabIndex = convertParamToInt(qs.tabIdx) ?? 0; - dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)); - if (chapter) { - // TODO: To migrate the state logic away from playgroundSourceChapter - // and playgroundSourceVariant into the language config instead - const languageConfig = getLanguageConfig(chapter, qs.variant as Variant); - handlers.handleChapterSelect(chapter, languageConfig.variant); - // Hardcoded for Playground only for now, while we await workspace refactoring - // to decouple the SicpWorkspace from the Playground. - dispatch(playgroundConfigLanguage(languageConfig)); - } - - const execTime = Math.max(convertParamToInt(qs.exec || '1000') || 1000, 1000); - if (execTime) { - handlers.handleChangeExecTime(execTime); - } - } -} - -export async function resetConfig( +export async function setStateFromPlaygroundConfiguration( configObj: ShareLinkState, handlers: { handleChapterSelect: (chapter: Chapter, variant: Variant) => void; @@ -250,31 +153,20 @@ export async function resetConfig( dispatch: Dispatch, fileSystem: FSModule | null ) { - const chapter = convertParamToInt(configObj.chap?.toString()) ?? undefined; - if (chapter === Chapter.FULL_JS) { + const { chap, exec, files, isFolder, tabIdx, tabs, variant } = configObj; + + if (chap === Chapter.FULL_JS) { showFullJSWarningOnUrlLoad(); - } else if (chapter === Chapter.FULL_TS) { + } else if (chap === Chapter.FULL_TS) { showFulTSWarningOnUrlLoad(); } else { - if (chapter === Chapter.HTML) { + if (chap === Chapter.HTML) { const continueToHtml = await showHTMLDisclaimer(); if (!continueToHtml) { return; } } - // For backward compatibility with old share links - 'prgrm' is no longer used. - const program = - configObj.prgrm === undefined ? '' : decompressFromEncodedURIComponent(configObj.prgrm); - - // By default, create just the default file. - const defaultFilePath = getDefaultFilePath(workspaceLocation); - const files: Record = - configObj.files === undefined - ? { - [defaultFilePath]: program - } - : parseQuery(decompressFromEncodedURIComponent(configObj.files)); if (fileSystem !== null) { await overwriteFilesInWorkspace(workspaceLocation, fileSystem, files); } @@ -283,48 +175,33 @@ export async function resetConfig( // updating the file system view troublesome. To force the file system view to re-render // (and thus display the updated file system), we first disable Folder mode. dispatch(setFolderMode(workspaceLocation, false)); - const isFolderModeEnabled = convertParamToBoolean(configObj.isFolder?.toString()) ?? false; // If Folder mode should be enabled, enabling it after disabling it earlier will cause the // newly-added files to be shown. Note that this has to take place after the files are // already added to the file system. - dispatch(setFolderMode(workspaceLocation, isFolderModeEnabled)); - - // By default, open a single editor tab containing the default playground file. - const editorTabFilePaths = configObj.tabs - ?.split(',') - .map(decompressFromEncodedURIComponent) ?? [defaultFilePath]; + dispatch(setFolderMode(workspaceLocation, isFolder)); // Remove all editor tabs before populating with the ones from the query string. dispatch( removeEditorTabsForDirectory(workspaceLocation, WORKSPACE_BASE_PATHS[workspaceLocation]) ); // Add editor tabs from the query string. - editorTabFilePaths.forEach(filePath => + tabs.forEach(filePath => // Fall back on the empty string if the file contents do not exist. dispatch(addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) ); - // By default, use the first editor tab. - const activeEditorTabIndex = convertParamToInt(configObj.tabIdx?.toString()) ?? 0; - dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)); - if (chapter) { - // TODO: To migrate the state logic away from playgroundSourceChapter - // and playgroundSourceVariant into the language config instead - const languageConfig = getLanguageConfig(chapter, configObj.variant as Variant); - handlers.handleChapterSelect(chapter, languageConfig.variant); - // Hardcoded for Playground only for now, while we await workspace refactoring - // to decouple the SicpWorkspace from the Playground. - dispatch(playgroundConfigLanguage(languageConfig)); - } + dispatch(updateActiveEditorTabIndex(workspaceLocation, tabIdx)); - const execTime = Math.max( - convertParamToInt(configObj.exec?.toString() || '1000') || 1000, - 1000 - ); - if (execTime) { - handlers.handleChangeExecTime(execTime); - } + // TODO: To migrate the state logic away from playgroundSourceChapter + // and playgroundSourceVariant into the language config instead + const languageConfig = getLanguageConfig(chap, variant); + handlers.handleChapterSelect(chap, languageConfig.variant); + // Hardcoded for Playground only for now, while we await workspace refactoring + // to decouple the SicpWorkspace from the Playground. + dispatch(playgroundConfigLanguage(languageConfig)); + + handlers.handleChangeExecTime(exec); } } @@ -360,9 +237,7 @@ const Playground: React.FC = props => { context: { chapter: playgroundSourceChapter, variant: playgroundSourceVariant } } = useTypedSelector(state => state.workspaces[workspaceLocation]); const fileSystem = useTypedSelector(state => state.fileSystem.inBrowserFileSystem); - const { queryString, shortURL, persistenceFile, githubSaveInfo } = useTypedSelector( - state => state.playground - ); + const { persistenceFile, githubSaveInfo } = useTypedSelector(state => state.playground); const { sourceChapter: courseSourceChapter, sourceVariant: courseSourceVariant, @@ -445,63 +320,35 @@ const Playground: React.FC = props => { const hash = isSicpEditor ? props.initialEditorValueHash : location.hash; const { uuid } = useParams<{ uuid: string }>(); - const config = useUrlEncoder(); - const tokens = useTypedSelector((state: OverallState) => ({ - accessToken: state.session.accessToken, - refreshToken: state.session.refreshToken - })); - - const handleURL = useCallback( - async (uuid: string | undefined) => { - if (uuid !== undefined) { - const resp = await requestToShareProgram(`shared_programs/${uuid}`, 'GET', { - ...tokens - }); - if (!resp) { - return showWarningMessage('Invalid share program link! '); - } - const respJson = await resp.json(); - const res: ShareLinkState = new ShareLinkStateDecoder(respJson).decodeWith( - new JsonDecoderDelegate() - ); - resetConfig( - res, - { handleChangeExecTime, handleChapterSelect }, - workspaceLocation, - dispatch, - fileSystem - ); - return; - } else { - const config = new ShareLinkStateDecoder(location.hash).decodeWith( - new UrlParamsDecoderDelegate() - ); - resetConfig( - config, - { handleChangeExecTime, handleChapterSelect }, - workspaceLocation, - dispatch, - fileSystem - ); - return; - } - }, - // disabled eslint here since tokens are checked separately, checking single object cause infinite rerender. - // eslint-disable-next-line - [ - dispatch, - fileSystem, - handleChangeExecTime, - handleChapterSelect, - location.hash, - workspaceLocation, - tokens.accessToken, - tokens.refreshToken - ] - ); useEffect(() => { - if (!hash && uuid === undefined) { + const getPlaygroundConfigurationFromHash = async (hash: string): Promise => + new ShareLinkStateDecoder(hash).decodeWith(new UrlParamsDecoderDelegate(), workspaceLocation); + + const getPlaygroundConfigurationFromUuid = (uuid: string): Promise => + getSharedProgram(uuid).then(jsonText => + new ShareLinkStateDecoder(jsonText).decodeWith(new JsonDecoderDelegate(), workspaceLocation) + ); + + const isLoadingFromPlaygroundConfiguration = hash || uuid; + + if (isLoadingFromPlaygroundConfiguration) { + const getPlaygroundConfiguration = hash + ? getPlaygroundConfigurationFromHash(hash) + : getPlaygroundConfigurationFromUuid(uuid!); + + getPlaygroundConfiguration + .then(playgroundConfiguration => + setStateFromPlaygroundConfiguration( + playgroundConfiguration, + { handleChangeExecTime, handleChapterSelect }, + workspaceLocation, + dispatch, + fileSystem + ) + ) + .catch(err => showWarningMessage(err.toString())); + } else { // If not a accessing via shared link, use the Source chapter and variant in the current course if (courseSourceChapter && courseSourceVariant) { handleChapterSelect(courseSourceChapter, courseSourceVariant); @@ -515,10 +362,7 @@ const Playground: React.FC = props => { // This is because Folder mode only works in Source 2+. dispatch(setFolderMode(workspaceLocation, false)); } - } else { - handleURL(uuid); } - return; }, [ dispatch, fileSystem, @@ -528,8 +372,7 @@ const Playground: React.FC = props => { courseSourceVariant, workspaceLocation, handleChapterSelect, - handleChangeExecTime, - handleURL + handleChangeExecTime ]); /** @@ -838,21 +681,8 @@ const Playground: React.FC = props => { ); const shareButton = useMemo(() => { - const qs = isSicpEditor ? Links.playground + '#' + props.initialEditorValueHash : queryString; - return ( - dispatch(generateLzString())} - handleShortenURL={s => dispatch(shortenURL(s))} - handleUpdateShortURL={s => dispatch(updateShortURL(s))} - queryString={qs} - programConfig={config} - token={tokens} - shortURL={shortURL} - isSicp={isSicpEditor} - key="share" - /> - ); - }, [dispatch, isSicpEditor, props.initialEditorValueHash, queryString, shortURL, config, tokens]); + return ; + }, [isSicpEditor]); const toggleFolderModeButton = useMemo(() => { return ( diff --git a/src/pages/playground/__tests__/Playground.tsx b/src/pages/playground/__tests__/Playground.tsx index 88ec002915..531f0c5475 100644 --- a/src/pages/playground/__tests__/Playground.tsx +++ b/src/pages/playground/__tests__/Playground.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { act, render } from '@testing-library/react'; import { require as acequire } from 'ace-builds'; import { FSModule } from 'browserfs/dist/node/core/FS'; import { Chapter } from 'js-slang/dist/types'; @@ -12,9 +12,12 @@ import { } from 'src/commons/application/ApplicationTypes'; import { WorkspaceSettingsContext } from 'src/commons/WorkspaceSettingsContext'; import { EditorBinding } from 'src/commons/WorkspaceSettingsContext'; +import ShareLinkStateEncoder from 'src/features/playground/shareLinks/encoder/Encoder'; +import { ShareLinkState } from 'src/features/playground/shareLinks/ShareLinkState'; import { createStore } from 'src/pages/createStore'; -import Playground, { handleHash } from '../Playground'; +import * as EncoderHooks from '../../../features/playground/shareLinks/encoder/EncoderHooks'; +import Playground, { setStateFromPlaygroundConfiguration } from '../Playground'; // Mock inspector (window as any).Inspector = jest.fn(); @@ -31,6 +34,11 @@ describe('Playground tests', () => { let routes: RouteObject[]; let mockStore: Store; + // BrowserFS has to be mocked in nodejs environments + jest + .spyOn(EncoderHooks, 'usePlaygroundConfigurationEncoder') + .mockReturnValue(new ShareLinkStateEncoder({} as ShareLinkState)); + const getSourceChapterFromStore = (store: Store) => store.getState().playground.languageConfig.chapter; const getEditorValueFromStore = (store: Store) => @@ -81,38 +89,40 @@ describe('Playground tests', () => { // Using @testing-library/react to render snapshot instead of react-test-renderer // as the useRefs require the notion of React DOM - const tree = render().container; + const tree = await act(() => render().container); expect(tree).toMatchSnapshot(); expect(getSourceChapterFromStore(mockStore)).toBe(Chapter.SOURCE_2); expect(getEditorValueFromStore(mockStore)).toBe("display('hello!');"); }); - describe('handleHash', () => { - test('disables loading hash with fullJS chapter in URL params', () => { - const testHash = '#chap=-1&prgrm=CYSwzgDgNghgngCgOQAsCmUoHsCESCUA3EA'; + describe('setStateFromPlaygroundConfiguration', () => { + test('disables loading playground with fullJS/ fullTS chapter in playground configuration', () => { + const chaptersThatDisableLoading: Chapter[] = [Chapter.FULL_JS, Chapter.FULL_TS]; const mockHandleEditorValueChanged = jest.fn(); const mockHandleChapterSelect = jest.fn(); const mockHandleChangeExecTime = jest.fn(); - handleHash( - testHash, - { - handleChapterSelect: mockHandleChapterSelect, - handleChangeExecTime: mockHandleChangeExecTime - }, - 'playground', - // We cannot make use of 'dispatch' & BrowserFS in test cases. However, the - // behaviour being tested here does not actually invoke either of these. As - // a workaround, we pass in 'undefined' instead & cast to the expected types. - undefined as unknown as Dispatch, - undefined as unknown as FSModule - ); - - expect(mockHandleEditorValueChanged).not.toHaveBeenCalled(); - expect(mockHandleChapterSelect).not.toHaveBeenCalled(); - expect(mockHandleChangeExecTime).not.toHaveBeenCalled(); + for (const chap of chaptersThatDisableLoading) { + setStateFromPlaygroundConfiguration( + { chap } as ShareLinkState, + { + handleChapterSelect: mockHandleChapterSelect, + handleChangeExecTime: mockHandleChangeExecTime + }, + 'playground', + // We cannot make use of 'dispatch' & BrowserFS in test cases. However, the + // behaviour being tested here does not actually invoke either of these. As + // a workaround, we pass in 'undefined' instead & cast to the expected types. + undefined as unknown as Dispatch, + null as unknown as FSModule + ); + + expect(mockHandleEditorValueChanged).not.toHaveBeenCalled(); + expect(mockHandleChapterSelect).not.toHaveBeenCalled(); + expect(mockHandleChangeExecTime).not.toHaveBeenCalled(); + } }); }); }); diff --git a/src/pages/playground/__tests__/__snapshots__/Playground.tsx.snap b/src/pages/playground/__tests__/__snapshots__/Playground.tsx.snap index 10c33683f0..9521e28d8e 100644 --- a/src/pages/playground/__tests__/__snapshots__/Playground.tsx.snap +++ b/src/pages/playground/__tests__/__snapshots__/Playground.tsx.snap @@ -1922,9 +1922,21 @@ exports[`Playground tests Playground with link renders correctly 1`] = ` >
diff --git a/src/routes/routerConfig.tsx b/src/routes/routerConfig.tsx index 14a8a6c0fc..ebee027096 100644 --- a/src/routes/routerConfig.tsx +++ b/src/routes/routerConfig.tsx @@ -68,10 +68,6 @@ export const playgroundOnlyRouterConfig: RouteObject[] = [ path: 'playground', lazy: Playground }, - { - path: 'playground/share/:uuid?', - lazy: Playground - }, ...commonChildrenRoutes, { path: '*', @@ -149,6 +145,11 @@ export const getFullAcademyRouterConfig = ({ lazy: Playground, loader: ensureUserAndRole }, + { + path: 'playground/share/:uuid?', + lazy: Playground, + loader: ensureUserAndRole + }, { path: 'mission-control/:assessmentId?/:questionId?', lazy: MissionControl From 59e810623618c81d9444d762039f5300dbbaf131 Mon Sep 17 00:00:00 2001 From: En Rong <53928333+chownces@users.noreply.github.com> Date: Thu, 16 May 2024 13:28:18 +0800 Subject: [PATCH 18/18] Remove redundant playground saga test --- src/commons/sagas/__tests__/PlaygroundSaga.ts | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/src/commons/sagas/__tests__/PlaygroundSaga.ts b/src/commons/sagas/__tests__/PlaygroundSaga.ts index 59b5f5cb0a..769e495fd2 100644 --- a/src/commons/sagas/__tests__/PlaygroundSaga.ts +++ b/src/commons/sagas/__tests__/PlaygroundSaga.ts @@ -5,42 +5,6 @@ import { externalUrlShortenerRequest } from '../PlaygroundSaga'; describe('Playground saga tests', () => { Constants.urlShortenerBase = 'http://url-shortener.com/'; - // This test relies on BrowserFS which works in browser environments and not Node.js. - // FIXME: Uncomment this test if BrowserFS adds support for running in Node.js. - // test('puts changeQueryString action with correct string argument when passed a dummy program', () => { - // const dummyFiles: Record = { - // [defaultPlaygroundFilePath]: '1 + 1;' - // }; - // const defaultPlaygroundState = createDefaultWorkspace('playground'); - // const dummyState: OverallState = { - // ...defaultState, - // workspaces: { - // ...defaultWorkspaceManager, - // playground: { - // ...defaultPlaygroundState, - // externalLibrary: ExternalLibraryName.NONE, - // editorTabs: [ - // { - // filePath: defaultPlaygroundFilePath, - // value: dummyFiles[defaultPlaygroundFilePath], - // breakpoints: [], - // highlightedLines: [] - // } - // ], - // usingSubst: false - // } - // } - // }; - // const expectedString: string = createQueryString(dummyFiles, dummyState); - // return expectSaga(PlaygroundSaga) - // .withState(dummyState) - // .put(changeQueryString(expectedString)) - // .dispatch({ - // type: GENERATE_LZ_STRING - // }) - // .silentRun(); - // }); - describe('externalUrlShortenerRequest', () => { const mockFetch = jest.spyOn(global, 'fetch'); const mockJsonFn = jest.fn();