From 62720944faef82860c1ff49367a073a7abb39364 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Sat, 22 Jul 2023 07:09:02 +0200 Subject: [PATCH] Feat/open input not focus (#811) * Fixed click outside * Finished * Fixed tests --- .../components/DropdownButton.tsx | 24 ++---- .../components/DropdownMenuContainer.tsx | 34 +++++++++ .../useListenClickOutsideArrayOfRef.test.tsx | 6 +- .../hooks/useListenClickOutsideArrayOfRef.ts | 22 +++--- .../editable-cell/components/EditableCell.tsx | 5 +- .../components/EditableCellContainer.tsx | 5 ++ .../components/EditableCellDisplayMode.tsx | 4 + .../components/EditableCellSoftFocusMode.tsx | 16 +--- .../__stories__/EditableCellText.stories.tsx | 18 ++++- .../editable-cell/hooks/useEditableCell.ts | 32 +++++++- .../hooks/useRegisterCloseCellHandlers.ts | 5 +- .../hooks/useRegisterEditableCell.ts | 23 ++++++ .../customCellHotkeyScopeScopedState.ts | 11 +++ .../companies/__stories__/Company.stories.tsx | 2 +- .../__stories__/People.inputs.stories.tsx | 73 ++++++++++--------- 15 files changed, 192 insertions(+), 88 deletions(-) create mode 100644 front/src/modules/ui/filter-n-sort/components/DropdownMenuContainer.tsx create mode 100644 front/src/modules/ui/table/editable-cell/hooks/useRegisterEditableCell.ts create mode 100644 front/src/modules/ui/table/editable-cell/states/customCellHotkeyScopeScopedState.ts diff --git a/front/src/modules/ui/filter-n-sort/components/DropdownButton.tsx b/front/src/modules/ui/filter-n-sort/components/DropdownButton.tsx index d5d17ada5..1ede36f95 100644 --- a/front/src/modules/ui/filter-n-sort/components/DropdownButton.tsx +++ b/front/src/modules/ui/filter-n-sort/components/DropdownButton.tsx @@ -1,14 +1,14 @@ -import { ReactNode, useRef } from 'react'; +import { ReactNode } from 'react'; import styled from '@emotion/styled'; import { Key } from 'ts-key-enum'; -import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu'; -import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef'; import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys'; import { IconChevronDown } from '@/ui/icon/index'; import { FiltersHotkeyScope } from '../types/FiltersHotkeyScope'; +import { DropdownMenuContainer } from './DropdownMenuContainer'; + type OwnProps = { label: string; isActive: boolean; @@ -50,12 +50,6 @@ const StyledDropdownButton = styled.div` } `; -export const StyledDropdownMenuContainer = styled.ul` - position: absolute; - right: 0; - top: 14px; -`; - const StyledDropdownTopOption = styled.li` color: ${({ theme }) => theme.font.color.primary}; cursor: pointer; @@ -104,12 +98,6 @@ function DropdownButton({ onIsUnfoldedChange?.(false); }; - const dropdownRef = useRef(null); - useListenClickOutsideArrayOfRef({ - refs: [dropdownRef], - callback: onOutsideClick, - }); - return ( {isUnfolded && ( - - {children} - + + {children} + )} ); diff --git a/front/src/modules/ui/filter-n-sort/components/DropdownMenuContainer.tsx b/front/src/modules/ui/filter-n-sort/components/DropdownMenuContainer.tsx new file mode 100644 index 000000000..1a5bbe0ac --- /dev/null +++ b/front/src/modules/ui/filter-n-sort/components/DropdownMenuContainer.tsx @@ -0,0 +1,34 @@ +import { useRef } from 'react'; +import styled from '@emotion/styled'; + +import { DropdownMenu } from '../../dropdown/components/DropdownMenu'; +import { useListenClickOutsideArrayOfRef } from '../../hooks/useListenClickOutsideArrayOfRef'; + +export const StyledDropdownMenuContainer = styled.ul` + position: absolute; + right: 0; + top: 14px; +`; + +export function DropdownMenuContainer({ + children, + onClose, +}: { + children: React.ReactNode; + onClose?: () => void; +}) { + const dropdownRef = useRef(null); + + useListenClickOutsideArrayOfRef({ + refs: [dropdownRef], + callback: () => { + onClose?.(); + }, + }); + + return ( + + {children} + + ); +} diff --git a/front/src/modules/ui/hooks/__tests__/useListenClickOutsideArrayOfRef.test.tsx b/front/src/modules/ui/hooks/__tests__/useListenClickOutsideArrayOfRef.test.tsx index a331c91f9..b289994c9 100644 --- a/front/src/modules/ui/hooks/__tests__/useListenClickOutsideArrayOfRef.test.tsx +++ b/front/src/modules/ui/hooks/__tests__/useListenClickOutsideArrayOfRef.test.tsx @@ -28,12 +28,12 @@ test('useListenClickOutsideArrayOfRef hook works in dom mode', async () => { const inside2 = getByText('Inside 2'); const outside = getByText('Outside'); - fireEvent.mouseUp(inside); + fireEvent.click(inside); expect(onOutsideClick).toHaveBeenCalledTimes(0); - fireEvent.mouseUp(inside2); + fireEvent.click(inside2); expect(onOutsideClick).toHaveBeenCalledTimes(0); - fireEvent.mouseUp(outside); + fireEvent.click(outside); expect(onOutsideClick).toHaveBeenCalledTimes(1); }); diff --git a/front/src/modules/ui/hooks/useListenClickOutsideArrayOfRef.ts b/front/src/modules/ui/hooks/useListenClickOutsideArrayOfRef.ts index 71bcbb22c..a3a601723 100644 --- a/front/src/modules/ui/hooks/useListenClickOutsideArrayOfRef.ts +++ b/front/src/modules/ui/hooks/useListenClickOutsideArrayOfRef.ts @@ -1,7 +1,5 @@ import React, { useEffect } from 'react'; -import { isDefined } from '~/utils/isDefined'; - export enum ClickOutsideMode { absolute = 'absolute', dom = 'dom', @@ -13,7 +11,7 @@ export function useListenClickOutsideArrayOfRef({ mode = ClickOutsideMode.dom, }: { refs: Array>; - callback: (event?: MouseEvent | TouchEvent) => void; + callback: (event: MouseEvent | TouchEvent) => void; mode?: ClickOutsideMode; }) { useEffect(() => { @@ -59,16 +57,18 @@ export function useListenClickOutsideArrayOfRef({ } } - const hasAtLeastOneRefDefined = refs.some((ref) => isDefined(ref.current)); - - if (hasAtLeastOneRefDefined) { - document.addEventListener('mouseup', handleClickOutside); - document.addEventListener('touchend', handleClickOutside); - } + document.addEventListener('click', handleClickOutside, { capture: true }); + document.addEventListener('touchend', handleClickOutside, { + capture: true, + }); return () => { - document.removeEventListener('mouseup', handleClickOutside); - document.removeEventListener('touchend', handleClickOutside); + document.removeEventListener('click', handleClickOutside, { + capture: true, + }); + document.removeEventListener('touchend', handleClickOutside, { + capture: true, + }); }; }, [refs, callback, mode]); } diff --git a/front/src/modules/ui/table/editable-cell/components/EditableCell.tsx b/front/src/modules/ui/table/editable-cell/components/EditableCell.tsx index b2fc355d1..3d3ad45ff 100644 --- a/front/src/modules/ui/table/editable-cell/components/EditableCell.tsx +++ b/front/src/modules/ui/table/editable-cell/components/EditableCell.tsx @@ -5,6 +5,7 @@ import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope'; import { useCurrentCellEditMode } from '../hooks/useCurrentCellEditMode'; import { useIsSoftFocusOnCurrentCell } from '../hooks/useIsSoftFocusOnCurrentCell'; +import { useRegisterEditableCell } from '../hooks/useRegisterEditableCell'; import { EditableCellDisplayMode } from './EditableCellDisplayMode'; import { EditableCellEditMode } from './EditableCellEditMode'; @@ -48,6 +49,8 @@ export function EditableCell({ const hasSoftFocus = useIsSoftFocusOnCurrentCell(); + useRegisterEditableCell(editHotkeyScope); + return ( {isCurrentCellInEditMode ? ( @@ -62,7 +65,7 @@ export function EditableCell({ {editModeContent} ) : hasSoftFocus ? ( - + {nonEditModeContent} ) : ( diff --git a/front/src/modules/ui/table/editable-cell/components/EditableCellContainer.tsx b/front/src/modules/ui/table/editable-cell/components/EditableCellContainer.tsx index 06c353534..896053788 100644 --- a/front/src/modules/ui/table/editable-cell/components/EditableCellContainer.tsx +++ b/front/src/modules/ui/table/editable-cell/components/EditableCellContainer.tsx @@ -40,6 +40,11 @@ export function EditableCellDisplayContainer({ }: React.PropsWithChildren) { return ( diff --git a/front/src/modules/ui/table/editable-cell/components/EditableCellDisplayMode.tsx b/front/src/modules/ui/table/editable-cell/components/EditableCellDisplayMode.tsx index 18cf45555..9992724d6 100644 --- a/front/src/modules/ui/table/editable-cell/components/EditableCellDisplayMode.tsx +++ b/front/src/modules/ui/table/editable-cell/components/EditableCellDisplayMode.tsx @@ -1,3 +1,4 @@ +import { useEditableCell } from '../hooks/useEditableCell'; import { useSetSoftFocusOnCurrentCell } from '../hooks/useSetSoftFocusOnCurrentCell'; import { EditableCellDisplayContainer } from './EditableCellContainer'; @@ -7,8 +8,11 @@ export function EditableCellDisplayMode({ }: React.PropsWithChildren) { const setSoftFocusOnCurrentCell = useSetSoftFocusOnCurrentCell(); + const { openEditableCell } = useEditableCell(); + function handleClick() { setSoftFocusOnCurrentCell(); + openEditableCell(); } return ( diff --git a/front/src/modules/ui/table/editable-cell/components/EditableCellSoftFocusMode.tsx b/front/src/modules/ui/table/editable-cell/components/EditableCellSoftFocusMode.tsx index fcfc58145..216fcb928 100644 --- a/front/src/modules/ui/table/editable-cell/components/EditableCellSoftFocusMode.tsx +++ b/front/src/modules/ui/table/editable-cell/components/EditableCellSoftFocusMode.tsx @@ -1,7 +1,6 @@ import { PropsWithChildren } from 'react'; import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys'; -import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope'; import { isNonTextWritingKey } from '@/ui/hotkey/utils/isNonTextWritingKey'; import { TableHotkeyScope } from '../../types/TableHotkeyScope'; @@ -9,22 +8,13 @@ import { useEditableCell } from '../hooks/useEditableCell'; import { EditableCellDisplayContainer } from './EditableCellContainer'; -type OwnProps = PropsWithChildren<{ - editHotkeyScope?: HotkeyScope; -}>; +type OwnProps = PropsWithChildren; -export function EditableCellSoftFocusMode({ - children, - editHotkeyScope, -}: OwnProps) { +export function EditableCellSoftFocusMode({ children }: OwnProps) { const { openEditableCell } = useEditableCell(); function openEditMode() { - openEditableCell( - editHotkeyScope ?? { - scope: TableHotkeyScope.CellEditMode, - }, - ); + openEditableCell(); } useScopedHotkeys( diff --git a/front/src/modules/ui/table/editable-cell/components/__stories__/EditableCellText.stories.tsx b/front/src/modules/ui/table/editable-cell/components/__stories__/EditableCellText.stories.tsx index a9d3640b4..ff7f01dab 100644 --- a/front/src/modules/ui/table/editable-cell/components/__stories__/EditableCellText.stories.tsx +++ b/front/src/modules/ui/table/editable-cell/components/__stories__/EditableCellText.stories.tsx @@ -1,3 +1,4 @@ +import { expect } from '@storybook/jest'; import type { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from '@storybook/testing-library'; @@ -28,9 +29,18 @@ export const SoftFocusMode: Story = { ...DisplayMode, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); + await step('Click once', () => userEvent.click(canvas.getByText('Content')), ); + + await step('Escape', () => { + userEvent.keyboard('{esc}'); + }); + + await step('Has soft focus mode', () => { + expect(canvas.getByTestId('editable-cell-soft-focus-mode')).toBeDefined(); + }); }, }; @@ -38,9 +48,15 @@ export const EditMode: Story = { ...DisplayMode, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); + const click = async () => userEvent.click(canvas.getByText('Content')); await step('Click once', click); - await step('Click twice', click); + + await step('Has edit mode', () => { + expect( + canvas.getByTestId('editable-cell-edit-mode-container'), + ).toBeDefined(); + }); }, }; diff --git a/front/src/modules/ui/table/editable-cell/hooks/useEditableCell.ts b/front/src/modules/ui/table/editable-cell/hooks/useEditableCell.ts index a79bbbc7f..4974b44e1 100644 --- a/front/src/modules/ui/table/editable-cell/hooks/useEditableCell.ts +++ b/front/src/modules/ui/table/editable-cell/hooks/useEditableCell.ts @@ -3,12 +3,20 @@ import { useRecoilCallback } from 'recoil'; import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope'; import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope'; +import { useContextScopeId } from '../../../recoil-scope/hooks/useContextScopeId'; +import { getSnapshotScopedState } from '../../../recoil-scope/utils/getSnapshotScopedState'; import { useCloseCurrentCellInEditMode } from '../../hooks/useClearCellInEditMode'; +import { CellContext } from '../../states/CellContext'; import { isSomeInputInEditModeState } from '../../states/isSomeInputInEditModeState'; import { TableHotkeyScope } from '../../types/TableHotkeyScope'; +import { customCellHotkeyScopeScopedState } from '../states/customCellHotkeyScopeScopedState'; import { useCurrentCellEditMode } from './useCurrentCellEditMode'; +const DEFAULT_CELL_SCOPE: HotkeyScope = { + scope: TableHotkeyScope.CellEditMode, +}; + export function useEditableCell() { const { setCurrentCellInEditMode } = useCurrentCellEditMode(); @@ -16,6 +24,8 @@ export function useEditableCell() { const closeCurrentCellInEditMode = useCloseCurrentCellInEditMode(); + const cellContextId = useContextScopeId(CellContext); + function closeEditableCell() { closeCurrentCellInEditMode(); setHotkeyScope(TableHotkeyScope.TableSoftFocus); @@ -23,20 +33,36 @@ export function useEditableCell() { const openEditableCell = useRecoilCallback( ({ snapshot, set }) => - (HotkeyScope: HotkeyScope) => { + () => { const isSomeInputInEditMode = snapshot .getLoadable(isSomeInputInEditModeState) .valueOrThrow(); + const customCellHotkeyScope = getSnapshotScopedState({ + snapshot, + state: customCellHotkeyScopeScopedState, + contextScopeId: cellContextId, + }); + if (!isSomeInputInEditMode) { set(isSomeInputInEditModeState, true); setCurrentCellInEditMode(); - setHotkeyScope(HotkeyScope.scope); + if (customCellHotkeyScope) { + setHotkeyScope( + customCellHotkeyScope.scope, + customCellHotkeyScope.customScopes, + ); + } else { + setHotkeyScope( + DEFAULT_CELL_SCOPE.scope, + DEFAULT_CELL_SCOPE.customScopes, + ); + } } }, - [setCurrentCellInEditMode, setHotkeyScope], + [setCurrentCellInEditMode, setHotkeyScope, cellContextId], ); return { diff --git a/front/src/modules/ui/table/editable-cell/hooks/useRegisterCloseCellHandlers.ts b/front/src/modules/ui/table/editable-cell/hooks/useRegisterCloseCellHandlers.ts index 997a558bb..a85f5ecc5 100644 --- a/front/src/modules/ui/table/editable-cell/hooks/useRegisterCloseCellHandlers.ts +++ b/front/src/modules/ui/table/editable-cell/hooks/useRegisterCloseCellHandlers.ts @@ -16,9 +16,12 @@ export function useRegisterCloseCellHandlers( const { isCurrentCellInEditMode } = useCurrentCellEditMode(); useListenClickOutsideArrayOfRef({ refs: [wrapperRef], - callback: () => { + callback: (event) => { if (isCurrentCellInEditMode) { + event.stopImmediatePropagation(); + onSubmit?.(); + closeEditableCell(); } }, diff --git a/front/src/modules/ui/table/editable-cell/hooks/useRegisterEditableCell.ts b/front/src/modules/ui/table/editable-cell/hooks/useRegisterEditableCell.ts new file mode 100644 index 000000000..4988eb5f6 --- /dev/null +++ b/front/src/modules/ui/table/editable-cell/hooks/useRegisterEditableCell.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; + +import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope'; + +import { useRecoilScopedState } from '../../../recoil-scope/hooks/useRecoilScopedState'; +import { CellContext } from '../../states/CellContext'; +import { TableHotkeyScope } from '../../types/TableHotkeyScope'; +import { customCellHotkeyScopeScopedState } from '../states/customCellHotkeyScopeScopedState'; + +const DEFAULT_CELL_SCOPE: HotkeyScope = { + scope: TableHotkeyScope.CellEditMode, +}; + +export function useRegisterEditableCell(cellHotkeyScope?: HotkeyScope) { + const [, setCustomCellHotkeyScope] = useRecoilScopedState( + customCellHotkeyScopeScopedState, + CellContext, + ); + + useEffect(() => { + setCustomCellHotkeyScope(cellHotkeyScope ?? DEFAULT_CELL_SCOPE); + }, [cellHotkeyScope, setCustomCellHotkeyScope]); +} diff --git a/front/src/modules/ui/table/editable-cell/states/customCellHotkeyScopeScopedState.ts b/front/src/modules/ui/table/editable-cell/states/customCellHotkeyScopeScopedState.ts new file mode 100644 index 000000000..4f0d9a224 --- /dev/null +++ b/front/src/modules/ui/table/editable-cell/states/customCellHotkeyScopeScopedState.ts @@ -0,0 +1,11 @@ +import { atomFamily } from 'recoil'; + +import { HotkeyScope } from '../../../hotkey/types/HotkeyScope'; + +export const customCellHotkeyScopeScopedState = atomFamily< + HotkeyScope | null, + string +>({ + key: 'customCellHotkeyScopeScopedState', + default: null, +}); diff --git a/front/src/pages/companies/__stories__/Company.stories.tsx b/front/src/pages/companies/__stories__/Company.stories.tsx index 8703d16e9..02a9ca4f9 100644 --- a/front/src/pages/companies/__stories__/Company.stories.tsx +++ b/front/src/pages/companies/__stories__/Company.stories.tsx @@ -71,7 +71,7 @@ export const EditNote: Story = { ).toBeInTheDocument(); const workspaceName = await canvas.findByText('Twenty'); - await fireEvent.mouseUp(workspaceName); + await fireEvent.click(workspaceName); expect(await canvas.queryByDisplayValue('My very first note')).toBeNull(); diff --git a/front/src/pages/people/__stories__/People.inputs.stories.tsx b/front/src/pages/people/__stories__/People.inputs.stories.tsx index 1b0cbe7b9..26b4a3bbf 100644 --- a/front/src/pages/people/__stories__/People.inputs.stories.tsx +++ b/front/src/pages/people/__stories__/People.inputs.stories.tsx @@ -30,7 +30,13 @@ export const InteractWithManyRows: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - let firstRowEmailCell = await canvas.findByText(mockedPeopleData[0].email); + const firstRowEmailCell = await canvas.findByText( + mockedPeopleData[0].email, + ); + + const secondRowEmailCell = await canvas.findByText( + mockedPeopleData[1].email, + ); expect( canvas.queryByTestId('editable-cell-edit-mode-container'), @@ -38,29 +44,19 @@ export const InteractWithManyRows: Story = { await userEvent.click(firstRowEmailCell); - firstRowEmailCell = await canvas.findByText(mockedPeopleData[0].email); - await userEvent.click(firstRowEmailCell); - expect( canvas.queryByTestId('editable-cell-edit-mode-container'), ).toBeInTheDocument(); - const secondRowEmailCell = await canvas.findByText( - mockedPeopleData[1].email, - ); await userEvent.click(secondRowEmailCell); await sleep(25); - const secondRowEmailCellFocused = await canvas.findByText( - mockedPeopleData[1].email, - ); - expect( canvas.queryByTestId('editable-cell-edit-mode-container'), ).toBeNull(); - await userEvent.click(secondRowEmailCellFocused); + await userEvent.click(secondRowEmailCell); await sleep(25); @@ -190,39 +186,44 @@ const editRelationMocks = ( export const EditRelation: Story = { render: getRenderWrapperForPage(, '/people'), - play: async ({ canvasElement }) => { + play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); - let secondRowCompanyCell = await canvas.findByText( - mockedPeopleData[1].company.name, - ); - await sleep(25); + await step('Click on second row company cell', async () => { + const secondRowCompanyCell = await canvas.findByText( + mockedPeopleData[1].company.name, + ); - await userEvent.click(secondRowCompanyCell); - - secondRowCompanyCell = await canvas.findByText( - mockedPeopleData[1].company.name, - ); - await sleep(25); - - await userEvent.click(secondRowCompanyCell); - - const relationInput = await canvas.findByPlaceholderText('Search'); - - await userEvent.type(relationInput, 'Air', { - delay: 200, + await userEvent.click(secondRowCompanyCell); }); - const airbnbChip = await canvas.findByText('Airbnb', { - selector: 'div', + await step('Type "Air" in relation picker', async () => { + const relationInput = await canvas.findByPlaceholderText('Search'); + + await userEvent.type(relationInput, 'Air', { + delay: 200, + }); }); - await userEvent.click(airbnbChip); + await step('Select "Airbnb"', async () => { + const airbnbChip = await canvas.findByText('Airbnb', { + selector: 'div', + }); - const otherCell = await canvas.findByText('Janice Dane'); - await userEvent.click(otherCell); + await userEvent.click(airbnbChip); + }); - await canvas.findByText('Airbnb'); + await step( + 'Click on last row company cell to exit relation picker', + async () => { + const otherCell = await canvas.findByText('Janice Dane'); + await userEvent.click(otherCell); + }, + ); + + await step('Check if Airbnb is in second row company cell', async () => { + await canvas.findByText('Airbnb'); + }); }, parameters: { actions: {},