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

@ -55,6 +55,7 @@ module.exports = {
"@storybook/addon-styling", "@storybook/addon-styling",
"@storybook/addon-knobs", "@storybook/addon-knobs",
"storybook-addon-pseudo-states", "storybook-addon-pseudo-states",
"storybook-addon-cookie",
], ],
framework: { framework: {
name: '@storybook/react-webpack5', name: '@storybook/react-webpack5',

View File

@ -28,6 +28,11 @@ const preview: Preview = {
date: /Date$/, date: /Date$/,
}, },
}, },
options: {
storySort: {
order: ['UI', 'Modules', 'Pages'],
},
},
}, },
}; };

View File

@ -155,6 +155,7 @@
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"storybook": "^7.0.22", "storybook": "^7.0.22",
"storybook-addon-cookie": "^3.0.1",
"storybook-addon-pseudo-states": "^2.1.0", "storybook-addon-pseudo-states": "^2.1.0",
"ts-jest": "^29.1.0", "ts-jest": "^29.1.0",
"typescript": "^4.9.3", "typescript": "^4.9.3",
@ -164,8 +165,9 @@
"workerDirectory": "public" "workerDirectory": "public"
}, },
"nyc": { "nyc": {
"lines": 60, "lines": 65,
"statements": 60, "statements": 65,
"functions": 60,
"exclude": [ "exclude": [
"src/generated/**/*" "src/generated/**/*"
] ]

View File

@ -2,7 +2,6 @@ import type { Meta, StoryObj } from '@storybook/react';
import { App } from '~/App'; import { App } from '~/App';
import { graphqlMocks } from '~/testing/graphqlMocks'; import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedUserJWT } from '~/testing/mock-data/jwt';
import { render } from './shared'; import { render } from './shared';
@ -16,14 +15,6 @@ export type Story = StoryObj<typeof App>;
export const Default: Story = { export const Default: Story = {
render, render,
loaders: [
async () => ({
accessTokenStored: window.localStorage.setItem(
'accessToken',
mockedUserJWT,
),
}),
],
parameters: { parameters: {
msw: graphqlMocks, msw: graphqlMocks,
}, },

View File

@ -30,7 +30,7 @@ const StyledCommentBody = styled.div`
text-align: left; text-align: left;
`; `;
export function CommentThreadItem({ comment, actionBar }: OwnProps) { export function Comment({ comment, actionBar }: OwnProps) {
return ( return (
<StyledContainer> <StyledContainer>
<CommentHeader comment={comment} actionBar={actionBar} /> <CommentHeader comment={comment} actionBar={actionBar} />

View File

@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { Comment } from '../Comment';
import { mockComment, mockCommentWithLongValues } from './mock-comment';
const meta: Meta<typeof Comment> = {
title: 'Modules/Activity/Comment/Comment',
component: Comment,
};
export default meta;
type Story = StoryObj<typeof Comment>;
export const Default: Story = {
render: getRenderWrapperForComponent(<Comment comment={mockComment} />),
};
export const WithLongValues: Story = {
render: getRenderWrapperForComponent(
<Comment comment={mockCommentWithLongValues} />,
),
};

View File

@ -1,92 +1,34 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { DateTime } from 'luxon';
import { CommentThreadActionBar } from '@/activities/right-drawer/components/CommentThreadActionBar'; import { CommentThreadActionBar } from '@/activities/right-drawer/components/CommentThreadActionBar';
import { CommentForDrawer } from '@/activities/types/CommentForDrawer';
import { mockedUsersData } from '~/testing/mock-data/users';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { CommentHeader } from '../CommentHeader'; import { CommentHeader } from '../CommentHeader';
import { mockComment, mockCommentWithLongValues } from './mock-comment';
const meta: Meta<typeof CommentHeader> = { const meta: Meta<typeof CommentHeader> = {
title: 'Modules/Comments/CommentHeader', title: 'Modules/Activity/Comment/CommentHeader',
component: CommentHeader, component: CommentHeader,
}; };
export default meta; export default meta;
type Story = StoryObj<typeof CommentHeader>; type Story = StoryObj<typeof CommentHeader>;
const mockUser = mockedUsersData[0];
const mockComment: Pick<CommentForDrawer, 'id' | 'author' | 'createdAt'> = {
id: 'fake_comment_1_uuid',
author: {
id: 'fake_comment_1_author_uuid',
displayName: mockUser.displayName ?? '',
firstName: mockUser.firstName ?? '',
lastName: mockUser.lastName ?? '',
avatarUrl: mockUser.avatarUrl,
},
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
};
const mockCommentWithLongName: Pick<
CommentForDrawer,
'id' | 'author' | 'createdAt'
> = {
id: 'fake_comment_2_uuid',
author: {
id: 'fake_comment_2_author_uuid',
displayName: mockUser.displayName + ' with a very long suffix' ?? '',
firstName: mockUser.firstName ?? '',
lastName: mockUser.lastName ?? '',
avatarUrl: mockUser.avatarUrl,
},
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
};
export const Default: Story = { export const Default: Story = {
render: getRenderWrapperForComponent( render: getRenderWrapperForComponent(<CommentHeader comment={mockComment} />),
<CommentHeader
comment={{
...mockComment,
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
}}
/>,
),
}; };
export const FewDaysAgo: Story = { export const FewDaysAgo: Story = {
render: getRenderWrapperForComponent( render: getRenderWrapperForComponent(<CommentHeader comment={mockComment} />),
<CommentHeader
comment={{
...mockComment,
createdAt: DateTime.now().minus({ days: 2 }).toISO() ?? '',
}}
/>,
),
}; };
export const FewMonthsAgo: Story = { export const FewMonthsAgo: Story = {
render: getRenderWrapperForComponent( render: getRenderWrapperForComponent(<CommentHeader comment={mockComment} />),
<CommentHeader
comment={{
...mockComment,
createdAt: DateTime.now().minus({ months: 2 }).toISO() ?? '',
}}
/>,
),
}; };
export const FewYearsAgo: Story = { export const FewYearsAgo: Story = {
render: getRenderWrapperForComponent( render: getRenderWrapperForComponent(<CommentHeader comment={mockComment} />),
<CommentHeader
comment={{
...mockComment,
createdAt: DateTime.now().minus({ years: 2 }).toISO() ?? '',
}}
/>,
),
}; };
export const WithoutAvatar: Story = { export const WithoutAvatar: Story = {
@ -98,7 +40,6 @@ export const WithoutAvatar: Story = {
...mockComment.author, ...mockComment.author,
avatarUrl: '', avatarUrl: '',
}, },
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
}} }}
/>, />,
), ),
@ -108,12 +49,11 @@ export const WithLongUserName: Story = {
render: getRenderWrapperForComponent( render: getRenderWrapperForComponent(
<CommentHeader <CommentHeader
comment={{ comment={{
...mockCommentWithLongName, ...mockCommentWithLongValues,
author: { author: {
...mockCommentWithLongName.author, ...mockCommentWithLongValues.author,
avatarUrl: '', avatarUrl: '',
}, },
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
}} }}
/>, />,
), ),
@ -122,10 +62,7 @@ export const WithLongUserName: Story = {
export const WithActionBar: Story = { export const WithActionBar: Story = {
render: getRenderWrapperForComponent( render: getRenderWrapperForComponent(
<CommentHeader <CommentHeader
comment={{ comment={mockComment}
...mockComment,
createdAt: DateTime.now().minus({ days: 2 }).toISO() ?? '',
}}
actionBar={<CommentThreadActionBar commentThreadId="test-id" />} actionBar={<CommentThreadActionBar commentThreadId="test-id" />}
/>, />,
), ),

View File

@ -0,0 +1,40 @@
import { DateTime } from 'luxon';
import { CommentForDrawer } from '@/activities/types/CommentForDrawer';
import { mockedUsersData } from '~/testing/mock-data/users';
const mockUser = mockedUsersData[0];
export const mockComment: Pick<
CommentForDrawer,
'id' | 'author' | 'createdAt' | 'body' | 'updatedAt'
> = {
id: 'fake_comment_1_uuid',
body: 'Hello, this is a comment.',
author: {
id: 'fake_comment_1_author_uuid',
displayName: mockUser.displayName ?? '',
firstName: mockUser.firstName ?? '',
lastName: mockUser.lastName ?? '',
avatarUrl: mockUser.avatarUrl,
},
createdAt: DateTime.fromFormat('2021-03-12', 'yyyy-MM-dd').toISO() ?? '',
updatedAt: DateTime.fromFormat('2021-03-13', 'yyyy-MM-dd').toISO() ?? '',
};
export const mockCommentWithLongValues: Pick<
CommentForDrawer,
'id' | 'author' | 'createdAt' | 'body' | 'updatedAt'
> = {
id: 'fake_comment_2_uuid',
body: 'Hello, this is a comment. Hello, this is a comment. Hello, this is a comment. Hello, this is a comment. Hello, this is a comment. Hello, this is a comment.',
author: {
id: 'fake_comment_2_author_uuid',
displayName: mockUser.displayName + ' with a very long suffix' ?? '',
firstName: mockUser.firstName ?? '',
lastName: mockUser.lastName ?? '',
avatarUrl: mockUser.avatarUrl,
},
createdAt: DateTime.fromFormat('2021-03-12', 'yyyy-MM-dd').toISO() ?? '',
updatedAt: DateTime.fromFormat('2021-03-13', 'yyyy-MM-dd').toISO() ?? '',
};

View File

@ -8,7 +8,7 @@ import { AutosizeTextInput } from '@/ui/input/components/AutosizeTextInput';
import { CommentThread, useCreateCommentMutation } from '~/generated/graphql'; import { CommentThread, useCreateCommentMutation } from '~/generated/graphql';
import { isNonEmptyString } from '~/utils/isNonEmptyString'; import { isNonEmptyString } from '~/utils/isNonEmptyString';
import { CommentThreadItem } from '../comment/CommentThreadItem'; import { Comment } from '../comment/Comment';
import { GET_COMMENT_THREAD } from '../queries'; import { GET_COMMENT_THREAD } from '../queries';
import { CommentForDrawer } from '../types/CommentForDrawer'; import { CommentForDrawer } from '../types/CommentForDrawer';
@ -80,7 +80,7 @@ export function CommentThreadComments({ commentThread }: OwnProps) {
<StyledThreadItemListContainer> <StyledThreadItemListContainer>
<StyledThreadCommentTitle>Comments</StyledThreadCommentTitle> <StyledThreadCommentTitle>Comments</StyledThreadCommentTitle>
{commentThread?.comments?.map((comment, index) => ( {commentThread?.comments?.map((comment, index) => (
<CommentThreadItem key={comment.id} comment={comment} /> <Comment key={comment.id} comment={comment} />
))} ))}
</StyledThreadItemListContainer> </StyledThreadItemListContainer>
</> </>

View File

@ -152,8 +152,11 @@ export function CommentThreadRelationPicker({ commentThread }: OwnProps) {
placement: 'bottom-start', placement: 'bottom-start',
}); });
useListenClickOutsideArrayOfRef([refs.floating, refs.domReference], () => { useListenClickOutsideArrayOfRef({
exitEditMode(); refs: [refs.floating, refs.domReference],
callback: () => {
exitEditMode();
},
}); });
const selectedEntities = flatMapAndSortEntityForSelectArrayOfArrayByName([ const selectedEntities = flatMapAndSortEntityForSelectArrayOfArrayByName([

View File

@ -7,7 +7,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
const meta: Meta<typeof EntityBoard> = { const meta: Meta<typeof EntityBoard> = {
title: 'UI/Board/Board', title: 'Modules/Companies/Board',
component: EntityBoard, component: EntityBoard,
decorators: [BoardDecorator], decorators: [BoardDecorator],
}; };

View File

@ -6,7 +6,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
const meta: Meta<typeof CompanyBoardCard> = { const meta: Meta<typeof CompanyBoardCard> = {
title: 'UI/Board/CompanyBoardCard', title: 'Modules/Companies/CompanyBoardCard',
component: CompanyBoardCard, component: CompanyBoardCard,
decorators: [BoardCardDecorator], decorators: [BoardCardDecorator],
}; };

View File

@ -7,7 +7,7 @@ import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { PersonChip } from '../PersonChip'; import { PersonChip } from '../PersonChip';
const meta: Meta<typeof PersonChip> = { const meta: Meta<typeof PersonChip> = {
title: 'Modules/Companies/PersonChip', title: 'Modules/People/PersonChip',
component: PersonChip, component: PersonChip,
}; };

View File

@ -72,9 +72,13 @@ export function NameFields({
}, 500); }, 500);
useEffect(() => { useEffect(() => {
if (!currentUser) {
return;
}
if ( if (
currentUser?.firstName !== firstName || currentUser.firstName !== firstName ||
currentUser?.lastName !== lastName currentUser.lastName !== lastName
) { ) {
debouncedUpdate(); debouncedUpdate();
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import styled from '@emotion/styled';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu'; 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 { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { IconChevronDown } from '@/ui/icon/index'; import { IconChevronDown } from '@/ui/icon/index';
@ -105,7 +105,10 @@ function DropdownButton({
}; };
const dropdownRef = useRef(null); const dropdownRef = useRef(null);
useOutsideAlerter({ ref: dropdownRef, callback: onOutsideClick }); useListenClickOutsideArrayOfRef({
refs: [dropdownRef],
callback: onOutsideClick,
});
return ( return (
<StyledDropdownButtonContainer> <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'; import { isDefined } from '~/utils/isDefined';
export function useListenClickOutsideArrayOfRef<T extends Element>( export enum ClickOutsideMode {
arrayOfRef: Array<React.RefObject<T>>, absolute = 'absolute',
outsideClickCallback: (event?: MouseEvent | TouchEvent) => void, 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(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent | TouchEvent) { function handleClickOutside(event: MouseEvent | TouchEvent) {
const clickedOnAtLeastOneRef = arrayOfRef if (mode === ClickOutsideMode.dom) {
.filter((ref) => !!ref.current) const clickedOnAtLeastOneRef = refs
.some((ref) => ref.current?.contains(event.target as Node)); .filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
if (!clickedOnAtLeastOneRef) { if (!clickedOnAtLeastOneRef) {
outsideClickCallback(event); 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) => const hasAtLeastOneRefDefined = refs.some((ref) => isDefined(ref.current));
isDefined(ref.current),
);
if (hasAtLeastOneRefDefined) { if (hasAtLeastOneRefDefined) {
document.addEventListener('mouseup', handleClickOutside); document.addEventListener('click', handleClickOutside);
document.addEventListener('touchend', handleClickOutside); document.addEventListener('touchend', handleClickOutside);
} }
return () => { return () => {
document.removeEventListener('mouseup', handleClickOutside); document.removeEventListener('click', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside); document.removeEventListener('touchend', handleClickOutside);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [refs, callback, mode]);
}, [arrayOfRef, outsideClickCallback]);
} }

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 !== ''; const showCreateButton = isDefined(onCreate) && searchFilter !== '';
useListenClickOutsideArrayOfRef([containerRef], () => { useListenClickOutsideArrayOfRef({
onCancel?.(); refs: [containerRef],
callback: () => {
onCancel?.();
},
}); });
return ( return (

View File

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

View File

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

View File

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

View File

@ -1,11 +0,0 @@
{ /* Companies.mdx */ }
import { Canvas, Meta } from '@storybook/blocks';
import * as Companies from './Companies.stories';
<Meta of={Companies} />
# Companies View
<Canvas of={Companies.Default} />

View File

@ -1,4 +1,5 @@
import { getOperationName } from '@apollo/client/utilities'; import { getOperationName } from '@apollo/client/utilities';
import { expect } from '@storybook/jest';
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/testing-library'; import { within } from '@storybook/testing-library';
import { graphql } from 'msw'; import { graphql } from 'msw';
@ -25,19 +26,66 @@ export default meta;
export type Story = StoryObj<typeof CompanyShow>; export type Story = StoryObj<typeof CompanyShow>;
const companyShowCommonGraphqlMocks = [
graphql.query(
getOperationName(GET_COMMENT_THREADS_BY_TARGETS) ?? '',
(req, res, ctx) => {
return res(
ctx.data({
findManyCommentThreads: mockedCommentThreads,
}),
);
},
),
graphql.query(getOperationName(GET_COMPANY) ?? '', (req, res, ctx) => {
return res(
ctx.data({
findUniqueCompany: mockedCompaniesData[0],
}),
);
}),
];
export const Default: Story = { export const Default: Story = {
render: getRenderWrapperForPage(
<CompanyShow />,
'/companies/89bb825c-171e-4bcc-9cf7-43448d6fb278',
),
parameters: {
msw: [...graphqlMocks, ...companyShowCommonGraphqlMocks],
},
};
export const EditNote: Story = {
render: getRenderWrapperForPage( render: getRenderWrapperForPage(
<CompanyShow />, <CompanyShow />,
'/companies/89bb825c-171e-4bcc-9cf7-43448d6fb278', '/companies/89bb825c-171e-4bcc-9cf7-43448d6fb278',
), ),
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const notesButton = await canvas.findByText('Note'); const firstNoteTitle = await canvas.findByText('My very first note');
await notesButton.click(); await firstNoteTitle.click();
expect(
await canvas.findByDisplayValue('My very first note'),
).toBeInTheDocument();
const workspaceName = await canvas.findByText('Twenty');
await workspaceName.click();
expect(await canvas.queryByDisplayValue('My very first note')).toBeNull();
const noteButton = await canvas.findByText('Note');
await noteButton.click();
expect(
await canvas.findByDisplayValue('My very first note'),
).toBeInTheDocument();
}, },
parameters: { parameters: {
msw: [ msw: [
...graphqlMocks, ...graphqlMocks,
...companyShowCommonGraphqlMocks,
graphql.mutation( graphql.mutation(
getOperationName(CREATE_COMMENT_THREAD_WITH_COMMENT) ?? '', getOperationName(CREATE_COMMENT_THREAD_WITH_COMMENT) ?? '',
(req, res, ctx) => { (req, res, ctx) => {
@ -48,33 +96,17 @@ export const Default: Story = {
); );
}, },
), ),
graphql.query(
getOperationName(GET_COMMENT_THREADS_BY_TARGETS) ?? '',
(req, res, ctx) => {
return res(
ctx.data({
findManyCommentThreads: mockedCommentThreads,
}),
);
},
),
graphql.query( graphql.query(
getOperationName(GET_COMMENT_THREAD) ?? '', getOperationName(GET_COMMENT_THREAD) ?? '',
(req, res, ctx) => { (req, res, ctx) => {
console.log('coucou');
return res( return res(
ctx.data({ ctx.data({
findManyCommentThreads: mockedCommentThreads[0], findManyCommentThreads: [mockedCommentThreads[0]],
}), }),
); );
}, },
), ),
graphql.query(getOperationName(GET_COMPANY) ?? '', (req, res, ctx) => {
return res(
ctx.data({
findUniqueCompany: mockedCompaniesData[0],
}),
);
}),
], ],
}, },
}; };

View File

@ -6,7 +6,7 @@ import { getRenderWrapperForPage } from '~/testing/renderWrappers';
import { Opportunities } from '../Opportunities'; import { Opportunities } from '../Opportunities';
const meta: Meta<typeof Opportunities> = { const meta: Meta<typeof Opportunities> = {
title: 'Pages/Opportunities', title: 'Pages/Opportunities/Default',
component: Opportunities, component: Opportunities,
}; };

View File

@ -1,11 +0,0 @@
{ /* People.mdx */ }
import { Canvas, Meta } from '@storybook/blocks';
import * as People from './People.stories';
<Meta of={People} />
# People View
<Canvas of={People.Default} />

View File

@ -1,6 +1,8 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/testing-library';
import { graphqlMocks } from '~/testing/graphqlMocks'; import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedUserJWT } from '~/testing/mock-data/jwt';
import { getRenderWrapperForPage } from '~/testing/renderWrappers'; import { getRenderWrapperForPage } from '~/testing/renderWrappers';
import { SettingsProfile } from '../SettingsProfile'; import { SettingsProfile } from '../SettingsProfile';
@ -16,6 +18,21 @@ export type Story = StoryObj<typeof SettingsProfile>;
export const Default: Story = { export const Default: Story = {
render: getRenderWrapperForPage(<SettingsProfile />, '/settings/profile'), render: getRenderWrapperForPage(<SettingsProfile />, '/settings/profile'),
parameters: {
msw: graphqlMocks,
cookie: {
tokenPair: `{%22accessToken%22:{%22token%22:%22${mockedUserJWT}%22%2C%22expiresAt%22:%222023-07-18T15:06:40.704Z%22%2C%22__typename%22:%22AuthToken%22}%2C%22refreshToken%22:{%22token%22:%22${mockedUserJWT}%22%2C%22expiresAt%22:%222023-10-15T15:06:41.558Z%22%2C%22__typename%22:%22AuthToken%22}%2C%22__typename%22:%22AuthTokenPair%22}`,
},
},
};
export const LogOut: Story = {
render: getRenderWrapperForPage(<SettingsProfile />, '/settings/profile'),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const logoutButton = await canvas.findByText('Logout');
await logoutButton.click();
},
parameters: { parameters: {
msw: graphqlMocks, msw: graphqlMocks,
}, },

View File

@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { graphqlMocks } from '~/testing/graphqlMocks'; import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedUserJWT } from '~/testing/mock-data/jwt';
import { getRenderWrapperForPage } from '~/testing/renderWrappers'; import { getRenderWrapperForPage } from '~/testing/renderWrappers';
import { SettingsWorkspaceMembers } from '../SettingsWorkspaceMembers'; import { SettingsWorkspaceMembers } from '../SettingsWorkspaceMembers';
@ -21,5 +22,8 @@ export const Default: Story = {
), ),
parameters: { parameters: {
msw: graphqlMocks, msw: graphqlMocks,
cookie: {
tokenPair: `{%22accessToken%22:{%22token%22:%22${mockedUserJWT}%22%2C%22expiresAt%22:%222023-07-18T15:06:40.704Z%22%2C%22__typename%22:%22AuthToken%22}%2C%22refreshToken%22:{%22token%22:%22${mockedUserJWT}%22%2C%22expiresAt%22:%222023-10-15T15:06:41.558Z%22%2C%22__typename%22:%22AuthToken%22}%2C%22__typename%22:%22AuthTokenPair%22}`,
},
}, },
}; };

View File

@ -8,6 +8,7 @@ import { GET_PEOPLE, UPDATE_PERSON } from '@/people/queries';
import { GET_PIPELINE_PROGRESS, GET_PIPELINES } from '@/pipeline/queries'; import { GET_PIPELINE_PROGRESS, GET_PIPELINES } from '@/pipeline/queries';
import { import {
SEARCH_COMPANY_QUERY, SEARCH_COMPANY_QUERY,
SEARCH_PEOPLE_QUERY,
SEARCH_USER_QUERY, SEARCH_USER_QUERY,
} from '@/search/queries/search'; } from '@/search/queries/search';
import { GET_CURRENT_USER } from '@/users/queries'; import { GET_CURRENT_USER } from '@/users/queries';
@ -15,6 +16,7 @@ import {
GetCompaniesQuery, GetCompaniesQuery,
GetPeopleQuery, GetPeopleQuery,
SearchCompanyQuery, SearchCompanyQuery,
SearchPeopleQuery,
SearchUserQuery, SearchUserQuery,
} from '~/generated/graphql'; } from '~/generated/graphql';
@ -61,6 +63,26 @@ export const graphqlMocks = [
); );
}, },
), ),
graphql.query(
getOperationName(SEARCH_PEOPLE_QUERY) ?? '',
(req, res, ctx) => {
const returnedMockedData = filterAndSortData<
SearchPeopleQuery['searchResults'][0]
>(
mockedPeopleData,
req.variables.where,
Array.isArray(req.variables.orderBy)
? req.variables.orderBy
: [req.variables.orderBy],
req.variables.limit,
);
return res(
ctx.data({
searchResults: returnedMockedData,
}),
);
},
),
graphql.query(getOperationName(SEARCH_USER_QUERY) ?? '', (req, res, ctx) => { graphql.query(getOperationName(SEARCH_USER_QUERY) ?? '', (req, res, ctx) => {
const returnedMockedData = filterAndSortData< const returnedMockedData = filterAndSortData<
SearchUserQuery['searchResults'][0] SearchUserQuery['searchResults'][0]

View File

@ -1,4 +1,5 @@
import { import {
ActivityType,
Comment, Comment,
CommentableType, CommentableType,
CommentThread, CommentThread,
@ -11,6 +12,7 @@ type MockedCommentThread = Pick<
| 'createdAt' | 'createdAt'
| 'updatedAt' | 'updatedAt'
| '__typename' | '__typename'
| 'type'
| 'body' | 'body'
| 'title' | 'title'
| 'authorId' | 'authorId'
@ -42,6 +44,7 @@ export const mockedCommentThreads: Array<MockedCommentThread> = [
createdAt: '2023-04-26T10:12:42.33625+00:00', createdAt: '2023-04-26T10:12:42.33625+00:00',
updatedAt: '2023-04-26T10:23:42.33625+00:00', updatedAt: '2023-04-26T10:23:42.33625+00:00',
title: 'My very first note', title: 'My very first note',
type: ActivityType.Note,
body: null, body: null,
author: { author: {
id: '374fe3a5-df1e-4119-afe0-2a62a2ba481e', id: '374fe3a5-df1e-4119-afe0-2a62a2ba481e',
@ -88,6 +91,7 @@ export const mockedCommentThreads: Array<MockedCommentThread> = [
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
title: 'Another note', title: 'Another note',
body: null, body: null,
type: ActivityType.Note,
author: { author: {
id: '374fe3a5-df1e-4119-afe0-2a62a2ba481e', id: '374fe3a5-df1e-4119-afe0-2a62a2ba481e',
firstName: 'Charles', firstName: 'Charles',

View File

@ -37,6 +37,7 @@ export const mockedCompaniesData: Array<MockedCompany> = [
displayName: 'Charles Test', displayName: 'Charles Test',
firstName: 'Charles', firstName: 'Charles',
lastName: 'Test', lastName: 'Test',
avatarUrl: null,
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
__typename: 'User', __typename: 'User',
}, },

View File

@ -17209,6 +17209,11 @@ store2@^2.14.2:
resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.2.tgz#56138d200f9fe5f582ad63bc2704dbc0e4a45068" resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.2.tgz#56138d200f9fe5f582ad63bc2704dbc0e4a45068"
integrity sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w== integrity sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w==
storybook-addon-cookie@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/storybook-addon-cookie/-/storybook-addon-cookie-3.0.1.tgz#e692fb72ce52063bf427eeac68dc46641da3178f"
integrity sha512-ne3jttK7rcJ2Lc5eZPi6h6/erKyIcfzuNPxLtvAssl+HRUtJ6OB4iEvYFFV9/nTxsuNlBkUILEkImqRFf/Bppg==
storybook-addon-pseudo-states@^2.1.0: storybook-addon-pseudo-states@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/storybook-addon-pseudo-states/-/storybook-addon-pseudo-states-2.1.0.tgz#05faffb7e0d19fc012035ecee2a02d432f312d2d" resolved "https://registry.yarnpkg.com/storybook-addon-pseudo-states/-/storybook-addon-pseudo-states-2.1.0.tgz#05faffb7e0d19fc012035ecee2a02d432f312d2d"