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 #2890

Draft
wants to merge 37 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
635cde8
connect shared links creation with backend database
Rachelcoll Mar 28, 2024
c4c0747
shared links procedure change
Rachelcoll Mar 28, 2024
5b655e0
shared links url change
Rachelcoll Mar 30, 2024
2b99c40
Merge branch 'master' into shared-links-frontend
RichDom2185 Mar 30, 2024
91b84e1
delete unnecessary lines
Rachelcoll Mar 31, 2024
5218f9d
Merge branch 'shared-links-frontend' of https://github.com/source-aca…
Rachelcoll Mar 31, 2024
a67a6b7
Merge branch 'master' into shared-links-frontend
RichDom2185 Apr 1, 2024
b6f036b
Fix format errors
RichDom2185 Apr 1, 2024
2315ede
Revert lockfile change
RichDom2185 Apr 1, 2024
d133925
Revert TS config change
RichDom2185 Apr 1, 2024
0f0fb61
test check
Rachelcoll Apr 3, 2024
76c2178
Merge branch 'shared-links-frontend' of https://github.com/source-aca…
Rachelcoll Apr 3, 2024
0a356ca
format
Rachelcoll Apr 4, 2024
55c81a8
format
Rachelcoll Apr 4, 2024
557d23a
format
Rachelcoll Apr 4, 2024
ccb8023
Merge branch 'master' into shared-links-frontend
RichDom2185 Apr 6, 2024
91a0a1e
Fix incorrect merge resolution
RichDom2185 Apr 6, 2024
8518e65
Add OOP-oriented implementation of encoding and decoding of share lin…
chownces Apr 7, 2024
433c81e
debug and add decoder oop
Rachelcoll Apr 12, 2024
bfe97cc
debug and add decoder oop
Rachelcoll Apr 12, 2024
b1842b4
Merge branch 'master' into shared-links-frontend
martin-henz Apr 13, 2024
6e7dcc4
remove decoder oop and fix bugs
Rachelcoll Apr 13, 2024
7bd35c8
Merge branch 'master' into shared-links-frontend
martin-henz Apr 13, 2024
cf4c625
Merge branch 'master' into shared-links-frontend
martin-henz Apr 13, 2024
cc412f0
change request method and fix bugs
Rachelcoll Apr 13, 2024
49537fc
Merge branch 'shared-links-frontend' of https://github.com/source-aca…
Rachelcoll Apr 13, 2024
5618a03
Revert lockfile changes
RichDom2185 Apr 13, 2024
fe9eea2
Merge branch 'master' of https://github.com/source-academy/frontend i…
RichDom2185 Apr 13, 2024
d7333da
Merge branch 'master' into shared-links-frontend
RichDom2185 Apr 13, 2024
840e5d5
Merge branch 'master' into shared-links-frontend
RichDom2185 Apr 14, 2024
cd4760e
Shared links frontend refactor (#2937)
chownces Apr 15, 2024
9b701f0
Merge branch 'master' of https://github.com/source-academy/frontend i…
RichDom2185 Apr 15, 2024
b48948a
Merge branch 'master' of https://github.com/source-academy/frontend i…
RichDom2185 May 5, 2024
ecb2063
Merge branch 'master' of https://github.com/source-academy/frontend i…
RichDom2185 May 6, 2024
bde45d1
Merge branch 'master' of https://github.com/source-academy/frontend i…
RichDom2185 May 12, 2024
2cf4672
Merge branch 'master' into shared-links-updated
chownces May 16, 2024
59e8106
Remove redundant playground saga test
chownces May 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
};
260 changes: 129 additions & 131 deletions src/commons/controlBar/ControlBarShareButton.tsx
Original file line number Diff line number Diff line change
@@ -1,145 +1,143 @@
import {
NonIdealState,
Popover,
Position,
Spinner,
SpinnerSize,
Text,
Tooltip
} from '@blueprintjs/core';
import { NonIdealState, Popover, Position, Spinner, SpinnerSize, Tooltip } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import React from 'react';
import { useHotkeys } from '@mantine/hooks';
import React, { useRef, useState } from 'react';
import * as CopyToClipboard from 'react-copy-to-clipboard';
import JsonEncoderDelegate from 'src/features/playground/shareLinks/encoder/delegates/JsonEncoderDelegate';
import UrlParamsEncoderDelegate from 'src/features/playground/shareLinks/encoder/delegates/UrlParamsEncoderDelegate';
import { usePlaygroundConfigurationEncoder } from 'src/features/playground/shareLinks/encoder/EncoderHooks';

