@codecademy/codebytes 1.0.4-alpha.18ad182ab.0 → 1.0.4-alpha.41a141e2a.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.eslintrc.json +35 -0
  2. package/CHANGELOG.md +14 -0
  3. package/babel.config.js +22 -0
  4. package/jest.config.ts +21 -0
  5. package/package.json +3 -8
  6. package/project.json +52 -0
  7. package/{dist/MonacoEditor/colorsDark.js → src/MonacoEditor/colorsDark.ts} +16 -8
  8. package/src/MonacoEditor/index.tsx +56 -0
  9. package/src/MonacoEditor/theme.ts +65 -0
  10. package/src/MonacoEditor/types.ts +1 -0
  11. package/src/__tests__/codebyte.test.tsx +186 -0
  12. package/src/__tests__/editor.test.tsx +108 -0
  13. package/src/__tests__/helpers.test.tsx +39 -0
  14. package/src/__tests__/language-selection.test.tsx +14 -0
  15. package/src/api.ts +28 -0
  16. package/src/codeByteEditor.tsx +115 -0
  17. package/src/consts.ts +64 -0
  18. package/src/drawers.tsx +133 -0
  19. package/src/editor.tsx +162 -0
  20. package/src/helpers/index.ts +8 -0
  21. package/src/helpers/useEverInView.test.ts +29 -0
  22. package/src/helpers/useEverInView.ts +28 -0
  23. package/src/helpers/useIntersection.test.ts +87 -0
  24. package/src/helpers/useIntersection.ts +35 -0
  25. package/{dist/index.d.ts → src/index.ts} +0 -0
  26. package/src/languageSelection.tsx +26 -0
  27. package/src/libs/eventTracking.ts +18 -0
  28. package/{dist → src}/theme.d.ts +0 -0
  29. package/src/types.ts +30 -0
  30. package/tsconfig.json +27 -0
  31. package/tsconfig.spec.json +21 -0
  32. package/dist/MonacoEditor/colorsDark.d.ts +0 -32
  33. package/dist/MonacoEditor/index.d.ts +0 -8
  34. package/dist/MonacoEditor/index.js +0 -42
  35. package/dist/MonacoEditor/theme.d.ts +0 -2
  36. package/dist/MonacoEditor/theme.js +0 -123
  37. package/dist/MonacoEditor/types.d.ts +0 -1
  38. package/dist/MonacoEditor/types.js +0 -1
  39. package/dist/api.d.ts +0 -12
  40. package/dist/api.js +0 -41
  41. package/dist/codeByteEditor.d.ts +0 -4
  42. package/dist/codeByteEditor.js +0 -141
  43. package/dist/consts.d.ts +0 -23
  44. package/dist/consts.js +0 -34
  45. package/dist/drawers.d.ts +0 -6
  46. package/dist/drawers.js +0 -149
  47. package/dist/editor.d.ts +0 -15
  48. package/dist/editor.js +0 -194
  49. package/dist/helpers/index.d.ts +0 -2
  50. package/dist/helpers/index.js +0 -12
  51. package/dist/helpers/useEverInView.d.ts +0 -5
  52. package/dist/helpers/useEverInView.js +0 -45
  53. package/dist/helpers/useEverInView.test.js +0 -63
  54. package/dist/helpers/useIntersection.d.ts +0 -2
  55. package/dist/helpers/useIntersection.js +0 -42
  56. package/dist/helpers/useIntersection.test.js +0 -127
  57. package/dist/index.js +0 -3
  58. package/dist/languageSelection.d.ts +0 -6
  59. package/dist/languageSelection.js +0 -22
  60. package/dist/libs/eventTracking.d.ts +0 -1
  61. package/dist/libs/eventTracking.js +0 -11
  62. package/dist/types.d.ts +0 -22
  63. package/dist/types.js +0 -1
