Improve test coverage and refactor storybook arch (#723)

* Improve test coverage and refactor storybook arch

* Fix coverage

* Fix tests

* Fix lint

* Fix lint
This commit is contained in:
Charles Bochet
2023-07-17 17:14:53 -07:00
committed by GitHub
parent 5b21657c4e
commit a972705ce6
43 changed files with 365 additions and 274 deletions

View File

@ -43,8 +43,11 @@ export function BoardCardEditableFieldEditMode({
}: OwnProps) {
const wrapperRef = useRef(null);
useListenClickOutsideArrayOfRef([wrapperRef], () => {
onExit();
useListenClickOutsideArrayOfRef({
refs: [wrapperRef],
callback: () => {
onExit();
},
});
useScopedHotkeys(

View File

@ -40,8 +40,11 @@ export function EditColumnTitleInput({
}) {
const inputRef = React.useRef<HTMLInputElement>(null);
useListenClickOutsideArrayOfRef([inputRef], () => {
onFocusLeave();
useListenClickOutsideArrayOfRef({
refs: [inputRef],
callback: () => {
onFocusLeave();
},
});
const setHotkeyScope = useSetHotkeyScope();
setHotkeyScope(ColumnHotkeyScope.EditColumnName, { goto: false });

View File

@ -83,14 +83,20 @@ const StyledIconButton = styled.button<Pick<ButtonProps, 'variant' | 'size'>>`
}
}};
justify-content: center;
outline: none;
padding: 0;
transition: background 0.1s ease;
user-select: none;
&:hover {
background: ${({ theme, disabled }) => {
return disabled ? 'auto' : theme.background.transparent.light;
}};
}
user-select: none;
&:active {
background: ${({ theme, disabled }) => {
return disabled ? 'auto' : theme.background.transparent.medium;
}};
}
width: ${({ size }) => {
switch (size) {
case 'large':
@ -102,11 +108,6 @@ const StyledIconButton = styled.button<Pick<ButtonProps, 'variant' | 'size'>>`
return '20px';
}
}};
&:active {
background: ${({ theme, disabled }) => {
return disabled ? 'auto' : theme.background.transparent.medium;
}};
}
`;
export function IconButton({

View File

@ -14,15 +14,16 @@ const StyledIconButton = styled.button`
justify-content: center;
outline: none;
padding: 0;
transition: color 0.1s ease-in-out, background 0.1s ease-in-out;
width: 20px;
&:disabled {
background: ${({ theme }) => theme.background.quaternary};
color: ${({ theme }) => theme.font.color.tertiary};
cursor: default;
}
width: 20px;
`;
export function RoundedIconButton({

View File

@ -56,7 +56,7 @@ const StyledButtonContainer = styled.div`
`;
const meta: Meta<typeof Button> = {
title: 'UI/Buttons/Button',
title: 'UI/Button/Button',
component: Button,
decorators: [withKnobs],
};

View File

@ -53,7 +53,7 @@ const StyledIconButtonContainer = styled.div`
`;
const meta: Meta<typeof IconButton> = {
title: 'UI/Buttons/IconButton',
title: 'UI/Button/IconButton',
component: IconButton,
decorators: [withKnobs],
};

View File

@ -8,7 +8,7 @@ import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { MainButton } from '../MainButton';
const meta: Meta<typeof MainButton> = {
title: 'UI/Buttons/MainButton',
title: 'UI/Button/MainButton',
component: MainButton,
};

View File

@ -8,7 +8,7 @@ import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { RoundedIconButton } from '../RoundedIconButton';
const meta: Meta<typeof RoundedIconButton> = {
title: 'UI/Buttons/RoundedIconButton',
title: 'UI/Button/RoundedIconButton',
component: RoundedIconButton,
};

View File

@ -15,7 +15,7 @@ import { DropdownMenuSelectableItem } from '../DropdownMenuSelectableItem';
import { DropdownMenuSeparator } from '../DropdownMenuSeparator';
const meta: Meta<typeof DropdownMenu> = {
title: 'UI/Menu/DropdownMenu',
title: 'UI/Dropdown/DropdownMenu',
component: DropdownMenu,
};

View File

@ -12,11 +12,14 @@ export function useRegisterCloseFieldHandlers(
) {
const { closeEditableField, isFieldInEditMode } = useEditableField();
useListenClickOutsideArrayOfRef([wrapperRef], () => {
if (isFieldInEditMode) {
onSubmit?.();
closeEditableField();
}
useListenClickOutsideArrayOfRef({
refs: [wrapperRef],
callback: () => {
if (isFieldInEditMode) {
onSubmit?.();
closeEditableField();
}
},
});
useScopedHotkeys(

View File

@ -3,7 +3,7 @@ import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
import { useOutsideAlerter } from '@/ui/hooks/useOutsideAlerter';
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { IconChevronDown } from '@/ui/icon/index';
@ -105,7 +105,10 @@ function DropdownButton({
};
const dropdownRef = useRef(null);
useOutsideAlerter({ ref: dropdownRef, callback: onOutsideClick });
useListenClickOutsideArrayOfRef({
refs: [dropdownRef],
callback: onOutsideClick,
});
return (
<StyledDropdownButtonContainer>

View File

@ -0,0 +1,39 @@
import { useRef } from 'react';
import { fireEvent, render } from '@testing-library/react';
import { useListenClickOutsideArrayOfRef } from '../useListenClickOutsideArrayOfRef';
const onOutsideClick = jest.fn();
function TestComponentDomMode() {
const buttonRef = useRef(null);
const buttonRef2 = useRef(null);
useListenClickOutsideArrayOfRef({
refs: [buttonRef, buttonRef2],
callback: onOutsideClick,
});
return (
<div>
<span>Outside</span>
<button ref={buttonRef}>Inside</button>
<button ref={buttonRef2}>Inside 2</button>
</div>
);
}
test('useListenClickOutsideArrayOfRef hook works in dom mode', async () => {
const { getByText } = render(<TestComponentDomMode />);
const inside = getByText('Inside');
const inside2 = getByText('Inside 2');
const outside = getByText('Outside');
fireEvent.click(inside);
expect(onOutsideClick).toHaveBeenCalledTimes(0);
fireEvent.click(inside2);
expect(onOutsideClick).toHaveBeenCalledTimes(0);
fireEvent.click(outside);
expect(onOutsideClick).toHaveBeenCalledTimes(1);
});

View File

@ -1,31 +0,0 @@
import { useRef } from 'react';
import { act } from 'react-dom/test-utils';
import { fireEvent, render } from '@testing-library/react';
import { useOutsideAlerter } from '../useOutsideAlerter';
const onOutsideClick = jest.fn();
function TestComponent() {
const buttonRef = useRef(null);
useOutsideAlerter({ ref: buttonRef, callback: onOutsideClick });
return (
<div>
<span>Outside</span>
<button ref={buttonRef}>Inside</button>
</div>
);
}
test('useOutsideAlerter hook works properly', async () => {
const { getByText } = render(<TestComponent />);
const inside = getByText('Inside');
const outside = getByText('Outside');
await act(() => Promise.resolve());
fireEvent.mouseDown(inside);
expect(onOutsideClick).toHaveBeenCalledTimes(0);
fireEvent.mouseDown(outside);
expect(onOutsideClick).toHaveBeenCalledTimes(1);
});

View File

@ -2,34 +2,75 @@ import React, { useEffect } from 'react';
import { isDefined } from '~/utils/isDefined';
export function useListenClickOutsideArrayOfRef<T extends Element>(
arrayOfRef: Array<React.RefObject<T>>,
outsideClickCallback: (event?: MouseEvent | TouchEvent) => void,
) {
export enum ClickOutsideMode {
absolute = 'absolute',
dom = 'dom',
}
export function useListenClickOutsideArrayOfRef<T extends Element>({
refs,
callback,
mode = ClickOutsideMode.dom,
}: {
refs: Array<React.RefObject<T>>;
callback: (event?: MouseEvent | TouchEvent) => void;
mode?: ClickOutsideMode;
}) {
useEffect(() => {
function handleClickOutside(event: MouseEvent | TouchEvent) {
const clickedOnAtLeastOneRef = arrayOfRef
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
if (mode === ClickOutsideMode.dom) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
if (!clickedOnAtLeastOneRef) {
outsideClickCallback(event);
if (!clickedOnAtLeastOneRef) {
callback(event);
}
}
if (mode === ClickOutsideMode.absolute) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => {
if (!ref.current) {
return false;
}
const { x, y, width, height } = ref.current.getBoundingClientRect();
const clientX =
'clientX' in event ? event.clientX : event.touches[0].clientX;
const clientY =
'clientY' in event ? event.clientY : event.touches[0].clientY;
console.log(clientX, clientY, x, y, width, height);
if (
clientX < x ||
clientX > x + width ||
clientY < y ||
clientY > y + height
) {
return false;
}
return true;
});
if (!clickedOnAtLeastOneRef) {
callback(event);
}
}
}
const hasAtLeastOneRefDefined = arrayOfRef.some((ref) =>
isDefined(ref.current),
);
const hasAtLeastOneRefDefined = refs.some((ref) => isDefined(ref.current));
if (hasAtLeastOneRefDefined) {
document.addEventListener('mouseup', handleClickOutside);
document.addEventListener('click', handleClickOutside);
document.addEventListener('touchend', handleClickOutside);
}
return () => {
document.removeEventListener('mouseup', handleClickOutside);
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [arrayOfRef, outsideClickCallback]);
}, [refs, callback, mode]);
}

View File

@ -1,52 +0,0 @@
import { useEffect } from 'react';
export enum OutsideClickAlerterMode {
absolute = 'absolute',
dom = 'dom',
}
type OwnProps = {
ref: React.RefObject<HTMLInputElement>;
callback: () => void;
mode?: OutsideClickAlerterMode;
};
export function useOutsideAlerter({
ref,
mode = OutsideClickAlerterMode.dom,
callback,
}: OwnProps) {
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLButtonElement;
if (!ref.current) {
return;
}
if (
mode === OutsideClickAlerterMode.dom &&
!ref.current.contains(target)
) {
callback();
}
if (mode === OutsideClickAlerterMode.absolute) {
const { x, y, width, height } = ref.current.getBoundingClientRect();
const { clientX, clientY } = event;
if (
clientX < x ||
clientX > x + width ||
clientY < y ||
clientY > y + height
) {
callback();
}
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref, callback, mode]);
}

View File

@ -46,8 +46,11 @@ export function SingleEntitySelect<
const showCreateButton = isDefined(onCreate) && searchFilter !== '';
useListenClickOutsideArrayOfRef([containerRef], () => {
onCancel?.();
useListenClickOutsideArrayOfRef({
refs: [containerRef],
callback: () => {
onCancel?.();
},
});
return (

View File

@ -5,9 +5,9 @@ import { motion } from 'framer-motion';
import { useRecoilState } from 'recoil';
import {
OutsideClickAlerterMode,
useOutsideAlerter,
} from '@/ui/hooks/useOutsideAlerter';
ClickOutsideMode,
useListenClickOutsideArrayOfRef,
} from '@/ui/hooks/useListenClickOutsideArrayOfRef';
import { isDefined } from '~/utils/isDefined';
import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState';
@ -41,10 +41,10 @@ export function RightDrawer() {
const [rightDrawerPage] = useRecoilState(rightDrawerPageState);
const rightDrawerRef = useRef(null);
useOutsideAlerter({
ref: rightDrawerRef,
useListenClickOutsideArrayOfRef({
refs: [rightDrawerRef],
callback: () => setIsRightDrawerOpen(false),
mode: OutsideClickAlerterMode.absolute,
mode: ClickOutsideMode.absolute,
});
const theme = useTheme();
if (!isDefined(rightDrawerPage)) {

View File

@ -90,8 +90,11 @@ export function EntityTable<SortField>({
const leaveTableFocus = useLeaveTableFocus();
useListenClickOutsideArrayOfRef([tableBodyRef], () => {
leaveTableFocus();
useListenClickOutsideArrayOfRef({
refs: [tableBodyRef],
callback: () => {
leaveTableFocus();
},
});
return (

View File

@ -14,11 +14,14 @@ export function useRegisterCloseCellHandlers(
) {
const { closeEditableCell } = useEditableCell();
const { isCurrentCellInEditMode } = useCurrentCellEditMode();
useListenClickOutsideArrayOfRef([wrapperRef], () => {
if (isCurrentCellInEditMode) {
onSubmit?.();
closeEditableCell();
}
useListenClickOutsideArrayOfRef({
refs: [wrapperRef],
callback: () => {
if (isCurrentCellInEditMode) {
onSubmit?.();
closeEditableCell();
}
},
});
const { moveRight, moveLeft, moveDown } = useMoveSoftFocus();