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 7c3464895a..83cf9b47b3 100644 --- a/src/commons/controlBar/ControlBarShareButton.tsx +++ b/src/commons/controlBar/ControlBarShareButton.tsx @@ -1,145 +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 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 { 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; }; -type State = { - keyword: string; - isLoading: boolean; -}; +/** + * 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(); -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.shareInputElem = React.createRef(); - this.state = { keyword: '', isLoading: 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.props.shortURL || this.props.shortURL === 'ERROR' ? ( - !this.state.isLoading || this.props.shortURL === 'ERROR' ? ( -
- {Constants.urlShortenerBase}  - - { - this.props.handleShortenURL(this.state.keyword); - this.setState({ isLoading: true }); - }} - /> -
- ) : ( -
- } - /> -
- ) - ) : ( -
- - - - - - -
- )} - - ); - - return ( - - - this.toggleButton()} /> - - - ); - } - - public componentDidUpdate(prevProps: ControlBarShareButtonProps) { - if (this.props.shortURL !== prevProps.shortURL) { - this.setState({ keyword: '', isLoading: false }); - } - } + const generateLinkBackend = () => { + setIsLoading(true); - private toggleButton() { - if (this.props.handleGenerateLz) { - this.props.handleGenerateLz(); - } + customStringKeyword; + + const configuration = playgroundConfiguration.encodeWith(new JsonEncoderDelegate()); + + return postSharedProgram(configuration) + .then(({ shortenedUrl }) => setShortenedUrl(shortenedUrl)) + .catch(err => showWarningMessage(err.toString())) + .finally(() => setIsLoading(false)); + }; + + const generateLinkPlaygroundOnly = () => { + const hash = playgroundConfiguration.encodeWith(new UrlParamsEncoderDelegate()); + setIsLoading(true); - // reset state - this.props.handleUpdateShortURL(''); - this.setState({ keyword: '', isLoading: false }); - } + return externalUrlShortenerRequest(hash, customStringKeyword) + .then(({ shortenedUrl, message }) => { + setShortenedUrl(shortenedUrl); + if (message) showSuccessMessage(message); + }) + .catch(err => showWarningMessage(err.toString())) + .finally(() => setIsLoading(false)); + }; - private handleChange(event: React.FormEvent) { - this.setState({ keyword: event.currentTarget.value }); - } + const generateLinkSicp = () => { + const hash = playgroundConfiguration.encodeWith(new UrlParamsEncoderDelegate()); + const shortenedUrl = `${Links.playground}#${hash}`; + setShortenedUrl(shortenedUrl); + }; - private selectShareInputText() { - if (this.shareInputElem.current !== null) { - this.shareInputElem.current.focus(); - this.shareInputElem.current.select(); + const generateLink = props.isSicp + ? generateLinkSicp + : Constants.playgroundOnly + ? generateLinkPlaygroundOnly + : generateLinkBackend; + + useHotkeys([['ctrl+e', generateLink]], []); + + const handleCustomStringChange = (event: React.FormEvent) => { + setCustomStringKeyword(event.currentTarget.value); + }; + + // For visual effect of highlighting the text field on copy + const selectShareInputText = () => { + if (shareInputElem.current !== null) { + shareInputElem.current.focus(); + shareInputElem.current.select(); } - } -} + }; + + 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 c4cd8a71d9..d953051ac4 100644 --- a/src/commons/sagas/PlaygroundSaga.ts +++ b/src/commons/sagas/PlaygroundSaga.ts @@ -1,64 +1,18 @@ -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 PlaygroundActions from '../../features/playground/PlaygroundActions'; import { isSchemeLanguage, isSourceLanguage, OverallState } from '../application/ApplicationTypes'; -import { ExternalLibraryName } from '../application/types/ExternalTypes'; -import { retrieveFilesInWorkspaceAsRecord } from '../fileSystem/utils'; import { visitSideContent } from '../sideContent/SideContentActions'; import { SideContentType } from '../sideContent/SideContentTypes'; import Constants from '../utils/Constants'; -import { showSuccessMessage, showWarningMessage } from '../utils/notifications/NotificationsHelper'; import WorkspaceActions 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(PlaygroundActions.generateLzString.type, updateQueryString); - - yield takeEvery( - PlaygroundActions.shortenURL.type, - 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(PlaygroundActions.updateShortURL(errorMsg)); - return yield call(showWarningMessage, 'Something went wrong trying to create the link.'); - } - - if (resp.status !== 'success' && !resp.shorturl) { - yield put(PlaygroundActions.updateShortURL(errorMsg)); - return yield call(showWarningMessage, resp.message); - } - - if (resp.status !== 'success') { - yield call(showSuccessMessage, resp.message); - } - yield put(PlaygroundActions.updateShortURL(Constants.urlShortenerBase + resp.url.keyword)); - } - ); - yield takeEvery( visitSideContent.type, function* ({ @@ -126,60 +80,31 @@ 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(PlaygroundActions.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 +124,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 cf6d66a1b6..769e495fd2 100644 --- a/src/commons/sagas/__tests__/PlaygroundSaga.ts +++ b/src/commons/sagas/__tests__/PlaygroundSaga.ts @@ -1,464 +1,74 @@ -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 PlaygroundActions from '../../../features/playground/PlaygroundActions'; -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. - // 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(); - // }); - - 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, - usingUpload: false, - 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: PlaygroundActions.shortenURL.type, - payload: '' - }) - .provide([[call(shortenURLRequest, queryString, ''), mockResp]]) - .not.call(showWarningMessage, errMsg) - .not.call(showSuccessMessage, mockResp.message) - .put(PlaygroundActions.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, - usingUpload: false, - 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: PlaygroundActions.shortenURL.type, - payload: 'tester' - }) - .provide([[call(shortenURLRequest, queryString, 'tester'), mockResp]]) - .not.call(showWarningMessage, errMsg) - .not.call(showSuccessMessage, mockResp.message) - .put(PlaygroundActions.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, - usingUpload: false, - 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: PlaygroundActions.shortenURL.type, - payload: '' - }) - .provide([[call(shortenURLRequest, queryString, ''), null]]) - .call(showWarningMessage, errMsg) - .put(PlaygroundActions.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, - usingUpload: 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: PlaygroundActions.shortenURL.type, - payload: '' - }) - .provide([[call(shortenURLRequest, queryString, ''), mockResp]]) - .call(showSuccessMessage, mockResp.message) - .not.call(showWarningMessage, errMsg) - .put(PlaygroundActions.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, - usingUpload: false, - 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: PlaygroundActions.shortenURL.type, - payload: '' - }) - .provide([[call(shortenURLRequest, queryString, ''), mockResp]]) - .call(showWarningMessage, mockResp.message) - .put(PlaygroundActions.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, - usingUpload: false, - 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: PlaygroundActions.shortenURL.type, - payload: '' - }) - .provide({ - race: () => ({ - result: undefined, - hasTimedOut: true - }) - }) - .call(showWarningMessage, errMsg) - .put(PlaygroundActions.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 dd73d4c8d0..6574d356aa 100644 --- a/src/features/playground/PlaygroundActions.ts +++ b/src/features/playground/PlaygroundActions.ts @@ -4,10 +4,6 @@ import { createActions } from 'src/commons/redux/utils'; import { PersistenceFile } from '../persistence/PersistenceTypes'; const PlaygroundActions = createActions('playground', { - generateLzString: () => ({}), - shortenURL: (keyword: string) => keyword, - updateShortURL: (shortURL: string) => shortURL, - changeQueryString: (queryString: string) => queryString, playgroundUpdatePersistenceFile: (file?: PersistenceFile) => file, playgroundUpdateGitHubSaveInfo: (repoName: string, filePath: string, lastSaved: Date) => ({ repoName, @@ -19,10 +15,6 @@ const PlaygroundActions = createActions('playground', { // For compatibility with existing code (reducer) export const { - generateLzString, - shortenURL, - updateShortURL, - changeQueryString, playgroundUpdatePersistenceFile, playgroundUpdateGitHubSaveInfo, playgroundConfigLanguage diff --git a/src/features/playground/PlaygroundReducer.ts b/src/features/playground/PlaygroundReducer.ts index e4e04ad247..aff9e5f2fc 100644 --- a/src/features/playground/PlaygroundReducer.ts +++ b/src/features/playground/PlaygroundReducer.ts @@ -4,11 +4,9 @@ import { Reducer } from 'redux'; import { defaultPlayground } from '../../commons/application/ApplicationTypes'; import { SourceActionType } from '../../commons/utils/ActionsHelper'; import { - changeQueryString, playgroundConfigLanguage, playgroundUpdateGitHubSaveInfo, - playgroundUpdatePersistenceFile, - updateShortURL + playgroundUpdatePersistenceFile } from './PlaygroundActions'; import { PlaygroundState } from './PlaygroundTypes'; @@ -16,12 +14,6 @@ export const PlaygroundReducer: Reducer = cre defaultPlayground, builder => { builder - .addCase(changeQueryString, (state, action) => { - state.queryString = action.payload; - }) - .addCase(updateShortURL, (state, action) => { - state.shortURL = action.payload; - }) .addCase(playgroundUpdateGitHubSaveInfo, (state, action) => { state.githubSaveInfo = action.payload; }) diff --git a/src/features/playground/PlaygroundTypes.ts b/src/features/playground/PlaygroundTypes.ts index 508e793784..108bea5025 100644 --- a/src/features/playground/PlaygroundTypes.ts +++ b/src/features/playground/PlaygroundTypes.ts @@ -4,8 +4,6 @@ import { GitHubSaveInfo } from '../github/GitHubTypes'; import { PersistenceFile } from '../persistence/PersistenceTypes'; 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 9ae3881125..0000000000 --- a/src/features/playground/__tests__/PlaygroundActions.ts +++ /dev/null @@ -1,18 +0,0 @@ -import PlaygroundActions from '../PlaygroundActions'; - -test('generateLzString generates correct action object', () => { - const action = PlaygroundActions.generateLzString(); - expect(action).toEqual({ - type: PlaygroundActions.generateLzString.type, - payload: {} - }); -}); - -test('changeQueryString generates correct action object', () => { - const queryString = 'test-query-string'; - const action = PlaygroundActions.changeQueryString(queryString); - expect(action).toEqual({ - type: PlaygroundActions.changeQueryString.type, - payload: queryString - }); -}); diff --git a/src/features/playground/__tests__/PlaygroundReducer.ts b/src/features/playground/__tests__/PlaygroundReducer.ts deleted file mode 100644 index 29a64780b7..0000000000 --- a/src/features/playground/__tests__/PlaygroundReducer.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defaultPlayground } from '../../../commons/application/ApplicationTypes'; -import PlaygroundActions from '../PlaygroundActions'; -import { PlaygroundReducer } from '../PlaygroundReducer'; - -test('CHANGE_QUERY_STRING sets queryString correctly ', () => { - const action = { - type: PlaygroundActions.changeQueryString.type, - 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 new file mode 100644 index 0000000000..e717980963 --- /dev/null +++ b/src/features/playground/shareLinks/ShareLinkState.ts @@ -0,0 +1,22 @@ +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; + exec: string; + 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 new file mode 100644 index 0000000000..5e4fcf20a4 --- /dev/null +++ b/src/features/playground/shareLinks/decoder/Decoder.ts @@ -0,0 +1,52 @@ +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'; + +/** + * Decodes the given encodedString with the specified decoder in `decodeWith`. + */ +class ShareLinkStateDecoder { + encodedString: string; + + constructor(encodedString: string) { + this.encodedString = 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 + }; + } +} + +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..e758ef8e3f --- /dev/null +++ b/src/features/playground/shareLinks/decoder/delegates/DecoderDelegate.ts @@ -0,0 +1,7 @@ +import { ParsedIntermediateShareLinkState } from '../../ShareLinkState'; + +interface DecoderDelegate { + 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 new file mode 100644 index 0000000000..04b72dea6f --- /dev/null +++ b/src/features/playground/shareLinks/decoder/delegates/JsonDecoderDelegate.ts @@ -0,0 +1,10 @@ +import { ParsedIntermediateShareLinkState } from '../../ShareLinkState'; +import DecoderDelegate from './DecoderDelegate'; + +class JsonDecoderDelegate implements DecoderDelegate { + decode(str: string): ParsedIntermediateShareLinkState { + return JSON.parse(str); + } +} + +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..686fcfc94e --- /dev/null +++ b/src/features/playground/shareLinks/decoder/delegates/UrlParamsDecoderDelegate.ts @@ -0,0 +1,23 @@ +import { parseQuery } from 'src/commons/utils/QueryHelper'; + +import { ParsedIntermediateShareLinkState } from '../../ShareLinkState'; +import DecoderDelegate from './DecoderDelegate'; + +class UrlParamsDecoderDelegate implements DecoderDelegate { + 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?.split(','), + variant: qs.variant, + prgrm: qs.prgrm + }; + } +} + +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..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/EncoderHooks.ts b/src/features/playground/shareLinks/encoder/EncoderHooks.ts new file mode 100644 index 0000000000..b1cbc9fc19 --- /dev/null +++ b/src/features/playground/shareLinks/encoder/EncoderHooks.ts @@ -0,0 +1,46 @@ +import { FSModule } from 'browserfs/dist/node/core/FS'; +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 ShareLinkStateEncoder from './Encoder'; + +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) + .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 files = useGetFile(); + + const result: ShareLinkState = { + isFolder: isFolderModeEnabled, + files: files, + tabs: editorTabFilePaths, + tabIdx: activeEditorTabIndex, + chap: chapter, + variant, + exec: execTime + }; + + return new ShareLinkStateEncoder(result); +}; + +const useGetFile = () => { + const fileSystem = useTypedSelector(state => state.fileSystem.inBrowserFileSystem); + const [files, setFiles] = useState>({}); + retrieveFilesInWorkspaceAsRecord('playground', fileSystem as FSModule).then(result => { + setFiles(result); + }); + 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 b7274483d1..9025a1749c 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -6,11 +6,10 @@ 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, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { Dispatch, useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useStore } from 'react-redux'; -import { useLocation, useNavigate } from 'react-router'; -import { AnyAction, Dispatch } from 'redux'; +import { useLocation, useNavigate, useParams } from 'react-router'; +import { AnyAction } from 'redux'; import InterpreterActions from 'src/commons/application/actions/InterpreterActions'; import SessionActions from 'src/commons/application/actions/SessionActions'; import { @@ -18,6 +17,8 @@ import { setSessionDetails, 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'; @@ -25,6 +26,7 @@ import makeUploadTabFrom from 'src/commons/sideContent/content/SideContentUpload import { changeSideContentHeight } from 'src/commons/sideContent/SideContentActions'; import { useSideContent } from 'src/commons/sideContent/SideContentHelper'; import { useResponsive, useTypedSelector } from 'src/commons/utils/Hooks'; +import { showWarningMessage } from 'src/commons/utils/notifications/NotificationsHelper'; import { showFullJSWarningOnUrlLoad, showFulTSWarningOnUrlLoad, @@ -39,15 +41,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 { ShareLinkState } from 'src/features/playground/shareLinks/ShareLinkState'; import { - getDefaultFilePath, getLanguageConfig, isSourceLanguage, OverallState, @@ -71,17 +71,14 @@ 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 } 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 { 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 { @@ -105,8 +102,8 @@ export type PlaygroundProps = { handleCloseEditor?: () => void; }; -export async function handleHash( - hash: string, +export async function setStateFromPlaygroundConfiguration( + configObj: ShareLinkState, handlers: { handleChapterSelect: (chapter: Chapter, variant: Variant) => void; handleChangeExecTime: (execTime: number) => void; @@ -115,33 +112,20 @@ export async function handleHash( 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 { chap, exec, files, isFolder, tabIdx, tabs, variant } = configObj; - const chapter = convertParamToInt(qs.chap) ?? undefined; - if (chapter === Chapter.FULL_JS) { + 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 = 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); } @@ -150,16 +134,12 @@ export async function handleHash( // 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(WorkspaceActions.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(WorkspaceActions.setFolderMode(workspaceLocation, isFolderModeEnabled)); + dispatch(WorkspaceActions.setFolderMode(workspaceLocation, isFolder)); - // 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( WorkspaceActions.removeEditorTabsForDirectory( @@ -168,28 +148,22 @@ export async function handleHash( ) ); // 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(WorkspaceActions.addEditorTab(workspaceLocation, filePath, files[filePath] ?? '')) ); - // By default, use the first editor tab. - const activeEditorTabIndex = convertParamToInt(qs.tabIdx) ?? 0; - dispatch(WorkspaceActions.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)); - } + dispatch(WorkspaceActions.updateActiveEditorTabIndex(workspaceLocation, tabIdx)); - const execTime = Math.max(convertParamToInt(qs.exec || '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); } } @@ -225,9 +199,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, @@ -320,9 +292,36 @@ const Playground: React.FC = props => { }, [editorSessionId]); const hash = isSicpEditor ? props.initialEditorValueHash : location.hash; + const { uuid } = useParams<{ uuid: string }>(); useEffect(() => { - if (!hash) { + 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); @@ -336,19 +335,12 @@ const Playground: React.FC = props => { // This is because Folder mode only works in Source 2+. dispatch(WorkspaceActions.setFolderMode(workspaceLocation, false)); } - return; } - handleHash( - hash, - { handleChangeExecTime, handleChapterSelect }, - workspaceLocation, - dispatch, - fileSystem - ); }, [ dispatch, fileSystem, hash, + uuid, courseSourceChapter, courseSourceVariant, workspaceLocation, @@ -657,19 +649,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} - shortURL={shortURL} - isSicp={isSicpEditor} - key="share" - /> - ); - }, [dispatch, isSicpEditor, props.initialEditorValueHash, queryString, shortURL]); + return ; + }, [isSicpEditor]); const toggleFolderModeButton = useMemo(() => { return ( diff --git a/src/pages/playground/__tests__/Playground.tsx b/src/pages/playground/__tests__/Playground.tsx index 39ff45b3b4..2af7d3c79a 100644 --- a/src/pages/playground/__tests__/Playground.tsx +++ b/src/pages/playground/__tests__/Playground.tsx @@ -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(); @@ -32,6 +35,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,31 +89,33 @@ describe('Playground tests', () => { 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/routes/routerConfig.tsx b/src/routes/routerConfig.tsx index 63a6876780..19b1ff2b43 100644 --- a/src/routes/routerConfig.tsx +++ b/src/routes/routerConfig.tsx @@ -121,6 +121,7 @@ export const getFullAcademyRouterConfig = ({ { path: 'courses', element: }, ensureUserAndRole({ path: 'courses/:courseId/*', lazy: Academy, children: academyRoutes }), ensureUserAndRole({ path: 'playground', lazy: Playground }), + ensureUserAndRole({ path: 'playground/share/:uuid?', lazy: Playground }), { path: 'mission-control/:assessmentId?/:questionId?', lazy: MissionControl }, ensureUserAndRole({ path: 'courses/:courseId/stories/new', lazy: EditStory }), ensureUserAndRole({ path: 'courses/:courseId/stories/view/:id', lazy: ViewStory }),