@@ -0,0 +1,14 @@
1
+ import { setupRtl } from '@codecademy/gamut-tests';
2
+
3
+ import { LanguageSelection } from '../languageSelection';
4
+
5
+ const renderWrapper = setupRtl(LanguageSelection, {
6
+ onChange: () => null,
7
+ });
8
+
9
+ describe('LanguageSelection', () => {
10
+ it('has placeholder text', () => {
11
+ const { view } = renderWrapper();
12
+ view.getByText('Which language do you want to code in?');
13
+ });
14
+ });
package/src/api.ts ADDED
@@ -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
+ };
@@ -0,0 +1,115 @@
1
+ import { Box, IconButton } from '@codecademy/gamut';
2
+ import { FaviconIcon } from '@codecademy/gamut-icons';
3
+ import { Background, system } from '@codecademy/gamut-styles';
4
+ import { StyleProps } from '@codecademy/variance';
5
+ import styled from '@emotion/styled';
6
+ import React, { useEffect, useState } from 'react';
7
+
8
+ import { helloWorld, LanguageOption } from './consts';
9
+ import { Editor } from './editor';
10
+ import { trackClick } from './helpers';
11
+ import { useEverInView } from './helpers/useEverInView';
12
+ import { LanguageSelection } from './languageSelection';
13
+ import { trackUserImpression } from './libs/eventTracking';
14
+ import { CodeByteEditorProps } from './types';
15
+
16
+ const editorBaseStyles = system.css({
17
+ border: 1,
18
+ borderColor: 'gray-900',
19
+ display: 'flex',
20
+ flexDirection: 'column',
21
+ minHeight: '25rem',
22
+ });
23
+
24
+ const EditorContainer = styled(Background)<StyleProps<typeof editorBaseStyles>>(
25
+ editorBaseStyles
26
+ );
27
+
28
+ export const CodeByteEditor: React.FC<CodeByteEditorProps> = ({
29
+ text: initialText,
30
+ language: initialLanguage,
31
+ hideCopyButton = false,
32
+ snippetsBaseUrl,
33
+ onEdit,
34
+ onLanguageChange,
35
+ copyFormatter,
36
+ trackingData,
37
+ trackFirstEdit = false,
38
+ ...rest
39
+ }) => {
40
+ const { everInView, ref } = useEverInView();
41
+
42
+ const getInitialText = () => {
43
+ if (initialText !== undefined) return initialText;
44
+ return initialLanguage ? helloWorld[initialLanguage] : '';
45
+ };
46
+
47
+ const [text, setText] = useState<string>(getInitialText());
48
+ const [language, setLanguage] = useState<LanguageOption>(
49
+ initialLanguage ?? ''
50
+ );
51
+ const [hasBeenEdited, setHasBeenEdited] = useState(false);
52
+
53
+ useEffect(() => {
54
+ if (everInView) {
55
+ trackUserImpression({
56
+ page_name: trackingData?.page_name ?? 'Unknown',
57
+ context: trackingData?.context ?? document.referrer,
58
+ target: 'codebyte',
59
+ });
60
+ }
61
+ }, [everInView, trackingData]);
62
+
63
+ return (
64
+ <EditorContainer
65
+ bg="black"
66
+ maxWidth="43rem"
67
+ {...rest}
68
+ overflow="hidden"
69
+ ref={ref}
70
+ >
71
+ <Box borderBottom={1} borderColor="gray-900" py={4} pl={8}>
72
+ <IconButton
73
+ icon={FaviconIcon}
74
+ variant="secondary"
75
+ href="https://www.codecademy.com/"
76
+ target="_blank"
77
+ rel="noreferrer"
78
+ aria-label="visit codecademy.com"
79
+ onClick={() => trackClick('logo', trackingData)}
80
+ />
81
+ </Box>
82
+ {language ? (
83
+ <Editor
84
+ language={language}
85
+ text={text}
86
+ hideCopyButton={hideCopyButton}
87
+ onChange={(newText: string) => {
88
+ setText(newText);
89
+ onEdit?.(newText, language);
90
+ if (trackFirstEdit && hasBeenEdited === false) {
91
+ setHasBeenEdited(true);
92
+ trackClick('edit', trackingData);
93
+ }
94
+ }}
95
+ snippetsBaseUrl={snippetsBaseUrl}
96
+ copyFormatter={copyFormatter}
97
+ trackingData={trackingData}
98
+ />
99
+ ) : (
100
+ <LanguageSelection
101
+ onChange={(newLanguage) => {
102
+ const newText: string =
103
+ text || (newLanguage ? helloWorld[newLanguage] : '');
104
+ setLanguage(newLanguage);
105
+ setText(newText);
106
+ trackClick('lang_select', trackingData);
107
+ onLanguageChange?.(newText, newLanguage);
108
+ }}
109
+ />
110
+ )}
111
+ </EditorContainer>
112
+ );
113
+ };
114
+
115
+ export default CodeByteEditor;
package/src/consts.ts ADDED
@@ -0,0 +1,64 @@
1
+ // key = language param to send to snippets service
2
+ // val = label in language selection drop down
3
+ export const LanguageOptions = {
4
+ '': 'Select a 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;
16
+
17
+ export const validLanguages = Object.keys(LanguageOptions).filter(
18
+ (option) => !!option
19
+ ) as Exclude<LanguageOption, ''>[];
20
+
21
+ const cpp = `#include <iostream>
22
+ int main() {
23
+ std::cout << "Hello world!";
24
+ return 0;
25
+ }`;
26
+
27
+ const csharp = `namespace HelloWorld {
28
+ class Hello {
29
+ static void Main(string[] args) {
30
+ System.Console.WriteLine("Hello world!");
31
+ }
32
+ }
33
+ }`;
34
+
35
+ const golang = `package main
36
+ import "fmt"
37
+ func main() {
38
+ fmt.Println("Hello world!")
39
+ }`;
40
+
41
+ const javascript = "console.log('Hello world!');";
42
+
43
+ const php = `<?php
44
+ echo "Hello world!";
45
+ ?>`;
46
+
47
+ const python = "print('Hello world!')";
48
+
49
+ const ruby = 'puts "Hello world!"';
50
+
51
+ const scheme = `(begin
52
+ (display "Hello world!")
53
+ (newline))`;
54
+
55
+ export const helloWorld = {
56
+ cpp,
57
+ csharp,
58
+ golang,
59
+ javascript,
60
+ php,
61
+ python,
62
+ ruby,
63
+ scheme,
64
+ } as const;
@@ -0,0 +1,133 @@
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
+ role="region"
125
+ open={!isLeftOpen}
126
+ overflow="hidden"
127
+ >
128
+ {rightChild}
129
+ </Drawer>
130
+ </FlexBox>
131
+ </>
132
+ );
133
+ };
package/src/editor.tsx ADDED
@@ -0,0 +1,162 @@
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 { UserClickData } from '@codecademy/tracking';
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
+ import { trackClick } from './helpers';
17
+ import { SimpleMonacoEditor } from './MonacoEditor';
18
+ import { CodebytesCopyFormatter } from './types';
19
+
20
+ const Output = styled.pre<{ hasError: boolean }>`
21
+ width: 100%;
22
+ height: 100%;
23
+ margin: 0;
24
+ padding: 0 1rem;
25
+ font-family: Monaco;
26
+ font-size: 0.875rem;
27
+ overflow: auto;
28
+ ${({ hasError, theme }) => `
29
+ color: ${hasError ? theme.colors.orange : theme.colors.text};
30
+ background-color: ${theme.colors['navy-900']};
31
+ `}
32
+ `;
33
+
34
+ const CopyIconStyled = styled(CopyIcon)`
35
+ margin-right: 0.5rem;
36
+ `;
37
+
38
+ const DOCKER_SIGTERM = 143;
39
+
40
+ type EditorProps = {
41
+ hideCopyButton: boolean;
42
+ language: LanguageOption;
43
+ text: string;
44
+ onChange: (text: string) => void;
45
+ snippetsBaseUrl?: string;
46
+ copyFormatter?: CodebytesCopyFormatter;
47
+ trackingData?: Omit<UserClickData, 'target'>;
48
+ };
49
+
50
+ export const Editor: React.FC<EditorProps> = ({
51
+ language,
52
+ text,
53
+ hideCopyButton,
54
+ onChange,
55
+ copyFormatter,
56
+ snippetsBaseUrl,
57
+ trackingData,
58
+ }) => {
59
+ const [output, setOutput] = useState('');
60
+ const [status, setStatus] = useState<'ready' | 'waiting' | 'error'>('ready');
61
+ const [isCodeByteCopied, setIsCodeByteCopied] = useState(false);
62
+ const onCopyClick = () => {
63
+ if (!isCodeByteCopied) {
64
+ navigator.clipboard
65
+ .writeText(copyFormatter ? copyFormatter({ text, language }) : text)
66
+
67
+ // eslint-disable-next-line no-console
68
+ .catch(() => console.error('Failed to copy'));
69
+ setIsCodeByteCopied(true);
70
+ trackClick('copy', trackingData);
71
+ }
72
+ };
73
+
74
+ const setErrorStatusAndOutput = (message: string) => {
75
+ setOutput(message);
76
+ setStatus('error');
77
+ };
78
+
79
+ const handleSubmit = async () => {
80
+ if (text.trim().length === 0) {
81
+ return;
82
+ }
83
+ const data = {
84
+ language,
85
+ code: text,
86
+ };
87
+ setStatus('waiting');
88
+ setOutput('');
89
+ trackClick('run', trackingData);
90
+
91
+ try {
92
+ const response = await postSnippet(data, snippetsBaseUrl);
93
+ if (response.stderr.length > 0) {
94
+ setErrorStatusAndOutput(response.stderr);
95
+ } else if (response.exit_code === DOCKER_SIGTERM) {
96
+ setErrorStatusAndOutput(
97
+ 'Your code took too long to return a result. Double check your code for any issues and try again!'
98
+ );
99
+ } else if (response.exit_code !== 0) {
100
+ setErrorStatusAndOutput('An unknown error occured.');
101
+ } else {
102
+ setOutput(response.stdout);
103
+ setStatus('ready');
104
+ }
105
+ } catch (error) {
106
+ setErrorStatusAndOutput('Error: ' + error);
107
+ }
108
+ };
109
+
110
+ return (
111
+ <>
112
+ <Drawers
113
+ leftChild={
114
+ <SimpleMonacoEditor
115
+ value={text}
116
+ language={language}
117
+ onChange={onChange}
118
+ />
119
+ }
120
+ rightChild={
121
+ <Output hasError={status === 'error'} aria-live="polite">
122
+ {output}
123
+ </Output>
124
+ }
125
+ />
126
+ <FlexBox
127
+ justifyContent={hideCopyButton ? 'flex-end' : 'space-between'}
128
+ pl={8}
129
+ >
130
+ {!hideCopyButton ? (
131
+ <ToolTip
132
+ id="codebyte-copied"
133
+ alignment="top-right"
134
+ target={
135
+ <TextButton
136
+ variant="secondary"
137
+ onClick={onCopyClick}
138
+ onBlur={() => setIsCodeByteCopied(false)}
139
+ data-testid="copy-codebyte-btn"
140
+ >
141
+ <CopyIconStyled aria-hidden="true" /> Copy Codebyte
142
+ </TextButton>
143
+ }
144
+ >
145
+ {isCodeByteCopied ? (
146
+ <span data-testid="copy-confirmation-tooltip" role="alert">
147
+ Copied!
148
+ </span>
149
+ ) : (
150
+ <span data-testid="copy-prompt-tooltip">
151
+ Copy to your clipboard
152
+ </span>
153
+ )}
154
+ </ToolTip>
155
+ ) : null}
156
+ <FillButton onClick={handleSubmit}>
157
+ {status === 'waiting' ? <Spinner /> : 'Run'}
158
+ </FillButton>
159
+ </FlexBox>
160
+ </>
161
+ );
162
+ };
@@ -0,0 +1,8 @@
1
+ import { UserClickData } from '@codecademy/tracking';
2
+
3
+ import { trackUserClick } from '../libs/eventTracking';
4
+
5
+ export const trackClick = (
6
+ target: string,
7
+ trackingData?: Omit<UserClickData, 'target'>
8
+ ) => trackUserClick({ ...trackingData, target });
@@ -0,0 +1,29 @@
1
+ import { renderHook } from '@testing-library/react-hooks';
2
+
3
+ import { useEverInView } from './useEverInView';
4
+
5
+ const mockUseIntersection = jest.fn();
6
+
7
+ jest.mock('./useIntersection', () => ({
8
+ get useIntersection() {
9
+ return mockUseIntersection;
10
+ },
11
+ }));
12
+
13
+ const ref = { current: null };
14
+
15
+ describe('useEverInView', () => {
16
+ it('returns true after inView becomes false after true', async () => {
17
+ mockUseIntersection.mockReturnValueOnce(null);
18
+ const { result, rerender } = renderHook(() => useEverInView());
19
+ expect(result.current).toEqual({ everInView: false, ref });
20
+
21
+ mockUseIntersection.mockReturnValueOnce({ isIntersecting: true });
22
+ rerender();
23
+ expect(result.current).toEqual({ everInView: true, ref });
24
+
25
+ mockUseIntersection.mockReturnValueOnce({ isIntersecting: false });
26
+ rerender();
27
+ expect(result.current).toEqual({ everInView: true, ref });
28
+ });
29
+ });
@@ -0,0 +1,28 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ import { useIntersection } from './useIntersection';
4
+
5
+ // Set either rootMargin or threshold for your use case. The threshold
6
+ // will determine the amount of the element that needs to come into the viewport
7
+ // before the useEverInView hook is triggered. The rootMargin will determine the
8
+ // size of the root element's bounding box.
9
+ // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#intersection_observer_options
10
+ export const useEverInView = (rootMargin = '0px', threshold = 0.2) => {
11
+ const ref = useRef(null);
12
+ const [everInView, setEverInView] = useState(false);
13
+
14
+ const intersection = useIntersection(ref, {
15
+ root: null,
16
+ rootMargin,
17
+ threshold,
18
+ });
19
+
20
+ const inView = intersection?.isIntersecting;
21
+ useEffect(() => {
22
+ if (inView) {
23
+ setEverInView(true);
24
+ }
25
+ }, [inView]);
26
+
27
+ return { everInView: Boolean(everInView || inView), ref };
28
+ };
@@ -0,0 +1,87 @@
1
+ import { act, renderHook } from '@testing-library/react-hooks/dom';
2
+
3
+ import { useIntersection } from './useIntersection';
4
+
5
+ const originalIntersectionObserver = window.IntersectionObserver;
6
+ const mockObserve = jest.fn();
7
+ const mockDisconnect = jest.fn();
8
+ const mockIntersectionObserver = jest.fn();
9
+ const mockIntersectionObserverInstance = {
10
+ observe: mockObserve,
11
+ disconnect: mockDisconnect,
12
+ };
13
+ const ref = { current: document.createElement('div') };
14
+ const options = {
15
+ root: null,
16
+ rootMargin: '0px',
17
+ threshold: 0.2,
18
+ };
19
+
20
+ beforeEach(() => {
21
+ mockIntersectionObserver.mockReturnValue(mockIntersectionObserverInstance);
22
+ window.IntersectionObserver = mockIntersectionObserver;
23
+ });
24
+
25
+ afterEach(() => {
26
+ window.IntersectionObserver = originalIntersectionObserver;
27
+ });
28
+
29
+ describe('useIntersection', () => {
30
+ test('intersection observer is not called', async () => {
31
+ // We render useIntersection with ref.current = null,
32
+ // which should prevent our hook from calling intersectionObserver.
33
+ const hook = renderHook(() => useIntersection({ current: null }, options));
34
+ expect(mockObserve).not.toHaveBeenCalled();
35
+
36
+ hook.unmount();
37
+ expect(mockDisconnect).not.toHaveBeenCalled();
38
+ });
39
+
40
+ test('intersection observer is called', async () => {
41
+ // We render useIntersection with ref.current = HTMLdivElement,
42
+ // which should make our hook call intersectionObserver.
43
+ const hook = renderHook(() => useIntersection(ref, options));
44
+ expect(mockObserve).toHaveBeenCalledWith(ref.current);
45
+
46
+ hook.unmount();
47
+ expect(mockDisconnect).toHaveBeenCalled();
48
+ });
49
+
50
+ it('returns the IntersectionObserverEntry from the callback', async () => {
51
+ const fakeIntersectionObserverEntry = {};
52
+ let handler: Function = () => {
53
+ return null;
54
+ };
55
+
56
+ // We mock the insersectionObserver Implementation
57
+ // to grab the handler, which allows us to simulate
58
+ // the element getting scrolled into view.
59
+ mockIntersectionObserver.mockImplementationOnce((cb) => {
60
+ handler = cb;
61
+
62
+ return mockIntersectionObserverInstance;
63
+ });
64
+ const { result, rerender, unmount } = renderHook(() =>
65
+ useIntersection(ref, options)
66
+ );
67
+
68
+ // Element hasn't been scrolled into view and therefore
69
+ // results.current should be null.
70
+ expect(result.current).toBeNull();
71
+
72
+ // Simulate element being scrolled into view
73
+ act(() => {
74
+ handler([fakeIntersectionObserverEntry]);
75
+ });
76
+ expect(result.current).toEqual(fakeIntersectionObserverEntry);
77
+
78
+ // We expect the results to remain the same after a rerender
79
+ rerender();
80
+ expect(result.current).toEqual(fakeIntersectionObserverEntry);
81
+
82
+ // The results should be null after unmounting.
83
+ unmount();
84
+ rerender();
85
+ expect(result.current).toBeNull();
86
+ });
87
+ });