Skip to content

Commit e84e265

Browse files
authored
feat(Codebytes): add editor and drawers disc 351 (#14)
* working version * update to include gamut styles * add editor container styles * fix prettier issues * pass onCopy from parent * update tslint for scale * incorporate review feedback * fix prettier issues * default snippets endpoint to empty string
1 parent 385adac commit e84e265

File tree

11 files changed

+410
-9
lines changed

11 files changed

+410
-9
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
.DS_Store
22

3+
# Env
4+
.env.*
5+
36
# Logs
47
logs
58
*.log

packages/codebytes/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@codecademy/gamut": "*",
2424
"@codecademy/gamut-icons": "*",
2525
"@codecademy/gamut-styles": "*",
26+
"@codecademy/variance": "*",
2627
"@emotion/react": "^11.4.0",
2728
"@emotion/styled": "^11.3.0"
2829
},

packages/codebytes/src/api.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { languageOption } from './consts';
2+
3+
interface Response {
4+
stderr: string;
5+
stdout: string;
6+
exit_code: number;
7+
}
8+
9+
interface PostSnippetData {
10+
language: languageOption;
11+
code: string;
12+
}
13+
14+
export const postSnippet = async (
15+
data: PostSnippetData,
16+
snippetsBaseUrl?: string
17+
): Promise<Response> => {
18+
const snippetsEndpoint = `https://${snippetsBaseUrl}/snippets`;
19+
20+
const response = await fetch(snippetsEndpoint, {
21+
method: 'POST',
22+
body: JSON.stringify(data),
23+
headers: {
24+
'x-codecademy-user-id': 'codebytes-anon-user',
25+
},
26+
});
27+
return response.json();
28+
};

packages/codebytes/src/consts.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// key = language param to send to snippets service
2+
// val = label in language selection drop down
3+
export const languageOptions = {
4+
'': 'Select your language',
5+
cpp: 'C++',
6+
csharp: 'C#',
7+
golang: 'Go',
8+
javascript: 'JavaScript',
9+
php: 'PHP',
10+
python: 'Python 3',
11+
ruby: 'Ruby',
12+
scheme: 'Scheme',
13+
};
14+
15+
export type languageOption = keyof typeof languageOptions;