import ControlButton from '../ControlButton';
import Constants from '../utils/Constants';
import { externalUrlShortenerRequest } from '../sagas/PlaygroundSaga';
import { postSharedProgram } from '../sagas/RequestsSaga';
import Constants, { Links } from '../utils/Constants';
import { showSuccessMessage, showWarningMessage } from '../utils/notifications/NotificationsHelper';

type ControlBarShareButtonProps = DispatchProps & StateProps;

type DispatchProps = {
handleGenerateLz?: () => void;
handleShortenURL: (s: string) => void;
handleUpdateShortURL: (s: string) => void;
};

type StateProps = {
queryString?: string;
shortURL?: string;
key: string;
type ControlBarShareButtonProps = {
isSicp?: boolean;
};

type State = {
keyword: string;
isLoading: boolean;
};
/**
* Generates the share link for programs in the Playground.
*
* For playground-only (no backend) deployments:
* - Generate a URL with playground configuration encoded as hash parameters
* - URL sent to external URL shortener service
* - Shortened URL displayed to user
* - (note: SICP CodeSnippets use these hash parameters)
*
* For 'with backend' deployments:
* - Send the playground configuration to the backend
* - Backend stores configuration and assigns a UUID
* - Backend pings the external URL shortener service with UUID link
* - Shortened URL returned to Frontend and displayed to user
*/
export const ControlBarShareButton: React.FC<ControlBarShareButtonProps> = props => {
const shareInputElem = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [shortenedUrl, setShortenedUrl] = useState('');
const [customStringKeyword, setCustomStringKeyword] = useState('');
const playgroundConfiguration = usePlaygroundConfigurationEncoder();

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.shareInputElem = React.createRef();
this.state = { keyword: '', isLoading: 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.props.shortURL || 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}
onClick={() => {
this.props.handleShortenURL(this.state.keyword);
this.setState({ isLoading: true });
}}
/>
</div>
) : (
<div>
<NonIdealState
description="Generating Shareable Link..."
icon={<Spinner size={SpinnerSize.SMALL} />}
/>
</div>
)
) : (
<div key={this.props.shortURL}>
<input defaultValue={this.props.shortURL} readOnly={true} ref={this.shareInputElem} />
<Tooltip content="Copy link to clipboard">
<CopyToClipboard text={this.props.shortURL}>
<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 generateLinkBackend = () => {
setIsLoading(true);

private toggleButton() {
if (this.props.handleGenerateLz) {
this.props.handleGenerateLz();
}
customStringKeyword;

const configuration = playgroundConfiguration.encodeWith(new JsonEncoderDelegate());

return postSharedProgram(configuration)
.then(({ shortenedUrl }) => setShortenedUrl(shortenedUrl))
.catch(err => showWarningMessage(err.toString()))
.finally(() => setIsLoading(false));
};

const generateLinkPlaygroundOnly = () => {
const hash = playgroundConfiguration.encodeWith(new UrlParamsEncoderDelegate());
setIsLoading(true);

// reset state
this.props.handleUpdateShortURL('');
this.setState({ keyword: '', isLoading: false });
}
return externalUrlShortenerRequest(hash, customStringKeyword)
.then(({ shortenedUrl, message }) => {
setShortenedUrl(shortenedUrl);
if (message) showSuccessMessage(message);
})
.catch(err => showWarningMessage(err.toString()))
.finally(() => setIsLoading(false));
};

private handleChange(event: React.FormEvent<HTMLInputElement>) {
this.setState({ keyword: event.currentTarget.value });
}
const generateLinkSicp = () => {
const hash = playgroundConfiguration.encodeWith(new UrlParamsEncoderDelegate());
const shortenedUrl = `${Links.playground}#${hash}`;
setShortenedUrl(shortenedUrl);
};

private selectShareInputText() {
if (this.shareInputElem.current !== null) {
this.shareInputElem.current.focus();
this.shareInputElem.current.select();
const generateLink = props.isSicp
? generateLinkSicp
: Constants.playgroundOnly
? generateLinkPlaygroundOnly
: generateLinkBackend;

useHotkeys([['ctrl+e', generateLink]], []);

const handleCustomStringChange = (event: React.FormEvent<HTMLInputElement>) => {
setCustomStringKeyword(event.currentTarget.value);
};

// For visual effect of highlighting the text field on copy
const selectShareInputText = () => {
if (shareInputElem.current !== null) {
shareInputElem.current.focus();
shareInputElem.current.select();
}
}
}
};

const generateLinkPopoverContent = (
<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