Skip to content
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

Shared links frontend refactor #2937

Merged
merged 11 commits into from
Apr 15, 2024
3 changes: 3 additions & 0 deletions src/commons/application/types/ShareLinkTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type ShareLinkShortenedUrlResponse = {
shortenedUrl: string;
};
322 changes: 127 additions & 195 deletions src/commons/controlBar/ControlBarShareButton.tsx
chownces marked this conversation as resolved.
Show resolved Hide resolved
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}&nbsp;
<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}&nbsp;
<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>
);
};
31 changes: 31 additions & 0 deletions src/commons/mocks/RequestMock.ts
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' };
Loading
Loading