Skip to content

Commit cd4760e

Browse files
authored
Shared links frontend refactor (#2937)
* Refactor ControlBarShareButton to functional react * Update hotkeys implementation * Re-add playground configuration encoder * Migrate external URL shortener request out of sagas * Implement retrieval of playground configuration from backend UUID and reinstate configuration by hash parameters * Update test suite * Mock BrowserFS in nodejs test environment * Fix mock file error * Update hotkeys binding * Update usage of uuid decoder * Update tests
1 parent 840e5d5 commit cd4760e

26 files changed

+614
-1076
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export type ShareLinkShortenedUrlResponse = {
2+
shortenedUrl: string;
3+
};
Lines changed: 127 additions & 195 deletions
Original file line numberDiff line numberDiff line change
@@ -1,211 +1,143 @@
1-
import {
2-
NonIdealState,
3-
Popover,
4-
Position,
5-
Spinner,
6-
SpinnerSize,
7-
Text,
8-
Tooltip
9-
} from '@blueprintjs/core';
1+
import { NonIdealState, Popover, Position, Spinner, SpinnerSize, Tooltip } from '@blueprintjs/core';
102
import { IconNames } from '@blueprintjs/icons';
11-
import React from 'react';
3+
import { useHotkeys } from '@mantine/hooks';
4+
import React, { useRef, useState } from 'react';
125
import * as CopyToClipboard from 'react-copy-to-clipboard';
13-
import ShareLinkState from 'src/features/playground/shareLinks/ShareLinkState';
6+
import JsonEncoderDelegate from 'src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate';
7+
import UrlParamsEncoderDelegate from 'src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate';
8+
import { usePlaygroundConfigurationEncoder } from 'src/features/playground/shareLinks/encoder/EncoderHooks';
149

1510
import ControlButton from '../ControlButton';
16-
import Constants from '../utils/Constants';
17-
import { showWarningMessage } from '../utils/notifications/NotificationsHelper';
18-
import { request } from '../utils/RequestHelper';
19-
import { RemoveLast } from '../utils/TypeHelper';
11+
import { externalUrlShortenerRequest } from '../sagas/PlaygroundSaga';
12+
import { postSharedProgram } from '../sagas/RequestsSaga';
13+
import Constants, { Links } from '../utils/Constants';
14+
import { showSuccessMessage, showWarningMessage } from '../utils/notifications/NotificationsHelper';
2015

21-
type ControlBarShareButtonProps = DispatchProps & StateProps;
22-
23-
type DispatchProps = {
24-
handleGenerateLz?: () => void;
25-
handleShortenURL: (s: string) => void;
26-
handleUpdateShortURL: (s: string) => void;
27-
};
28-
29-
type StateProps = {
30-
queryString?: string;
31-
shortURL?: string;
32-
key: string;
16+
type ControlBarShareButtonProps = {
3317
isSicp?: boolean;
34-
programConfig: ShareLinkState;
35-
token: Tokens;
36-
};
37-
38-
type State = {
39-
keyword: string;
40-
isLoading: boolean;
41-
isSuccess: boolean;
4218
};
4319

44-
type ShareLinkRequestHelperParams = RemoveLast<Parameters<typeof request>>;
45-
46-
export type Tokens = {
47-
accessToken: string | undefined;
48-
refreshToken: string | undefined;
49-
};
50-
51-
export const requestToShareProgram = async (
52-
...[path, method, opts]: ShareLinkRequestHelperParams
53-
) => {
54-
const resp = await request(path, method, opts);
55-
return resp;
56-
};
20+
/**
21+
* Generates the share link for programs in the Playground.
22+
*
23+
* For playground-only (no backend) deployments:
24+
* - Generate a URL with playground configuration encoded as hash parameters
25+
* - URL sent to external URL shortener service
26+
* - Shortened URL displayed to user
27+
* - (note: SICP CodeSnippets use these hash parameters)
28+
*
29+
* For 'with backend' deployments:
30+
* - Send the playground configuration to the backend
31+
* - Backend stores configuration and assigns a UUID
32+
* - Backend pings the external URL shortener service with UUID link
33+
* - Shortened URL returned to Frontend and displayed to user
34+
*/
35+
export const ControlBarShareButton: React.FC<ControlBarShareButtonProps> = props => {
36+
const shareInputElem = useRef<HTMLInputElement>(null);
37+
const [isLoading, setIsLoading] = useState(false);
38+
const [shortenedUrl, setShortenedUrl] = useState('');
39+
const [customStringKeyword, setCustomStringKeyword] = useState('');
40+
const playgroundConfiguration = usePlaygroundConfigurationEncoder();
41+
42+
const generateLinkBackend = () => {
43+
setIsLoading(true);
44+
45+
customStringKeyword;
46+
47+
const configuration = playgroundConfiguration.encodeWith(new JsonEncoderDelegate());
48+
49+
return postSharedProgram(configuration)
50+
.then(({ shortenedUrl }) => setShortenedUrl(shortenedUrl))
51+
.catch(err => showWarningMessage(err.toString()))
52+
.finally(() => setIsLoading(false));
53+
};
5754

58-
export class ControlBarShareButton extends React.PureComponent<ControlBarShareButtonProps, State> {
59-
private shareInputElem: React.RefObject<HTMLInputElement>;
60-
61-
constructor(props: ControlBarShareButtonProps) {
62-
super(props);
63-
this.selectShareInputText = this.selectShareInputText.bind(this);
64-
this.handleChange = this.handleChange.bind(this);
65-
this.toggleButton = this.toggleButton.bind(this);
66-
this.fetchUUID = this.fetchUUID.bind(this);
67-
this.shareInputElem = React.createRef();
68-
this.state = { keyword: '', isLoading: false, isSuccess: false };
69-
}
70-
71-
componentDidMount() {
72-
document.addEventListener('keydown', this.handleKeyDown);
73-
}
74-
75-
componentWillUnmount() {
76-
document.removeEventListener('keydown', this.handleKeyDown);
77-
}
78-
79-
handleKeyDown = (event: any) => {
80-
if (event.key === 'Enter' && event.ctrlKey) {
81-
// press Ctrl+Enter to generate and copy new share link directly
82-
this.setState({ keyword: 'Test' });
83-
this.props.handleShortenURL(this.state.keyword);
84-
this.setState({ isLoading: true });
85-
if (this.props.shortURL || this.props.isSicp) {
86-
this.selectShareInputText();
87-
console.log('link created.');
88-
}
89-
}
55+
const generateLinkPlaygroundOnly = () => {
56+
const hash = playgroundConfiguration.encodeWith(new UrlParamsEncoderDelegate());
57+
setIsLoading(true);
58+
59+
return externalUrlShortenerRequest(hash, customStringKeyword)
60+
.then(({ shortenedUrl, message }) => {
61+
setShortenedUrl(shortenedUrl);
62+
if (message) showSuccessMessage(message);
63+
})
64+
.catch(err => showWarningMessage(err.toString()))
65+
.finally(() => setIsLoading(false));
9066
};
9167

92-
public render() {
93-
const shareButtonPopoverContent =
94-
this.props.queryString === undefined ? (
95-
<Text>
96-
Share your programs! Type something into the editor (left), then click on this button
97-
again.
98-
</Text>
99-
) : this.props.isSicp ? (
100-
<div>
101-
<input defaultValue={this.props.queryString!} readOnly={true} ref={this.shareInputElem} />
102-
<Tooltip content="Copy link to clipboard">
103-
<CopyToClipboard text={this.props.queryString!}>
104-
<ControlButton icon={IconNames.DUPLICATE} onClick={this.selectShareInputText} />
105-
</CopyToClipboard>
106-
</Tooltip>
107-
</div>
108-
) : (
109-
<>
110-
{!this.state.isSuccess || this.props.shortURL === 'ERROR' ? (
111-
!this.state.isLoading || this.props.shortURL === 'ERROR' ? (
112-
<div>
113-
{Constants.urlShortenerBase}&nbsp;
114-
<input
115-
placeholder={'custom string (optional)'}
116-
onChange={this.handleChange}
117-
style={{ width: 175 }}
118-
/>
119-
<ControlButton
120-
label="Get Link"
121-
icon={IconNames.SHARE}
122-
// post request to backend, set keyword as return uuid
123-
onClick={() => this.fetchUUID(this.props.token)}
124-
/>
125-
</div>
126-
) : (
127-
<div>
128-
<NonIdealState
129-
description="Generating Shareable Link..."
130-
icon={<Spinner size={SpinnerSize.SMALL} />}
131-
/>
132-
</div>
133-
)
134-
) : (
135-
<div key={this.state.keyword}>
136-
<input defaultValue={this.state.keyword} readOnly={true} ref={this.shareInputElem} />
137-
<Tooltip content="Copy link to clipboard">
138-
<CopyToClipboard text={this.state.keyword}>
139-
<ControlButton icon={IconNames.DUPLICATE} onClick={this.selectShareInputText} />
140-
</CopyToClipboard>
141-
</Tooltip>
142-
</div>
143-
)}
144-
</>
145-
);
146-
147-
return (
148-
<Popover
149-
popoverClassName="Popover-share"
150-
inheritDarkTheme={false}
151-
content={shareButtonPopoverContent}
152-
>
153-
<Tooltip content="Get shareable link" placement={Position.TOP}>
154-
<ControlButton label="Share" icon={IconNames.SHARE} onClick={() => this.toggleButton()} />
155-
</Tooltip>
156-
</Popover>
157-
);
158-
}
159-
160-
public componentDidUpdate(prevProps: ControlBarShareButtonProps) {
161-
if (this.props.shortURL !== prevProps.shortURL) {
162-
this.setState({ keyword: '', isLoading: false });
163-
}
164-
}
68+
const generateLinkSicp = () => {
69+
const hash = playgroundConfiguration.encodeWith(new UrlParamsEncoderDelegate());
70+
const shortenedUrl = `${Links.playground}#${hash}`;
71+
setShortenedUrl(shortenedUrl);
72+
};
16573

166-
private toggleButton() {
167-
if (this.props.handleGenerateLz) {
168-
this.props.handleGenerateLz();
169-
}
74+
const generateLink = props.isSicp
75+
? generateLinkSicp
76+
: Constants.playgroundOnly
77+
? generateLinkPlaygroundOnly
78+
: generateLinkBackend;
17079

171-
// reset state
172-
this.setState({ keyword: '', isLoading: false, isSuccess: false });
173-
}
80+
useHotkeys([['ctrl+e', generateLink]], []);
17481

175-
private handleChange(event: React.FormEvent<HTMLInputElement>) {
176-
this.setState({ keyword: event.currentTarget.value });
177-
}
82+
const handleCustomStringChange = (event: React.FormEvent<HTMLInputElement>) => {
83+
setCustomStringKeyword(event.currentTarget.value);
84+
};
17885

179-
private selectShareInputText() {
180-
if (this.shareInputElem.current !== null) {
181-
this.shareInputElem.current.focus();
182-
this.shareInputElem.current.select();
86+
// For visual effect of highlighting the text field on copy
87+
const selectShareInputText = () => {
88+
if (shareInputElem.current !== null) {
89+
shareInputElem.current.focus();
90+
shareInputElem.current.select();
18391
}
184-
}
185-
186-
private fetchUUID(tokens: Tokens) {
187-
const requestBody = {
188-
shared_program: {
189-
data: this.props.programConfig
190-
}
191-
};
192-
193-
const getProgramUrl = async () => {
194-
const resp = await requestToShareProgram(`shared_programs`, 'POST', {
195-
body: requestBody,
196-
...tokens
197-
});
198-
if (!resp) {
199-
return showWarningMessage('Fail to generate url!');
200-
}
201-
const respJson = await resp.json();
202-
this.setState({
203-
keyword: `${window.location.host}/playground/share/` + respJson.uuid
204-
});
205-
this.setState({ isLoading: true, isSuccess: true });
206-
return;
207-
};
208-
209-
getProgramUrl();
210-
}
211-
}
92+
};
93+
94+
const generateLinkPopoverContent = (
95+
<div>
96+
{Constants.urlShortenerBase}&nbsp;
97+
<input
98+
placeholder={'custom string (optional)'}
99+
onChange={handleCustomStringChange}
100+
style={{ width: 175 }}
101+
/>
102+
<ControlButton label="Get Link" icon={IconNames.SHARE} onClick={generateLink} />
103+
</div>
104+
);
105+
106+
const generatingLinkPopoverContent = (
107+
<div>
108+
<NonIdealState
109+
description="Generating Shareable Link..."
110+
icon={<Spinner size={SpinnerSize.SMALL} />}
111+
/>
112+
</div>
113+
);
114+
115+
const copyLinkPopoverContent = (
116+
<div key={shortenedUrl}>
117+
<input defaultValue={shortenedUrl} readOnly={true} ref={shareInputElem} />
118+
<Tooltip content="Copy link to clipboard">
119+
<CopyToClipboard text={shortenedUrl}>
120+
<ControlButton icon={IconNames.DUPLICATE} onClick={selectShareInputText} />
121+
</CopyToClipboard>
122+
</Tooltip>
123+
</div>
124+
);
125+
126+
const shareButtonPopoverContent = isLoading
127+
? generatingLinkPopoverContent
128+
: shortenedUrl
129+
? copyLinkPopoverContent
130+
: generateLinkPopoverContent;
131+
132+
return (
133+
<Popover
134+
popoverClassName="Popover-share"
135+
inheritDarkTheme={false}
136+
content={shareButtonPopoverContent}
137+
>
138+
<Tooltip content="Get shareable link" placement={Position.TOP}>
139+
<ControlButton label="Share" icon={IconNames.SHARE} />
140+
</Tooltip>
141+
</Popover>
142+
);
143+
};

src/commons/mocks/RequestMock.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import * as RequestsSaga from '../utils/RequestHelper';
2+
3+
export class RequestMock {
4+
static noResponse(): typeof RequestsSaga.request {
5+
return () => Promise.resolve(null);
6+
}
7+
8+
static nonOk(textMockFn: jest.Mock = jest.fn()): typeof RequestsSaga.request {
9+
const resp = {
10+
text: textMockFn,
11+
ok: false
12+
} as unknown as Response;
13+
14+
return () => Promise.resolve(resp);
15+
}
16+
17+
static success(
18+
jsonMockFn: jest.Mock = jest.fn(),
19+
textMockFn: jest.Mock = jest.fn()
20+
): typeof RequestsSaga.request {
21+
const resp = {
22+
json: jsonMockFn,
23+
text: textMockFn,
24+
ok: true
25+
} as unknown as Response;
26+
27+
return () => Promise.resolve(resp);
28+
}
29+
}
30+
31+
export const mockTokens = { accessToken: 'access', refreshToken: 'refresherOrb' };

0 commit comments

Comments
 (0)