-
Notifications
You must be signed in to change notification settings - Fork 172
Shared links frontend refactor #2937
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
martin-henz
merged 11 commits into
shared-links-frontend
from
shared-links-frontend-enrong
Apr 15, 2024
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
24b31fa
Refactor ControlBarShareButton to functional react
chownces f5c5735
Update hotkeys implementation
chownces 177f873
Re-add playground configuration encoder
chownces d63f217
Migrate external URL shortener request out of sagas
chownces b3f8ed2
Implement retrieval of playground configuration from backend UUID and…
chownces c24d3a3
Update test suite
chownces 5630af6
Mock BrowserFS in nodejs test environment
chownces b849eb8
Fix mock file error
chownces ccf539d
Update hotkeys binding
chownces 6c54e72
Update usage of uuid decoder
chownces da049d8
Update tests
chownces File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export type ShareLinkShortenedUrlResponse = { | ||
shortenedUrl: string; | ||
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Parameters<typeof request>>; | ||
|
||
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<ControlBarShareButtonProps> = props => { | ||
const shareInputElem = useRef<HTMLInputElement>(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<ControlBarShareButtonProps, State> { | ||
private shareInputElem: React.RefObject<HTMLInputElement>; | ||
|
||
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 ? ( | ||
<Text> | ||
Share your programs! Type something into the editor (left), then click on this button | ||
again. | ||
</Text> | ||
) : this.props.isSicp ? ( | ||
<div> | ||
<input defaultValue={this.props.queryString!} readOnly={true} ref={this.shareInputElem} /> | ||
<Tooltip content="Copy link to clipboard"> | ||
<CopyToClipboard text={this.props.queryString!}> | ||
<ControlButton icon={IconNames.DUPLICATE} onClick={this.selectShareInputText} /> | ||
</CopyToClipboard> | ||
</Tooltip> | ||
</div> | ||
) : ( | ||
<> | ||
{!this.state.isSuccess || this.props.shortURL === 'ERROR' ? ( | ||
!this.state.isLoading || this.props.shortURL === 'ERROR' ? ( | ||
<div> | ||
{Constants.urlShortenerBase} | ||
<input | ||
placeholder={'custom string (optional)'} | ||
onChange={this.handleChange} | ||
style={{ width: 175 }} | ||
/> | ||
<ControlButton | ||
label="Get Link" | ||
icon={IconNames.SHARE} | ||
// post request to backend, set keyword as return uuid | ||
onClick={() => this.fetchUUID(this.props.token)} | ||
/> | ||
</div> | ||
) : ( | ||
<div> | ||
<NonIdealState | ||
description="Generating Shareable Link..." | ||
icon={<Spinner size={SpinnerSize.SMALL} />} | ||
/> | ||
</div> | ||
) | ||
) : ( | ||
<div key={this.state.keyword}> | ||
<input defaultValue={this.state.keyword} readOnly={true} ref={this.shareInputElem} /> | ||
<Tooltip content="Copy link to clipboard"> | ||
<CopyToClipboard text={this.state.keyword}> | ||
<ControlButton icon={IconNames.DUPLICATE} onClick={this.selectShareInputText} /> | ||
</CopyToClipboard> | ||
</Tooltip> | ||
</div> | ||
)} | ||
</> | ||
); | ||
|
||
return ( | ||
<Popover | ||
popoverClassName="Popover-share" | ||
inheritDarkTheme={false} | ||
content={shareButtonPopoverContent} | ||
> | ||
<Tooltip content="Get shareable link" placement={Position.TOP}> | ||
<ControlButton label="Share" icon={IconNames.SHARE} onClick={() => this.toggleButton()} /> | ||
</Tooltip> | ||
</Popover> | ||
); | ||
} | ||
|
||
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<HTMLInputElement>) { | ||
this.setState({ keyword: event.currentTarget.value }); | ||
} | ||
const handleCustomStringChange = (event: React.FormEvent<HTMLInputElement>) => { | ||
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 = ( | ||
<div> | ||
{Constants.urlShortenerBase} | ||
<input | ||
placeholder={'custom string (optional)'} | ||
onChange={handleCustomStringChange} | ||
style={{ width: 175 }} | ||
/> | ||
<ControlButton label="Get Link" icon={IconNames.SHARE} onClick={generateLink} /> | ||
</div> | ||
); | ||
|
||
const generatingLinkPopoverContent = ( | ||
<div> | ||
<NonIdealState | ||
description="Generating Shareable Link..." | ||
icon={<Spinner size={SpinnerSize.SMALL} />} | ||
/> | ||
</div> | ||
); | ||
|
||
const copyLinkPopoverContent = ( | ||
<div key={shortenedUrl}> | ||
<input defaultValue={shortenedUrl} readOnly={true} ref={shareInputElem} /> | ||
<Tooltip content="Copy link to clipboard"> | ||
<CopyToClipboard text={shortenedUrl}> | ||
<ControlButton icon={IconNames.DUPLICATE} onClick={selectShareInputText} /> | ||
</CopyToClipboard> | ||
</Tooltip> | ||
</div> | ||
); | ||
|
||
const shareButtonPopoverContent = isLoading | ||
? generatingLinkPopoverContent | ||
: shortenedUrl | ||
? copyLinkPopoverContent | ||
: generateLinkPopoverContent; | ||
|
||
return ( | ||
<Popover | ||
popoverClassName="Popover-share" | ||
inheritDarkTheme={false} | ||
content={shareButtonPopoverContent} | ||
> | ||
<Tooltip content="Get shareable link" placement={Position.TOP}> | ||
<ControlButton label="Share" icon={IconNames.SHARE} /> | ||
</Tooltip> | ||
</Popover> | ||
); | ||
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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' }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.