packages/codebytes/src/drawers.tsx

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { FlexBox, IconButton } from '@codecademy/gamut';
2+
import {
3+
ArrowChevronLeftIcon,
4+
ArrowChevronRightIcon,
5+
} from '@codecademy/gamut-icons';
6+
import styled from '@emotion/styled';
7+
import React, { useState } from 'react';
8+
9+
const DrawerLabel = styled.span`
10+
padding: 0.875rem 0.5rem;
11+
`;
12+
13+
const LeftDrawerIcon = styled(ArrowChevronLeftIcon)<{ open?: boolean }>`
14+
transition: transform 0.2s ease-in-out;
15+
`;
16+
const RightDrawerIcon = LeftDrawerIcon.withComponent(ArrowChevronRightIcon);
17+
18+
const Drawer = styled(FlexBox)<{ open?: boolean; hideOnClose?: boolean }>`
19+
position: relative;
20+
${({ open, hideOnClose }) => `
21+
flex-basis: ${open ? '100%' : '0%'};
22+
visibility: ${!open && hideOnClose ? 'hidden' : 'visible'};
23+
transition: flex-basis 0.2s ${
24+
open ? 'ease-out' : 'ease-in, visibility 0s 0.2s'
25+
};
26+
27+
${LeftDrawerIcon}, ${RightDrawerIcon} {
28+
transform: rotateZ(${open ? '0' : '180'}deg)};
29+
}
30+
`}
31+
`;
32+
33+
export type DrawersProps = {
34+
leftChild: React.ReactNode;
35+
rightChild: React.ReactNode;
36+
};
37+
38+
export const Drawers: React.FC<DrawersProps> = ({ leftChild, rightChild }) => {
39+
const [open, setOpen] = useState<'left' | 'right' | 'both'>('both');
40+
41+
let ariaLabelCodeButton = 'hide code';
42+
let ariaLabelOutputButton = 'hide output';
43+
let isLeftOpen = false;
44+
let isRightOpen = false;
45+
46+
if (open === 'left') {
47+
ariaLabelCodeButton = ariaLabelOutputButton = 'show output';
48+
isLeftOpen = true;
49+
} else if (open === 'right') {
50+
ariaLabelCodeButton = ariaLabelOutputButton = 'show code';
51+
isRightOpen = true;
52+
}
53+
54+
return (
55+
<>
56+
<FlexBox>
57+
<Drawer
58+
open={!isRightOpen}
59+
alignItems="center"
60+
flexWrap="nowrap"
61+
textAlign="left"
62+
borderRight={1}
63+
borderColor="gray-900"
64+
px={8}
65+
>
66+
<IconButton
67+
icon={LeftDrawerIcon}
68+
variant="secondary"
69+
size="small"
70+
onClick={() =>
71+
setOpen((state) => (state === 'both' ? 'right' : 'both'))
72+
}
73+
aria-label={ariaLabelCodeButton}
74+
aria-controls="code-drawer"
75+
aria-expanded={!isRightOpen}
76+
/>
77+
<DrawerLabel id="code-drawer-label">Code</DrawerLabel>
78+
</Drawer>
79+
<Drawer
80+
open={!isLeftOpen}
81+
alignItems="center"
82+
flexWrap="nowrap"
83+
justifyContent="flex-end"
84+
px={8}
85+
>
86+
<DrawerLabel id="output-drawer-label">Output</DrawerLabel>
87+
<IconButton
88+
icon={RightDrawerIcon}
89+
variant="secondary"
90+
size="small"
91+
onClick={() =>
92+
setOpen((state) => (state === 'both' ? 'left' : 'both'))
93+
}
94+
aria-label={ariaLabelOutputButton}
95+
aria-controls="output-drawer"
96+
aria-expanded={!isLeftOpen}
97+
/>
98+
</Drawer>
99+
</FlexBox>
100+
<FlexBox
101+
flexGrow={1}
102+
borderY={1}
103+
borderColor="gray-900"
104+
alignItems="stretch"
105+
overflow="hidden"
106+
>
107+
<Drawer
108+
hideOnClose
109+
id="code-drawer"
110+
aria-labelledby="code-drawer-label"
111+
open={!isRightOpen}
112+
flexGrow={0}
113+
overflow="hidden"
114+
borderColor="gray-900"
115+
borderStyleRight="solid"
116+
borderWidthRight="thin"
117+
>
118+
{leftChild}
119+
</Drawer>
120+
<Drawer
121+
hideOnClose
122+
id="output-drawer"
123+
aria-labelledby="output-drawer-label"
124+
open={!isLeftOpen}
125+
overflow="hidden"
126+
>
127+
{rightChild}
128+
</Drawer>
129+
</FlexBox>
130+
</>
131+
);
132+
};

packages/codebytes/src/editor.tsx

Lines changed: 141 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,147 @@
1-
import React from 'react';
1+
import {
2+
FillButton,
3+
FlexBox,
4+
Spinner,
5+
TextButton,
6+
ToolTip,
7+
} from '@codecademy/gamut';
8+
import { CopyIcon } from '@codecademy/gamut-icons';
9+
import { theme } from '@codecademy/gamut-styles';
10+
import styled from '@emotion/styled';
11+
import React, { useState } from 'react';
12+
13+
import { postSnippet } from './api';
14+
import type { languageOption } from './consts';
15+
import { Drawers } from './drawers';
16+
17+
const Output = styled.pre<{ hasError: boolean }>`
18+
width: 100%;
19+
height: 100%;
20+
margin: 0;
21+
padding: 0 1rem;
22+
font-family: Monaco;
23+
font-size: 0.875rem;
24+
overflow: auto;
25+
${({ hasError }) => `
26+
color: ${hasError ? theme.colors.orange : theme.colors.white};
27+
background-color: ${theme.colors['gray-900']};
28+
`}
29+
`;
30+
31+
const CopyIconStyled = styled(CopyIcon)`
32+
margin-right: 0.5rem;
33+
`;
34+
35+
const DOCKER_SIGTERM = 143;
236

