Skip to content

Commit 47554c5

Browse files
committed
feat: add share code functionality
1 parent 4c572d0 commit 47554c5

File tree

11 files changed

+215
-2
lines changed

11 files changed

+215
-2
lines changed

eslint.config.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ export default [
3939
'react/jsx-uses-vars': 'error',
4040
'react/prop-types': 'off',
4141
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
42+
'@typescript-eslint/no-unused-vars': [
43+
'error',
44+
{
45+
argsIgnorePattern: '^_',
46+
varsIgnorePattern: '^_',
47+
caughtErrorsIgnorePattern: '^_',
48+
},
49+
],
4250
},
4351
},
4452
];

src/components/App/App.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { AppContext } from 'context/AppContext';
66
import { AppActions } from 'context/Reducer';
77
import History from 'components/History';
88
import About from 'components/About';
9+
import ShareCode from 'components/ShareCode';
10+
import { decompressFromEncodedURIComponent } from 'lz-string';
911

1012
const App: React.FC = () => {
1113
const { dispatch } = useContext(AppContext);
@@ -16,6 +18,20 @@ const App: React.FC = () => {
1618
dispatch({ type: AppActions.CODE_RUN_SUCCESS, payload: msg });
1719
consoleProxy(msg);
1820
};
21+
22+
try {
23+
const params = new URLSearchParams(window.location.search);
24+
const codeParam = params.get('code');
25+
if (codeParam) {
26+
const payload = {
27+
codeSample: decompressFromEncodedURIComponent(codeParam),
28+
codeSampleName: 'URL Code',
29+
};
30+
dispatch({ type: AppActions.LOAD_CODE_SAMPLE, payload });
31+
}
32+
} catch (_) {
33+
// Ignore errors in URL parsing or decompression
34+
}
1935
}, []);
2036

2137
return (
@@ -38,6 +54,7 @@ const App: React.FC = () => {
3854

3955
<History />
4056
<About />
57+
<ShareCode />
4158
</div>
4259
);
4360
};

