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