337
type EditorProps = {
38+
hideCopyButton: boolean;
39+
language: languageOption;
440
text: string;
5-
onChange: (text: string) => void;
41+
// eslint-disable-next-line react/no-unused-prop-types
42+
onChange: (
43+
text: string
44+
) => void /* TODO: Add onChange behavior in DISC-353 */;
45+
onCopy?: (text: string, language: string) => void;
46+
snippetsBaseUrl?: string;
647
};
748

8-
export const Editor: React.FC<EditorProps> = ({ text, onChange }) => {
9-
return <textarea value={text} onChange={(e) => onChange(e.target.value)} />;
49+
export const Editor: React.FC<EditorProps> = ({
50+
language,
51+
text,
52+
hideCopyButton,
53+
onCopy,
54+
snippetsBaseUrl,
55+
}) => {
56+
const [output, setOutput] = useState('');
57+
const [status, setStatus] = useState<'ready' | 'waiting' | 'error'>('ready');
58+
const [isCodeByteCopied, setIsCodeByteCopied] = useState(false);
59+
const onCopyClick = () => {
60+
if (!isCodeByteCopied) {
61+
navigator.clipboard
62+
.writeText(text)
63+
// eslint-disable-next-line no-console
64+
.catch(() => console.error('Failed to copy'));
65+
onCopy?.(text, language);
66+
setIsCodeByteCopied(true);
67+
}
68+
};
69+
70+
const setErrorStatusAndOutput = (message: string) => {
71+
setOutput(message);
72+
setStatus('error');
73+
};
74+
75+
const handleSubmit = async () => {
76+
if (text.trim().length === 0) {
77+
return;
78+
}
79+
const data = {
80+
language,
81+
code: text,
82+
};
83+
setStatus('waiting');
84+
setOutput('');
85+
86+
try {
87+
const response = await postSnippet(data, snippetsBaseUrl);
88+
if (response.stderr.length > 0) {
89+
setErrorStatusAndOutput(response.stderr);
90+
} else if (response.exit_code === DOCKER_SIGTERM) {
91+
setErrorStatusAndOutput(
92+
'Your code took too long to return a result. Double check your code for any issues and try again!'
93+
);
94+
} else if (response.exit_code !== 0) {
95+
setErrorStatusAndOutput('An unknown error occured.');
96+
} else {
97+
setOutput(response.stdout);
98+
setStatus('ready');
99+
}
100+
} catch (error) {
101+
setErrorStatusAndOutput('Error: ' + error);
102+
}
103+
};
104+
105+
return (
106+
<>
107+
<Drawers
108+
leftChild={<div>{text}</div>}
109+
rightChild={
110+
<Output hasError={status === 'error'} aria-live="polite">
111+
{output}
112+
</Output>
113+
}
114+
/>
115+
<FlexBox
116+
justifyContent={hideCopyButton ? 'flex-end' : 'space-between'}
117+
pl={8}
118+
>
119+
{!hideCopyButton ? (
120+
<ToolTip
121+
id="codebyte-copied"
122+
alignment="top-right"
123+
mode="dark"
124+
target={
125+
<TextButton
126+
variant="secondary"
127+
onClick={onCopyClick}
128+
onBlur={() => setIsCodeByteCopied(false)}
129+
>
130+
<CopyIconStyled aria-hidden="true" /> Copy Codebyte
131+
</TextButton>
132+
}
133+
>
134+
{isCodeByteCopied ? (
135+
<span role="alert">Copied!</span>
136+
) : (
137+
<span>Copy to your clipboard</span>
138+
)}
139+
</ToolTip>
140+
) : null}
141+
<FillButton onClick={handleSubmit}>
142+
{status === 'waiting' ? <Spinner /> : 'Run'}
143+
</FillButton>
144+
</FlexBox>
145+
</>
146+
);
10147
};

0 commit comments

Comments
 (0)