src/components/Layout/ActionBar.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useContext, useEffect, useState } from 'react';
12
import {
23
Disclosure,
34
DisclosureButton,
@@ -6,16 +7,17 @@ import {
67
import { ChevronRightIcon } from '@heroicons/react/20/solid';
78
import {
89
PlayIcon,
10+
ShareIcon,
911
TrashIcon,
1012
CodeBracketIcon,
1113
ClockIcon,
1214
} from '@heroicons/react/24/solid';
15+
import { compressToEncodedURIComponent } from 'lz-string';
1316
import Spinner from 'components/Spinner';
1417
import { AppContext } from 'context/AppContext';
1518
import { AppActions } from 'context/Reducer';
16-
import { CODE_SAMPLES } from 'helpers/const';
19+
import { CODE_SAMPLES, MAX_SHARE_CODE_LENGTH } from 'helpers/const';
1720
import useCodeRunner from 'hooks/useCodeRunner';
18-
import { useContext } from 'react';
1921

2022
const codeSampleToMenu = CODE_SAMPLES.map(sample => {
2123
const { codeSample, name } = sample;
@@ -51,6 +53,23 @@ const actionBarItems: ActionBarItem[] = [
5153
const ActionBar: React.FC = () => {
5254
const { state, dispatch } = useContext(AppContext);
5355
const { runCode } = useCodeRunner();
56+
const [showShareButton, setShowShareButton] = useState(false);
57+
58+
useEffect(() => {
59+
const { code } = state;
60+
const showShareButton = code.length > 0;
61+
setShowShareButton(showShareButton);
62+
}, [state.code.length]);
63+
64+
const onShareButtonClick = () => {
65+
const compressedCode = compressToEncodedURIComponent(state.code);
66+
const shareUrl = `${window.location.origin}/?code=${compressedCode}`;
67+
if (shareUrl.length > MAX_SHARE_CODE_LENGTH) {
68+
alert('The code is too long to share. Please reduce the code length.');
69+
return;
70+
}
71+
dispatch({ type: AppActions.SET_SHARE_URL, payload: shareUrl });
72+
};
5473

5574
return (
5675
<nav className="flex flex-1 flex-col">
@@ -113,6 +132,17 @@ const ActionBar: React.FC = () => {
113132
)}
114133
</li>
115134
))}
135+
{showShareButton && (
136+
<li>
137+
<a
138+
onClick={onShareButtonClick}
139+
className="text-amber-400 hover:bg-gray-800 hover:amber-600 group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold cursor-pointer"
140+
>
141+
Share Code
142+
<ShareIcon aria-hidden="true" className="size-5 shrink-0" />
143+
</a>
144+
</li>
145+
)}
116146
</ul>
117147
</li>
118148
<li className="-mx-6 mt-auto">
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { act, fireEvent, render, screen } from '@testing-library/react';
2+
import ShareCode from 'components/ShareCode';
3+
import { AppContext } from 'context/AppContext';
4+
5+
describe('<ShareCode />', () => {
6+
const state = {
7+
shareUrl: 'https://abolkog.github.io/js-playground/?code=sample',
8+
} as AppState;
9+
const dispatch = jest.fn();
10+
11+
beforeEach(jest.clearAllMocks);
12+
13+
it('it render share code modal', async () => {
14+
await act(async () => {
15+
render(
16+
<AppContext.Provider value={{ state, dispatch }}>
17+
<ShareCode />
18+
</AppContext.Provider>,
19+
);
20+
});
21+
const shareTextBox = screen.getByRole('textbox');
22+
expect(shareTextBox).toBeInTheDocument();
23+
expect(shareTextBox).toHaveValue(state.shareUrl);
24+
});
25+
26+
it('it copy url to clip board on button click', async () => {
27+
Object.defineProperty(global.navigator, 'clipboard', {
28+
value: {
29+
writeText: jest.fn().mockResolvedValue(undefined),
30+
},
31+
configurable: true,
32+
});
33+
const clipboardWriteText = jest.spyOn(navigator.clipboard, 'writeText');
34+
35+
await act(async () => {
36+
render(
37+
<AppContext.Provider value={{ state, dispatch }}>
38+
<ShareCode />
39+
</AppContext.Provider>,
40+
);
41+
});
42+
const shareButton = screen.getByRole('button');
43+
fireEvent.click(shareButton);
44+
expect(clipboardWriteText).toHaveBeenCalledWith(state.shareUrl);
45+
});
46+
47+
it('it does not render share modal when no url', async () => {
48+
await act(async () => {
49+
render(
50+
<AppContext.Provider
51+
value={{ state: { ...state, shareUrl: '' }, dispatch }}
52+
>
53+
<ShareCode />
54+
</AppContext.Provider>,
55+
);
56+
});
57+
const shareTextBox = screen.queryByRole('textbox');
58+
expect(shareTextBox).not.toBeInTheDocument();
59+
});
60+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useContext } from 'react';
2+
import { AppContext } from 'context/AppContext';
3+
import { AppActions } from 'context/Reducer';
4+
import {
5+
Dialog,
6+
DialogBackdrop,
7+
DialogPanel,
8+
DialogTitle,
9+
} from '@headlessui/react';
10+
import { ShareIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline';
11+
12+
const ShareCode: React.FC = () => {
13+
const { state, dispatch } = useContext(AppContext);
14+
15+
const closeModal = () => {
16+
dispatch({
17+
type: AppActions.SET_SHARE_URL,
18+
payload: '',
19+
});
20+
};
21+
22+
const onShareButtonClick = async () => {
23+
await navigator.clipboard.writeText(state.shareUrl);
24+
closeModal();
25+
};
26+
27+
return (
28+
<Dialog
29+
open={state.shareUrl.length > 0}
30+
onClose={closeModal}
31+
className="relative z-10"
32+
>
33+
<DialogBackdrop
34+
transition
35+
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
36+
/>
37+
38+
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
39+
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
40+
<DialogPanel
41+
transition
42+
className="relative transform overflow-hidden rounded-lg bg-gray-900 text-white/60 px-4 pt-5 pb-4 text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg sm:p-6 data-closed:sm:translate-y-0 data-closed:sm:scale-95"
43+
>
44+
<div>
45+
<div className="mx-auto flex size-12 items-center justify-center rounded-full bg-green-100">
46+
<ShareIcon
47+
aria-hidden="true"
48+
className="size-6 text-green-600"
49+
/>
50+
</div>
51+
<div className="mt-3 text-center sm:mt-5">
52+
<DialogTitle
53+
as="h3"
54+
className="text-base font-semibold text-yellow-500"
55+
>
56+
Share your code
57+
</DialogTitle>
58+
<p>Use the following URL to share code</p>
59+
<div className="mt-2">
60+
<div className="flex items-center gap-2">
61+
<input
62+
type="text"
63+
readOnly
64+
value={state.shareUrl}
65+
className="block w-full rounded-md outline-gray-600 bg-gray-600 px-3 py-1.5 text-white outline-1 -outline-offset-1 focus:outline-2 focus:-outline-offset-2 focus:outline-gray-300 sm:text-sm/6"
66+
/>
67+
<button
68+
type="button"
69+
className="p-2 rounded bg-gray-700 hover:bg-gray-600 transition cursor-pointer"
70+
onClick={onShareButtonClick}
71+
aria-label="Copy to clipboard"
72+
>
73+
<ClipboardDocumentIcon className="size-5 text-white" />
74+
</button>
75+
</div>
76+
</div>
77+
</div>
78+
</div>
79+
</DialogPanel>
80+
</div>
81+
</div>
82+
</Dialog>
83+
);
84+
};
85+
86+
export default ShareCode;

src/components/ShareCode/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from 'components/ShareCode/ShareCode';

src/context/AppContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const initialState: AppState = {
88
codeSampleName: '',
99
result: [],
1010
error: '',
11+
shareUrl: '',
1112
loading: false,
1213
sidebarOpen: false,
1314
historyOpen: false,

src/context/Reducer.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ describe('Reducer tests', () => {
77
codeSampleName: '',
88
result: [],
99
error: '',
10+
shareUrl: '',
1011
loading: false,
1112
sidebarOpen: false,
1213
historyOpen: false,

src/context/Reducer.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const AppActions = {
1414
HIDE_HISTORY: 'HIDE_HISTORY',
1515
SHOW_ABOUT_MODAL: 'SHOW_ABOUT_MODAL',
1616
HIDE_ABOUT_MODAL: 'HIDE_ABOUT_MODAL',
17+
SET_SHARE_URL: 'SET_SHARE_URL',
1718
};
1819

1920
const handleCodeUpdate = (state: AppState, action: Action): AppState => {
@@ -85,6 +86,11 @@ export const reducer = (state: AppState, action: Action): AppState => {
8586
return { ...state, sidebarOpen: true };
8687
case AppActions.HIDE_SIDEBAR:
8788
return { ...state, sidebarOpen: false };
89+
case AppActions.SET_SHARE_URL:
90+
return {
91+
...state,
92+
shareUrl: action.payload as string,
93+
};
8894
default:
8995
return state;
9096
}

src/context/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type AppState = {
88
sidebarOpen: boolean;
99
historyOpen: boolean;
1010
aboutModalOpen: boolean;
11+
shareUrl: string;
1112
};
1213

1314
type Payload = {

0 commit comments

Comments
 (0)