Keep migrating to jotai (#18064)

And we continue!
This commit is contained in:
Charles Bochet
2026-02-19 12:10:02 +01:00
committed by GitHub
parent c0ea049ad7
commit 1a13258302
216 changed files with 2366 additions and 1594 deletions
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useRegisterInputEvents } from '@/object-record/record-field/ui/meta-types/input/hooks/useRegisterInputEvents';
import { DatePicker } from '@/ui/input/components/internal/date/components/DatePicker';
@@ -8,7 +8,7 @@ import {
} from '@/ui/input/components/internal/date/components/DateTimePicker';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { useRecoilCallback } from 'recoil';
import { useStore } from 'jotai';
import { type Nullable } from 'twenty-ui/utilities';
export type DateInputProps = {
@@ -39,6 +39,8 @@ export const DateInput = ({
onSubmit,
hideHeaderInput,
}: DateInputProps) => {
const store = useStore();
const [internalValue, setInternalValue] = useState(value);
const wrapperRef = useRef<HTMLDivElement>(null);
@@ -75,25 +77,23 @@ export const DateInput = ({
onEscape(internalValue);
};
const handleClickOutside = useRecoilCallback(
({ snapshot }) =>
(event: MouseEvent | TouchEvent) => {
const currentFocusId = snapshot
.getLoadable(currentFocusIdSelector)
.getValue();
const handleClickOutside = useCallback(
(event: MouseEvent | TouchEvent) => {
const currentFocusId = store.get(currentFocusIdSelector.atom);
if (currentFocusId === instanceId) {
closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID);
closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID);
onClickOutside(event, internalValue);
}
},
if (currentFocusId === instanceId) {
closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID);
closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID);
onClickOutside(event, internalValue);
}
},
[
instanceId,
closeDropdownYearSelect,
closeDropdownMonthSelect,
onClickOutside,
internalValue,
store,
],
);
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useRegisterInputEvents } from '@/object-record/record-field/ui/meta-types/input/hooks/useRegisterInputEvents';
import {
@@ -9,7 +9,7 @@ import {
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { useRecoilCallback } from 'recoil';
import { useStore } from 'jotai';
import { type Temporal } from 'temporal-polyfill';
import { type Nullable } from 'twenty-ui/utilities';
@@ -41,6 +41,8 @@ export const DateTimeInput = ({
onSubmit,
hideHeaderInput,
}: DateTimeInputProps) => {
const store = useStore();
const [internalValue, setInternalValue] = useState(value);
const { userTimezone } = useUserTimezone();
@@ -78,25 +80,23 @@ export const DateTimeInput = ({
onEscape(internalValue);
};
const handleClickOutside = useRecoilCallback(
({ snapshot }) =>
(event: MouseEvent | TouchEvent) => {
const currentFocusId = snapshot
.getLoadable(currentFocusIdSelector)
.getValue();
const handleClickOutside = useCallback(
(event: MouseEvent | TouchEvent) => {
const currentFocusId = store.get(currentFocusIdSelector.atom);
if (currentFocusId === instanceId) {
closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID);
closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID);
onClickOutside(event, internalValue);
}
},
if (currentFocusId === instanceId) {
closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID);
closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID);
onClickOutside(event, internalValue);
}
},
[
instanceId,
closeDropdownYearSelect,
closeDropdownMonthSelect,
onClickOutside,
internalValue,
store,
],
);
@@ -14,7 +14,7 @@ import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectab
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilComponentValueV2';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { type SelectOption } from 'twenty-ui/input';
@@ -47,7 +47,7 @@ export const MultiSelectInput = ({
selectableListComponentInstanceId,
);
const selectedItemId = useRecoilComponentValue(
const selectedItemId = useRecoilComponentValueV2(
selectedItemIdComponentState,
selectableListComponentInstanceId,
);
@@ -17,7 +17,7 @@ import { type DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilComponentValueV2';
import { t } from '@lingui/core/macro';
import { useRecoilValue, useResetRecoilState } from 'recoil';
import { IconApps, type IconComponent, useIcons } from 'twenty-ui/display';
@@ -99,7 +99,7 @@ const IconPickerIcon = ({
Icon,
focusedIconKey,
}: IconPickerIconProps) => {
const isSelectedItemId = useRecoilComponentValue(
const isSelectedItemId = useRecoilComponentValueV2(
selectedItemIdComponentState,
iconKey,
);
@@ -242,7 +242,7 @@ export const IconPicker = ({
const selectableListInstanceId = 'icon-list';
const focusedIconKey =
useRecoilComponentValue(
useRecoilComponentValueV2(
selectedItemIdComponentState,
selectableListInstanceId,
) ?? undefined;
@@ -16,7 +16,7 @@ import { SelectableList } from '@/ui/layout/selectable-list/components/Selectabl
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilComponentValueV2';
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type IconComponent } from 'twenty-ui/display';
@@ -140,7 +140,7 @@ export const Select = <Value extends SelectValue>({
const selectableItemIdArray = filteredOptions.map((option) => option.label);
const selectedItemId = useRecoilComponentValue(
const selectedItemId = useRecoilComponentValueV2(
selectedItemIdComponentState,
dropdownId,
);
@@ -8,7 +8,7 @@ import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-l
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilComponentValueV2';
import { useEffect, useMemo, useRef, useState } from 'react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
@@ -46,7 +46,7 @@ export const SelectInput = ({
SelectableListComponentInstanceContext,
);
const selectedItemId = useRecoilComponentValue(
const selectedItemId = useRecoilComponentValueV2(
selectedItemIdComponentState,
selectableListInstanceId,
);
@@ -4,7 +4,7 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
export const useCloseAnyOpenDropdown = () => {
@@ -13,14 +13,14 @@ export const useCloseAnyOpenDropdown = () => {
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const store = useStore();
const closeAnyOpenDropdown = useCallback(() => {
const previousDropdownFocusId = jotaiStore.get(
const previousDropdownFocusId = store.get(
previousDropdownFocusIdState.atom,
);
const activeDropdownFocusId = jotaiStore.get(
activeDropdownFocusIdState.atom,
);
const activeDropdownFocusId = store.get(activeDropdownFocusIdState.atom);
const thereIsNoDropdownOpen =
!isDefined(activeDropdownFocusId) && !isDefined(previousDropdownFocusId);
@@ -45,9 +45,9 @@ export const useCloseAnyOpenDropdown = () => {
});
}
jotaiStore.set(previousDropdownFocusIdState.atom, null);
jotaiStore.set(activeDropdownFocusIdState.atom, null);
}, [closeDropdown, removeFocusItemFromFocusStackById]);
store.set(previousDropdownFocusIdState.atom, null);
store.set(activeDropdownFocusIdState.atom, null);
}, [closeDropdown, removeFocusItemFromFocusStackById, store]);
return { closeAnyOpenDropdown };
};
@@ -6,7 +6,7 @@ import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDrop
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
export const useCloseDropdown = () => {
@@ -16,6 +16,8 @@ export const useCloseDropdown = () => {
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const store = useStore();
const dropdownComponentInstanceIdFromContext =
useAvailableComponentInstanceId(DropdownComponentInstanceContext);
@@ -29,7 +31,7 @@ export const useCloseDropdown = () => {
throw new Error('Dropdown component instance ID is not defined');
}
const isDropdownOpen = jotaiStore.get(
const isDropdownOpen = store.get(
isDropdownOpenComponentState.atomFamily({
instanceId: dropdownComponentInstanceId,
}),
@@ -42,7 +44,7 @@ export const useCloseDropdown = () => {
goBackToPreviousDropdownFocusId();
jotaiStore.set(
store.set(
isDropdownOpenComponentState.atomFamily({
instanceId: dropdownComponentInstanceId,
}),
@@ -54,6 +56,7 @@ export const useCloseDropdown = () => {
removeFocusItemFromFocusStackById,
goBackToPreviousDropdownFocusId,
dropdownComponentInstanceIdFromContext,
store,
],
);
@@ -2,21 +2,20 @@ import { useCallback } from 'react';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { useStore } from 'jotai';
// TODO: this won't work for more than 1 nested dropdown
export const useGoBackToPreviousDropdownFocusId = () => {
const store = useStore();
const goBackToPreviousDropdownFocusId = useCallback(() => {
const previouslyFocusedDropdownId = jotaiStore.get(
const previouslyFocusedDropdownId = store.get(
previousDropdownFocusIdState.atom,
);
jotaiStore.set(
activeDropdownFocusIdState.atom,
previouslyFocusedDropdownId,
);
jotaiStore.set(previousDropdownFocusIdState.atom, null);
}, []);
store.set(activeDropdownFocusIdState.atom, previouslyFocusedDropdownId);
store.set(previousDropdownFocusIdState.atom, null);
}, [store]);
return {
goBackToPreviousDropdownFocusId,
@@ -8,7 +8,7 @@ import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePush
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { type GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
type OpenDropdownArgs = {
@@ -21,6 +21,8 @@ export const useOpenDropdown = () => {
const { setActiveDropdownFocusIdAndMemorizePrevious } =
useSetActiveDropdownFocusIdAndMemorizePrevious();
const store = useStore();
const dropdownComponentInstanceIdFromContext =
useAvailableComponentInstanceId(DropdownComponentInstanceContext);
@@ -34,7 +36,7 @@ export const useOpenDropdown = () => {
throw new Error('Dropdown component instance ID is not defined');
}
jotaiStore.set(
store.set(
isDropdownOpenComponentState.atomFamily({
instanceId: dropdownComponentInstanceId,
}),
@@ -63,6 +65,7 @@ export const useOpenDropdown = () => {
pushFocusItemToFocusStack,
setActiveDropdownFocusIdAndMemorizePrevious,
dropdownComponentInstanceIdFromContext,
store,
],
);
@@ -2,23 +2,23 @@ import { useCallback } from 'react';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { useStore } from 'jotai';
export const useSetActiveDropdownFocusIdAndMemorizePrevious = () => {
const store = useStore();
const setActiveDropdownFocusIdAndMemorizePrevious = useCallback(
(dropdownId: string | null) => {
const activeDropdownFocusId = jotaiStore.get(
activeDropdownFocusIdState.atom,
);
const activeDropdownFocusId = store.get(activeDropdownFocusIdState.atom);
if (activeDropdownFocusId === dropdownId) {
return;
}
jotaiStore.set(previousDropdownFocusIdState.atom, activeDropdownFocusId);
jotaiStore.set(activeDropdownFocusIdState.atom, dropdownId);
store.set(previousDropdownFocusIdState.atom, activeDropdownFocusId);
store.set(activeDropdownFocusIdState.atom, dropdownId);
},
[],
[store],
);
return {
@@ -6,7 +6,7 @@ import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { type GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
type ToggleDropdownArgs = {
@@ -21,6 +21,8 @@ export const useToggleDropdown = () => {
const { openDropdown } = useOpenDropdown();
const { closeDropdown } = useCloseDropdown();
const store = useStore();
const toggleDropdown = useCallback(
(args?: ToggleDropdownArgs | null | undefined) => {
const dropdownComponentInstanceId =
@@ -31,7 +33,7 @@ export const useToggleDropdown = () => {
throw new Error('Dropdown component instance ID is not defined');
}
const isDropdownOpen = jotaiStore.get(
const isDropdownOpen = store.get(
isDropdownOpenComponentState.atomFamily({
instanceId: dropdownComponentInstanceId,
}),
@@ -46,7 +48,12 @@ export const useToggleDropdown = () => {
});
}
},
[closeDropdown, openDropdown, dropdownComponentInstanceIdFromContext],
[
closeDropdown,
openDropdown,
dropdownComponentInstanceIdFromContext,
store,
],
);
return {
@@ -9,7 +9,7 @@ import { MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID } from '@/ui/layout/modal/cons
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilComponentValueV2';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AnimatePresence, motion } from 'framer-motion';
@@ -243,7 +243,7 @@ export const Modal = ({
e.stopPropagation();
};
const isModalOpened = useRecoilComponentValue(
const isModalOpened = useRecoilComponentValueV2(
isModalOpenedComponentState,
modalId,
);
@@ -1,24 +1,27 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import {
type Decorator,
type Meta,
type StoryObj,
} from '@storybook/react-vite';
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { type SetRecoilState } from 'recoil';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { ComponentDecorator } from 'twenty-ui/testing';
import { RootDecorator } from '~/testing/decorators/RootDecorator';
import { sleep } from '~/utils/sleep';
const initializeState = ({ set }: { set: SetRecoilState }) => {
set(
const JotaiInitDecorator: Decorator = (Story) => {
jotaiStore.set(
isModalOpenedComponentState.atomFamily({
instanceId: 'confirmation-modal',
}),
true,
);
set(focusStackState, [
jotaiStore.set(focusStackState.atom, [
{
focusId: 'confirmation-modal',
componentInstance: {
@@ -31,14 +34,14 @@ const initializeState = ({ set }: { set: SetRecoilState }) => {
},
},
]);
return <Story />;
};
const meta: Meta<typeof ConfirmationModal> = {
title: 'UI/Layout/Modal/ConfirmationModal',
component: ConfirmationModal,
decorators: [RootDecorator, ComponentDecorator],
decorators: [JotaiInitDecorator, RootDecorator, ComponentDecorator],
parameters: {
initializeState,
disableHotkeyInitialization: true,
},
};
@@ -1,24 +1,27 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import {
type Decorator,
type Meta,
type StoryObj,
} from '@storybook/react-vite';
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { type SetRecoilState } from 'recoil';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { ComponentDecorator } from 'twenty-ui/testing';
import { RootDecorator } from '~/testing/decorators/RootDecorator';
import { sleep } from '~/utils/sleep';
const initializeState = ({ set }: { set: SetRecoilState }) => {
set(
const JotaiInitDecorator: Decorator = (Story) => {
jotaiStore.set(
isModalOpenedComponentState.atomFamily({
instanceId: 'modal-id',
}),
true,
);
set(focusStackState, [
jotaiStore.set(focusStackState.atom, [
{
focusId: 'modal-id',
componentInstance: {
@@ -31,14 +34,14 @@ const initializeState = ({ set }: { set: SetRecoilState }) => {
},
},
]);
return <Story />;
};
const meta: Meta<typeof Modal> = {
title: 'UI/Layout/Modal/Modal',
component: Modal,
decorators: [RootDecorator, ComponentDecorator],
decorators: [JotaiInitDecorator, RootDecorator, ComponentDecorator],
parameters: {
initializeState,
disableHotkeyInitialization: true,
},
};
@@ -1,28 +1,39 @@
import { renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { createStore, Provider } from 'jotai';
import { type ReactNode, act } from 'react';
import { RecoilRoot } from 'recoil';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { act } from 'react';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilComponentValueV2';
const modalId = 'test-modal-id';
describe('useModal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const createTestWrapper = () => {
const store = createStore();
const TestWrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<Provider store={store}>{children}</Provider>
</RecoilRoot>
);
return TestWrapper;
};
describe('useModal', () => {
it('should open a modal', () => {
const { result } = renderHook(
() => {
const modal = useModal();
const isModalOpened = useRecoilValue(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
const isModalOpened = useRecoilComponentValueV2(
isModalOpenedComponentState,
modalId,
);
return { modal, isModalOpened };
},
{
wrapper: RecoilRoot,
wrapper: createTestWrapper(),
},
);
@@ -37,13 +48,14 @@ describe('useModal', () => {
const { result } = renderHook(
() => {
const modal = useModal();
const isModalOpened = useRecoilValue(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
const isModalOpened = useRecoilComponentValueV2(
isModalOpenedComponentState,
modalId,
);
return { modal, isModalOpened };
},
{
wrapper: RecoilRoot,
wrapper: createTestWrapper(),
},
);
@@ -63,13 +75,14 @@ describe('useModal', () => {
const { result } = renderHook(
() => {
const modal = useModal();
const isModalOpened = useRecoilValue(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
const isModalOpened = useRecoilComponentValueV2(
isModalOpenedComponentState,
modalId,
);
return { modal, isModalOpened };
},
{
wrapper: RecoilRoot,
wrapper: createTestWrapper(),
},
);
@@ -86,13 +99,14 @@ describe('useModal', () => {
const { result } = renderHook(
() => {
const modal = useModal();
const isModalOpened = useRecoilValue(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
const isModalOpened = useRecoilComponentValueV2(
isModalOpenedComponentState,
modalId,
);
return { modal, isModalOpened };
},
{
wrapper: RecoilRoot,
wrapper: createTestWrapper(),
},
);
@@ -1,88 +1,83 @@
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilCallback } from 'recoil';
export const useModal = () => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const closeModal = useRecoilCallback(
({ set, snapshot }) =>
(modalId: string) => {
const isModalOpen = snapshot
.getLoadable(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
)
.getValue();
const store = useStore();
if (!isModalOpen) {
return;
}
const closeModal = useCallback(
(modalId: string) => {
const isModalOpen = store.get(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
);
removeFocusItemFromFocusStackById({
focusId: modalId,
});
if (!isModalOpen) {
return;
}
set(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
false,
);
},
[removeFocusItemFromFocusStackById],
removeFocusItemFromFocusStackById({
focusId: modalId,
});
store.set(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
false,
);
},
[store, removeFocusItemFromFocusStackById],
);
const openModal = useRecoilCallback(
({ set, snapshot }) =>
(modalId: string) => {
const isModalOpened = snapshot
.getLoadable(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
)
.getValue();
const openModal = useCallback(
(modalId: string) => {
const isModalOpened = store.get(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
);
if (isModalOpened) {
return;
}
if (isModalOpened) {
return;
}
set(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
true,
);
store.set(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
true,
);
pushFocusItemToFocusStack({
focusId: modalId,
component: {
type: FocusComponentType.MODAL,
instanceId: modalId,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
},
[pushFocusItemToFocusStack],
pushFocusItemToFocusStack({
focusId: modalId,
component: {
type: FocusComponentType.MODAL,
instanceId: modalId,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
},
[store, pushFocusItemToFocusStack],
);
const toggleModal = useRecoilCallback(
({ snapshot }) =>
(modalId: string) => {
const isModalOpen = snapshot
.getLoadable(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
)
.getValue();
const toggleModal = useCallback(
(modalId: string) => {
const isModalOpen = store.get(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
);
if (isModalOpen) {
closeModal(modalId);
} else {
openModal(modalId);
}
},
[closeModal, openModal],
if (isModalOpen) {
closeModal(modalId);
} else {
openModal(modalId);
}
},
[store, closeModal, openModal],
);
return {
@@ -1,7 +1,7 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { createComponentStateV2 } from '@/ui/utilities/state/jotai/utils/createComponentStateV2';
import { ModalComponentInstanceContext } from '@/ui/layout/modal/contexts/ModalComponentInstanceContext';
export const isModalOpenedComponentState = createComponentState<boolean>({
export const isModalOpenedComponentState = createComponentStateV2<boolean>({
key: 'isModalOpenedComponentState',
defaultValue: false,
componentInstanceContext: ModalComponentInstanceContext,
@@ -4,7 +4,7 @@ import { useSelectableListHotKeys } from '@/ui/layout/selectable-list/hooks/inte
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { SelectableListContextProvider } from '@/ui/layout/selectable-list/states/contexts/SelectableListContext';
import { selectableItemIdsComponentState } from '@/ui/layout/selectable-list/states/selectableItemIdsComponentState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetRecoilComponentStateV2';
import { isDefined } from 'twenty-shared/utils';
import { arrayToChunks } from '~/utils/array/arrayToChunks';
@@ -27,7 +27,7 @@ export const SelectableList = ({
}: SelectableListProps) => {
useSelectableListHotKeys(selectableListInstanceId, focusId, onSelect);
const setSelectableItemIds = useSetRecoilComponentState(
const setSelectableItemIds = useSetRecoilComponentStateV2(
selectableItemIdsComponentState,
selectableListInstanceId,
);
@@ -1,8 +1,8 @@
import { type ReactNode, useEffect, useRef } from 'react';
import { SelectableListItemHotkeyEffect } from '@/ui/layout/selectable-list/components/SelectableListItemHotkeyEffect';
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilComponentFamilyValueV2';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
@@ -25,8 +25,8 @@ export const SelectableListItem = ({
children,
onEnter,
}: SelectableListItemProps) => {
const isSelectedItemId = useRecoilComponentFamilyValue(
isSelectedItemIdComponentFamilySelector,
const isSelectedItemId = useRecoilComponentFamilyValueV2(
isSelectedItemIdComponentFamilyState,
itemId,
);
@@ -1,12 +1,12 @@
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { RecoilRoot } from 'recoil';
import { Provider } from 'jotai';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { selectableItemIdsComponentState } from '@/ui/layout/selectable-list/states/selectableItemIdsComponentState';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetRecoilComponentStateV2';
const selectableListComponentInstanceId = 'testId';
const testArr = [['1'], ['2'], ['3']];
@@ -16,12 +16,12 @@ describe('useSelectableList', () => {
it('Should setSelectableItemIds', async () => {
const { result } = renderHook(
() => {
const setSelectableItemIds = useSetRecoilComponentState(
const setSelectableItemIds = useSetRecoilComponentStateV2(
selectableItemIdsComponentState,
selectableListComponentInstanceId,
);
const selectableItemIds = useRecoilComponentValue(
const selectableItemIds = useRecoilComponentValueV2(
selectableItemIdsComponentState,
selectableListComponentInstanceId,
);
@@ -32,7 +32,7 @@ describe('useSelectableList', () => {
};
},
{
wrapper: RecoilRoot,
wrapper: Provider,
},
);
@@ -52,11 +52,11 @@ describe('useSelectableList', () => {
selectableListComponentInstanceId,
);
const selectedItemId = useRecoilComponentValue(
const selectedItemId = useRecoilComponentValueV2(
selectedItemIdComponentState,
selectableListComponentInstanceId,
);
const setSelectedItemId = useSetRecoilComponentState(
const setSelectedItemId = useSetRecoilComponentStateV2(
selectedItemIdComponentState,
selectableListComponentInstanceId,
);
@@ -67,7 +67,7 @@ describe('useSelectableList', () => {
};
},
{
wrapper: RecoilRoot,
wrapper: Provider,
},
);
@@ -1,12 +1,12 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { Key } from 'ts-key-enum';
import { selectableItemIdsComponentState } from '@/ui/layout/selectable-list/states/selectableItemIdsComponentState';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
type Direction = 'up' | 'down' | 'left' | 'right';
@@ -31,110 +31,109 @@ export const useSelectableListHotKeys = (
}
};
const handleSelect = useRecoilCallback(
({ snapshot, set }) =>
(direction: Direction) => {
const selectedItemId = getSnapshotValue(
snapshot,
selectedItemIdComponentState.atomFamily({
instanceId: instanceId,
}),
);
const selectableItemIds = getSnapshotValue(
snapshot,
selectableItemIdsComponentState.atomFamily({
instanceId: instanceId,
}),
);
const store = useStore();
const currentPosition = findPosition(selectableItemIds, selectedItemId);
const handleSelect = useCallback(
(direction: Direction) => {
const selectedItemId = store.get(
selectedItemIdComponentState.atomFamily({
instanceId: instanceId,
}),
);
const selectableItemIds = store.get(
selectableItemIdsComponentState.atomFamily({
instanceId: instanceId,
}),
);
const computeNextId = (direction: Direction) => {
if (
selectableItemIds.length === 0 ||
selectableItemIds[0]?.length === 0
) {
return;
}
const currentPosition = findPosition(selectableItemIds, selectedItemId);
if (!selectedItemId || !currentPosition) {
return selectableItemIds[0][0];
}
const { row: currentRow, col: currentCol } = currentPosition;
const isSingleRow = selectableItemIds.length === 1;
let nextRow: number;
let nextCol: number;
switch (direction) {
case 'up':
nextRow = isSingleRow ? currentRow : Math.max(0, currentRow - 1);
nextCol = isSingleRow ? Math.max(0, currentCol - 1) : currentCol;
break;
case 'down':
nextRow = isSingleRow
? currentRow
: Math.min(selectableItemIds.length - 1, currentRow + 1);
nextCol = isSingleRow
? Math.min(
selectableItemIds[currentRow].length - 1,
currentCol + 1,
)
: currentCol;
break;
case 'left':
nextRow = currentRow;
nextCol = Math.max(0, currentCol - 1);
break;
case 'right':
nextRow = currentRow;
nextCol = Math.min(
selectableItemIds[currentRow].length - 1,
currentCol + 1,
);
break;
default:
nextRow = currentRow;
nextCol = currentCol;
}
return selectableItemIds[nextRow][nextCol];
};
const nextId = computeNextId(direction);
if (selectedItemId !== nextId) {
if (isNonEmptyString(nextId)) {
set(
isSelectedItemIdComponentFamilySelector.selectorFamily({
instanceId: instanceId,
familyKey: nextId,
}),
true,
);
set(
selectedItemIdComponentState.atomFamily({
instanceId: instanceId,
}),
nextId,
);
onSelect?.(nextId);
}
if (isNonEmptyString(selectedItemId)) {
set(
isSelectedItemIdComponentFamilySelector.selectorFamily({
instanceId: instanceId,
familyKey: selectedItemId,
}),
false,
);
}
const computeNextId = (direction: Direction) => {
if (
selectableItemIds.length === 0 ||
selectableItemIds[0]?.length === 0
) {
return;
}
},
[instanceId, onSelect],
if (!selectedItemId || !currentPosition) {
return selectableItemIds[0][0];
}
const { row: currentRow, col: currentCol } = currentPosition;
const isSingleRow = selectableItemIds.length === 1;
let nextRow: number;
let nextCol: number;
switch (direction) {
case 'up':
nextRow = isSingleRow ? currentRow : Math.max(0, currentRow - 1);
nextCol = isSingleRow ? Math.max(0, currentCol - 1) : currentCol;
break;
case 'down':
nextRow = isSingleRow
? currentRow
: Math.min(selectableItemIds.length - 1, currentRow + 1);
nextCol = isSingleRow
? Math.min(
selectableItemIds[currentRow].length - 1,
currentCol + 1,
)
: currentCol;
break;
case 'left':
nextRow = currentRow;
nextCol = Math.max(0, currentCol - 1);
break;
case 'right':
nextRow = currentRow;
nextCol = Math.min(
selectableItemIds[currentRow].length - 1,
currentCol + 1,
);
break;
default:
nextRow = currentRow;
nextCol = currentCol;
}
return selectableItemIds[nextRow][nextCol];
};
const nextId = computeNextId(direction);
if (selectedItemId !== nextId) {
if (isNonEmptyString(nextId)) {
store.set(
isSelectedItemIdComponentFamilyState.atomFamily({
instanceId: instanceId,
familyKey: nextId,
}),
true,
);
store.set(
selectedItemIdComponentState.atomFamily({
instanceId: instanceId,
}),
nextId,
);
onSelect?.(nextId);
}
if (isNonEmptyString(selectedItemId)) {
store.set(
isSelectedItemIdComponentFamilyState.atomFamily({
instanceId: instanceId,
familyKey: selectedItemId,
}),
false,
);
}
}
},
[store, instanceId, onSelect],
);
useHotkeysOnFocusedElement({
@@ -1,10 +1,10 @@
import { useRecoilCallback } from 'recoil';
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { isDefined } from 'twenty-shared/utils';
export const useSelectableList = (instanceId?: string) => {
@@ -12,70 +12,66 @@ export const useSelectableList = (instanceId?: string) => {
SelectableListComponentInstanceContext,
instanceId,
);
const resetSelectedItem = useRecoilCallback(
({ snapshot, set }) =>
() => {
const selectedItemId = getSnapshotValue(
snapshot,
selectedItemIdComponentState.atomFamily({
instanceId: selectableListInstanceId,
}),
);
if (isDefined(selectedItemId)) {
set(
selectedItemIdComponentState.atomFamily({
instanceId: selectableListInstanceId,
}),
null,
);
set(
isSelectedItemIdComponentFamilySelector.selectorFamily({
instanceId: selectableListInstanceId,
familyKey: selectedItemId,
}),
false,
);
}
},
[selectableListInstanceId],
);
const store = useStore();
const setSelectedItemId = useRecoilCallback(
({ set, snapshot }) =>
(itemId: string) => {
const selectedItemId = getSnapshotValue(
snapshot,
selectedItemIdComponentState.atomFamily({
instanceId: selectableListInstanceId,
}),
);
const resetSelectedItem = useCallback(() => {
const selectedItemId = store.get(
selectedItemIdComponentState.atomFamily({
instanceId: selectableListInstanceId,
}),
);
if (isDefined(selectedItemId)) {
set(
isSelectedItemIdComponentFamilySelector.selectorFamily({
instanceId: selectableListInstanceId,
familyKey: selectedItemId,
}),
false,
);
}
if (isDefined(selectedItemId)) {
store.set(
selectedItemIdComponentState.atomFamily({
instanceId: selectableListInstanceId,
}),
null,
);
store.set(
isSelectedItemIdComponentFamilyState.atomFamily({
instanceId: selectableListInstanceId,
familyKey: selectedItemId,
}),
false,
);
}
}, [store, selectableListInstanceId]);
set(
selectedItemIdComponentState.atomFamily({
const setSelectedItemId = useCallback(
(itemId: string) => {
const selectedItemId = store.get(
selectedItemIdComponentState.atomFamily({
instanceId: selectableListInstanceId,
}),
);
if (isDefined(selectedItemId)) {
store.set(
isSelectedItemIdComponentFamilyState.atomFamily({
instanceId: selectableListInstanceId,
familyKey: selectedItemId,
}),
itemId,
false,
);
set(
isSelectedItemIdComponentFamilySelector.selectorFamily({
instanceId: selectableListInstanceId,
familyKey: itemId,
}),
true,
);
},
[selectableListInstanceId],
}
store.set(
selectedItemIdComponentState.atomFamily({
instanceId: selectableListInstanceId,
}),
itemId,
);
store.set(
isSelectedItemIdComponentFamilyState.atomFamily({
instanceId: selectableListInstanceId,
familyKey: itemId,
}),
true,
);
},
[store, selectableListInstanceId],
);
return {
@@ -2,9 +2,9 @@ import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-l
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { Key } from 'ts-key-enum';
export const useSelectableListListenToEnterHotkeyOnItem = ({
@@ -20,24 +20,23 @@ export const useSelectableListListenToEnterHotkeyOnItem = ({
SelectableListComponentInstanceContext,
);
const store = useStore();
const handleEnterKey = useCallback(() => {
const selectedItemId = store.get(
selectedItemIdComponentState.atomFamily({
instanceId,
}),
);
if (isNonEmptyString(selectedItemId) && selectedItemId === itemId) {
onEnter?.();
}
}, [store, instanceId, itemId, onEnter]);
useHotkeysOnFocusedElement({
keys: Key.Enter,
callback: useRecoilCallback(
({ snapshot }) =>
() => {
const selectedItemId = getSnapshotValue(
snapshot,
selectedItemIdComponentState.atomFamily({
instanceId,
}),
);
if (isNonEmptyString(selectedItemId) && selectedItemId === itemId) {
onEnter?.();
}
},
[instanceId, itemId, onEnter],
),
callback: handleEnterKey,
focusId,
dependencies: [itemId, onEnter],
});
@@ -1,11 +1,9 @@
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState';
import { createComponentFamilyStateV2 } from '@/ui/utilities/state/jotai/utils/createComponentFamilyStateV2';
export const isSelectedItemIdComponentFamilyState = createComponentFamilyState<
boolean,
string
>({
key: 'isSelectedItemIdComponentFamilyState',
defaultValue: false,
componentInstanceContext: SelectableListComponentInstanceContext,
});
export const isSelectedItemIdComponentFamilyState =
createComponentFamilyStateV2<boolean, string>({
key: 'isSelectedItemIdComponentFamilyState',
defaultValue: false,
componentInstanceContext: SelectableListComponentInstanceContext,
});
@@ -1,10 +1,10 @@
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { createComponentStateV2 } from '@/ui/utilities/state/jotai/utils/createComponentStateV2';
export const selectableItemIdsComponentState = createComponentState<string[][]>(
{
key: 'selectableItemIdsComponentState',
defaultValue: [[]],
componentInstanceContext: SelectableListComponentInstanceContext,
},
);
export const selectableItemIdsComponentState = createComponentStateV2<
string[][]
>({
key: 'selectableItemIdsComponentState',
defaultValue: [[]],
componentInstanceContext: SelectableListComponentInstanceContext,
});
@@ -1,10 +1,10 @@
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { createComponentStateV2 } from '@/ui/utilities/state/jotai/utils/createComponentStateV2';
export const selectedItemIdComponentState = createComponentState<string | null>(
{
key: 'selectedItemIdComponentState',
defaultValue: null,
componentInstanceContext: SelectableListComponentInstanceContext,
},
);
export const selectedItemIdComponentState = createComponentStateV2<
string | null
>({
key: 'selectedItemIdComponentState',
defaultValue: null,
componentInstanceContext: SelectableListComponentInstanceContext,
});
@@ -1,28 +0,0 @@
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
import { createComponentFamilySelector } from '@/ui/utilities/state/component-state/utils/createComponentFamilySelector';
export const isSelectedItemIdComponentFamilySelector =
createComponentFamilySelector<boolean, string>({
key: 'isSelectedItemIdComponentFamilySelector',
componentInstanceContext: SelectableListComponentInstanceContext,
get:
({ instanceId, familyKey }: { instanceId: string; familyKey: string }) =>
({ get }) =>
get(
isSelectedItemIdComponentFamilyState.atomFamily({
instanceId: instanceId,
familyKey: familyKey,
}),
),
set:
({ instanceId, familyKey }: { instanceId: string; familyKey: string }) =>
({ set }, newValue) =>
set(
isSelectedItemIdComponentFamilyState.atomFamily({
instanceId: instanceId,
familyKey: familyKey,
}),
newValue,
),
});
@@ -6,7 +6,7 @@ import { type TabListProps } from '@/ui/layout/tab-list/types/TabListProps';
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
import { TabListHiddenMeasurements } from '@/ui/layout/tab-list/components/TabListHiddenMeasurements';
import { useTabListMeasurements } from '@/ui/layout/tab-list/hooks/useTabListMeasurements';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilComponentStateV2';
import styled from '@emotion/styled';
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
@@ -53,7 +53,7 @@ export const TabList = ({
const visibleTabs = tabs.filter((tab) => !tab.hide);
const navigate = useNavigate();
const [activeTabId, setActiveTabId] = useRecoilComponentState(
const [activeTabId, setActiveTabId] = useRecoilComponentStateV2(
activeTabIdComponentState,
componentInstanceId,
);
@@ -1,6 +1,6 @@
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetRecoilComponentStateV2';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
@@ -14,8 +14,10 @@ export const TabListFromUrlOptionalEffect = ({
isInRightDrawer,
}: TabListFromUrlOptionalEffectProps) => {
const location = useLocation();
const activeTabId = useRecoilComponentValue(activeTabIdComponentState);
const setActiveTabId = useSetRecoilComponentState(activeTabIdComponentState);
const activeTabId = useRecoilComponentValueV2(activeTabIdComponentState);
const setActiveTabId = useSetRecoilComponentStateV2(
activeTabIdComponentState,
);
const hash = location.hash.replace('#', '');
@@ -1,7 +1,7 @@
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { createComponentStateV2 } from '@/ui/utilities/state/jotai/utils/createComponentStateV2';
export const activeTabIdComponentState = createComponentState<string | null>({
export const activeTabIdComponentState = createComponentStateV2<string | null>({
key: 'activeTabIdComponentState',
defaultValue: null,
componentInstanceContext: TabListComponentInstanceContext,
@@ -2,7 +2,8 @@ import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableHeaderText } from '@/ui/layout/table/components/TableHeaderText';
import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState';
import { type TableSortValue } from '@/ui/layout/table/types/TableSortValue';
import { useRecoilState } from 'recoil';
import { useFamilyRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useFamilyRecoilValueV2';
import { useSetFamilyRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetFamilyRecoilStateV2';
import {
IconArrowDown,
IconArrowUp,
@@ -24,8 +25,13 @@ export const SortableTableHeader = ({
initialSort?: TableSortValue;
Icon?: IconComponent;
}) => {
const [sortedFieldByTable, setSortedFieldByTable] = useRecoilState(
sortedFieldByTableFamilyState({ tableId }),
const sortedFieldByTable = useFamilyRecoilValueV2(
sortedFieldByTableFamilyState,
{ tableId },
);
const setSortedFieldByTable = useSetFamilyRecoilStateV2(
sortedFieldByTableFamilyState,
{ tableId },
);
const sortValue = sortedFieldByTable ?? initialSort;
@@ -1,6 +1,6 @@
import { renderHook } from '@testing-library/react';
import React, { type ReactNode } from 'react';
import { type MutableSnapshot, RecoilRoot } from 'recoil';
import { createStore, Provider } from 'jotai';
import {
mockedTableMetadata,
@@ -17,41 +17,32 @@ import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFi
import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
interface WrapperProps {
children: ReactNode;
initializeState?: (mutableSnapshot: MutableSnapshot) => void;
}
const Wrapper: React.FC<WrapperProps> = ({ children, initializeState }) => (
<RecoilRoot initializeState={initializeState}>{children}</RecoilRoot>
);
const createSortedWrapper = (
fieldName: keyof MockedTableType,
orderBy: OrderBy,
) => {
const store = createStore();
store.set(
sortedFieldByTableFamilyState.atomFamily({
tableId: mockedTableMetadata.tableId,
}),
{
fieldName: fieldName as string,
orderBy,
},
);
const Wrapper = ({ children }: { children: ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
return Wrapper;
};
describe('useSortedArray hook', () => {
const initializeState =
(fieldName: keyof MockedTableType, orderBy: OrderBy) =>
({ set }: MutableSnapshot) => {
set(
sortedFieldByTableFamilyState({
tableId: mockedTableMetadata.tableId,
}),
{
fieldName,
orderBy,
},
);
};
test('initial sorting behavior for string fields - Ascending', () => {
const { result } = renderHook(
() => useSortedArray(tableData, mockedTableMetadata),
{
wrapper: ({ children }: { children: ReactNode }) => (
<Wrapper
initializeState={initializeState('labelPlural', 'AscNullsLast')}
>
{children}
</Wrapper>
),
wrapper: createSortedWrapper('labelPlural', 'AscNullsLast'),
},
);
@@ -64,13 +55,7 @@ describe('useSortedArray hook', () => {
const { result } = renderHook(
() => useSortedArray(tableData, mockedTableMetadata),
{
wrapper: ({ children }: { children: ReactNode }) => (
<Wrapper
initializeState={initializeState('labelPlural', 'DescNullsLast')}
>
{children}
</Wrapper>
),
wrapper: createSortedWrapper('labelPlural', 'DescNullsLast'),
},
);
@@ -83,13 +68,7 @@ describe('useSortedArray hook', () => {
const { result } = renderHook(
() => useSortedArray(tableData, mockedTableMetadata),
{
wrapper: ({ children }: { children: ReactNode }) => (
<Wrapper
initializeState={initializeState('fieldsCount', 'AscNullsLast')}
>
{children}
</Wrapper>
),
wrapper: createSortedWrapper('fieldsCount', 'AscNullsLast'),
},
);
@@ -102,13 +81,7 @@ describe('useSortedArray hook', () => {
const { result } = renderHook(
() => useSortedArray(tableData, mockedTableMetadata),
{
wrapper: ({ children }: { children: ReactNode }) => (
<Wrapper
initializeState={initializeState('fieldsCount', 'DescNullsLast')}
>
{children}
</Wrapper>
),
wrapper: createSortedWrapper('fieldsCount', 'DescNullsLast'),
},
);
@@ -1,15 +1,16 @@
import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState';
import { type TableMetadata } from '@/ui/layout/table/types/TableMetadata';
import { useFamilyRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useFamilyRecoilValueV2';
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useSortedArray = <T>(
arrayToSort: T[],
tableMetadata: TableMetadata<T>,
): T[] => {
const sortedFieldByTable = useRecoilValue(
sortedFieldByTableFamilyState({ tableId: tableMetadata.tableId }),
const sortedFieldByTable = useFamilyRecoilValueV2(
sortedFieldByTableFamilyState,
{ tableId: tableMetadata.tableId },
);
const initialSort = tableMetadata.initialSort;
@@ -1,11 +1,11 @@
import { type TableSortValue } from '@/ui/layout/table/types/TableSortValue';
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
import { createFamilyStateV2 } from '@/ui/utilities/state/jotai/utils/createFamilyStateV2';
export type SortedFieldByTableFamilyStateKey = {
tableId: string;
};
export const sortedFieldByTableFamilyState = createFamilyState<
export const sortedFieldByTableFamilyState = createFamilyStateV2<
TableSortValue | null,
SortedFieldByTableFamilyStateKey
>({
@@ -2,9 +2,11 @@ import { useCallback } from 'react';
import { isDragSelectionStartEnabledStateV2 } from '@/ui/utilities/drag-select/states/internal/isDragSelectionStartEnabledStateV2';
import { useSetRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetRecoilStateV2';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { useStore } from 'jotai';
export const useDragSelect = () => {
const store = useStore();
const setIsDragSelectionStartEnabled = useSetRecoilStateV2(
isDragSelectionStartEnabledStateV2,
);
@@ -14,8 +16,8 @@ export const useDragSelect = () => {
};
const isDragSelectionStartEnabled = useCallback(
() => jotaiStore.get(isDragSelectionStartEnabledStateV2.atom),
[],
() => store.get(isDragSelectionStartEnabledStateV2.atom),
[store],
);
return {
@@ -2,16 +2,27 @@ import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePush
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
import { renderHook } from '@testing-library/react';
import { createStore, Provider as JotaiProvider } from 'jotai';
import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
const createTestWrapper = () => {
const store = createStore();
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<JotaiProvider store={store}>{children}</JotaiProvider>
);
return Wrapper;
};
const renderHooks = () => {
const { result } = renderHook(
() => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
const focusStack = useRecoilValueV2(focusStackState);
const currentFocusId = useRecoilValueV2(currentFocusIdSelector);
return {
pushFocusItemToFocusStack,
@@ -20,7 +31,7 @@ const renderHooks = () => {
};
},
{
wrapper: RecoilRoot,
wrapper: createTestWrapper(),
},
);
@@ -3,9 +3,18 @@ import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
import { renderHook } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
);
const renderHooks = () => {
const { result } = renderHook(
@@ -13,8 +22,8 @@ const renderHooks = () => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
const focusStack = useRecoilValueV2(focusStackState);
const currentFocusId = useRecoilValueV2(currentFocusIdSelector);
return {
pushFocusItemToFocusStack,
@@ -24,7 +33,7 @@ const renderHooks = () => {
};
},
{
wrapper: RecoilRoot,
wrapper: Wrapper,
},
);
@@ -56,6 +65,10 @@ const secondFocusItem = {
};
describe('useRemoveFocusItemFromFocusStackById', () => {
beforeEach(() => {
resetJotaiStore();
});
it('should remove focus item from the stack', async () => {
const { result } = renderHooks();
@@ -3,17 +3,26 @@ import { useResetFocusStack } from '@/ui/utilities/focus/hooks/useResetFocusStac
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
import { renderHook } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
);
const renderHooks = () => {
const { result } = renderHook(
() => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { resetFocusStack } = useResetFocusStack();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
const focusStack = useRecoilValueV2(focusStackState);
const currentFocusId = useRecoilValueV2(currentFocusIdSelector);
return {
pushFocusItemToFocusStack,
@@ -23,7 +32,7 @@ const renderHooks = () => {
};
},
{
wrapper: RecoilRoot,
wrapper: Wrapper,
},
);
@@ -31,6 +40,10 @@ const renderHooks = () => {
};
describe('useResetFocusStack', () => {
beforeEach(() => {
resetJotaiStore();
});
it('should reset the focus stack', async () => {
const { result } = renderHooks();
@@ -3,17 +3,26 @@ import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useRes
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
import { renderHook } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { act } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
);
const renderHooks = () => {
const { result } = renderHook(
() => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { resetFocusStackToFocusItem } = useResetFocusStackToFocusItem();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
const focusStack = useRecoilValueV2(focusStackState);
const currentFocusId = useRecoilValueV2(currentFocusIdSelector);
return {
pushFocusItemToFocusStack,
@@ -23,7 +32,7 @@ const renderHooks = () => {
};
},
{
wrapper: RecoilRoot,
wrapper: Wrapper,
},
);
@@ -31,6 +40,10 @@ const renderHooks = () => {
};
describe('useResetFocusStackToFocusItem', () => {
beforeEach(() => {
resetJotaiStore();
});
it('should reset the focus stack to a specific focus item', async () => {
const { result } = renderHooks();
@@ -1,9 +1,11 @@
import { useCallback } from 'react';
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { type FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { type FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
import { type GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
import { useRecoilCallback } from 'recoil';
import { useStore } from 'jotai';
import { logDebug } from '~/utils/logDebug';
const addOrMoveItemToTheTopOfTheStack = ({
@@ -21,54 +23,53 @@ const addOrMoveItemToTheTopOfTheStack = ({
];
export const usePushFocusItemToFocusStack = () => {
const pushFocusItemToFocusStack = useRecoilCallback(
({ snapshot, set }) =>
({
const store = useStore();
const pushFocusItemToFocusStack = useCallback(
({
focusId,
component,
globalHotkeysConfig,
}: {
focusId: string;
component: {
type: FocusComponentType;
instanceId: string;
};
globalHotkeysConfig?: Partial<GlobalHotkeysConfig>;
}) => {
const focusStackItem: FocusStackItem = {
focusId,
component,
globalHotkeysConfig,
}: {
focusId: string;
component: {
type: FocusComponentType;
instanceId: string;
};
globalHotkeysConfig?: Partial<GlobalHotkeysConfig>;
}) => {
const focusStackItem: FocusStackItem = {
focusId,
componentInstance: {
componentType: component.type,
componentInstanceId: component.instanceId,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers:
globalHotkeysConfig?.enableGlobalHotkeysWithModifiers ?? true,
enableGlobalHotkeysConflictingWithKeyboard:
globalHotkeysConfig?.enableGlobalHotkeysConflictingWithKeyboard ??
true,
},
};
componentInstance: {
componentType: component.type,
componentInstanceId: component.instanceId,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers:
globalHotkeysConfig?.enableGlobalHotkeysWithModifiers ?? true,
enableGlobalHotkeysConflictingWithKeyboard:
globalHotkeysConfig?.enableGlobalHotkeysConflictingWithKeyboard ??
true,
},
};
const currentFocusStack = snapshot
.getLoadable(focusStackState)
.getValue();
const currentFocusStack = store.get(focusStackState.atom);
const newFocusStack = addOrMoveItemToTheTopOfTheStack({
const newFocusStack = addOrMoveItemToTheTopOfTheStack({
focusStackItem,
currentFocusStack,
});
store.set(focusStackState.atom, newFocusStack);
if (DEBUG_FOCUS_STACK) {
logDebug(`DEBUG: pushFocusItemToFocusStack ${focusId}`, {
focusStackItem,
currentFocusStack,
newFocusStack,
});
set(focusStackState, newFocusStack);
if (DEBUG_FOCUS_STACK) {
logDebug(`DEBUG: pushFocusItemToFocusStack ${focusId}`, {
focusStackItem,
newFocusStack,
});
}
},
[],
}
},
[store],
);
return { pushFocusItemToFocusStack };
@@ -1,48 +1,51 @@
import { useCallback } from 'react';
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { type FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilCallback } from 'recoil';
import { useStore } from 'jotai';
import { logDebug } from '~/utils/logDebug';
export const useRemoveLastFocusItemFromFocusStackByComponentType = () => {
const removeLastFocusItemFromFocusStackByComponentType = useRecoilCallback(
({ snapshot, set }) =>
({ componentType }: { componentType: FocusComponentType }) => {
const focusStack = snapshot.getLoadable(focusStackState).getValue();
const store = useStore();
const lastMatchingIndex = focusStack.findLastIndex(
(focusStackItem) =>
focusStackItem.componentInstance.componentType === componentType,
);
const removeLastFocusItemFromFocusStackByComponentType = useCallback(
({ componentType }: { componentType: FocusComponentType }) => {
const focusStack = store.get(focusStackState.atom);
if (lastMatchingIndex === -1) {
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: removeFocusItemFromFocusStackByComponentType - no item found for type ${componentType}`,
{ focusStack },
);
}
return;
}
const removedFocusItem = focusStack[lastMatchingIndex];
const newFocusStack = focusStack.filter(
(_, index) => index !== lastMatchingIndex,
);
set(focusStackState, newFocusStack);
const lastMatchingIndex = focusStack.findLastIndex(
(focusStackItem) =>
focusStackItem.componentInstance.componentType === componentType,
);
if (lastMatchingIndex === -1) {
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: removeFocusItemFromFocusStackByComponentType ${componentType}`,
{
removedFocusItem,
newFocusStack,
},
`DEBUG: removeFocusItemFromFocusStackByComponentType - no item found for type ${componentType}`,
{ focusStack },
);
}
},
[],
return;
}
const removedFocusItem = focusStack[lastMatchingIndex];
const newFocusStack = focusStack.filter(
(_, index) => index !== lastMatchingIndex,
);
store.set(focusStackState.atom, newFocusStack);
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: removeFocusItemFromFocusStackByComponentType ${componentType}`,
{
removedFocusItem,
newFocusStack,
},
);
}
},
[store],
);
return { removeLastFocusItemFromFocusStackByComponentType };
@@ -1,35 +1,38 @@
import { useCallback } from 'react';
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { useRecoilCallback } from 'recoil';
import { useStore } from 'jotai';
import { logDebug } from '~/utils/logDebug';
export const useRemoveFocusItemFromFocusStackById = () => {
const removeFocusItemFromFocusStackById = useRecoilCallback(
({ snapshot, set }) =>
({ focusId }: { focusId: string }) => {
const focusStack = snapshot.getLoadable(focusStackState).getValue();
const store = useStore();
const removedFocusItem = focusStack.find(
(focusStackItem) => focusStackItem.focusId === focusId,
);
const removeFocusItemFromFocusStackById = useCallback(
({ focusId }: { focusId: string }) => {
const focusStack = store.get(focusStackState.atom);
if (!removedFocusItem) {
return;
}
const removedFocusItem = focusStack.find(
(focusStackItem) => focusStackItem.focusId === focusId,
);
const newFocusStack = focusStack.filter(
(focusStackItem) => focusStackItem.focusId !== focusId,
);
if (!removedFocusItem) {
return;
}
set(focusStackState, newFocusStack);
const newFocusStack = focusStack.filter(
(focusStackItem) => focusStackItem.focusId !== focusId,
);
if (DEBUG_FOCUS_STACK) {
logDebug(`DEBUG: removeFocusItemFromFocusStack ${focusId}`, {
newFocusStack,
});
}
},
[],
store.set(focusStackState.atom, newFocusStack);
if (DEBUG_FOCUS_STACK) {
logDebug(`DEBUG: removeFocusItemFromFocusStack ${focusId}`, {
newFocusStack,
});
}
},
[store],
);
return { removeFocusItemFromFocusStackById };
@@ -1,20 +1,20 @@
import { useCallback } from 'react';
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { useRecoilCallback } from 'recoil';
import { useStore } from 'jotai';
import { logDebug } from '~/utils/logDebug';
export const useResetFocusStack = () => {
const resetFocusStack = useRecoilCallback(
({ reset }) =>
() => {
reset(focusStackState);
const store = useStore();
if (DEBUG_FOCUS_STACK) {
logDebug(`DEBUG: reset focus stack`);
}
},
[],
);
const resetFocusStack = useCallback(() => {
store.set(focusStackState.atom, []);
if (DEBUG_FOCUS_STACK) {
logDebug(`DEBUG: reset focus stack`);
}
}, [store]);
return { resetFocusStack };
};
@@ -1,22 +1,25 @@
import { useCallback } from 'react';
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { type FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
import { useRecoilCallback } from 'recoil';
import { useStore } from 'jotai';
import { logDebug } from '~/utils/logDebug';
export const useResetFocusStackToFocusItem = () => {
const resetFocusStackToFocusItem = useRecoilCallback(
({ set }) =>
({ focusStackItem }: { focusStackItem: FocusStackItem }) => {
set(focusStackState, [focusStackItem]);
const store = useStore();
if (DEBUG_FOCUS_STACK) {
logDebug(`DEBUG: reset focus stack to focus item`, {
focusStackItem,
});
}
},
[],
const resetFocusStackToFocusItem = useCallback(
({ focusStackItem }: { focusStackItem: FocusStackItem }) => {
store.set(focusStackState.atom, [focusStackItem]);
if (DEBUG_FOCUS_STACK) {
logDebug(`DEBUG: reset focus stack to focus item`, {
focusStackItem,
});
}
},
[store],
);
return { resetFocusStackToFocusItem };
@@ -1,7 +1,7 @@
import { selector } from 'recoil';
import { createSelectorV2 } from '@/ui/utilities/state/jotai/utils/createSelectorV2';
import { focusStackState } from './focusStackState';
export const currentFocusIdSelector = selector<string | undefined>({
export const currentFocusIdSelector = createSelectorV2<string | undefined>({
key: 'currentFocusIdSelector',
get: ({ get }) => {
const focusStack = get(focusStackState);
@@ -1,8 +1,10 @@
import { type FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
import { selector } from 'recoil';
import { createSelectorV2 } from '@/ui/utilities/state/jotai/utils/createSelectorV2';
import { focusStackState } from './focusStackState';
export const currentFocusedItemSelector = selector<FocusStackItem | undefined>({
export const currentFocusedItemSelector = createSelectorV2<
FocusStackItem | undefined
>({
key: 'currentFocusedItemSelector',
get: ({ get }) => {
const focusStack = get(focusStackState);
@@ -1,11 +1,11 @@
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { DEFAULT_GLOBAL_HOTKEYS_CONFIG } from '@/ui/utilities/hotkey/constants/DefaultGlobalHotkeysConfig';
import { type GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
import { selector } from 'recoil';
import { createSelectorV2 } from '@/ui/utilities/state/jotai/utils/createSelectorV2';
import { isDefined } from 'twenty-shared/utils';
export const currentGlobalHotkeysConfigSelector = selector<GlobalHotkeysConfig>(
{
export const currentGlobalHotkeysConfigSelector =
createSelectorV2<GlobalHotkeysConfig>({
key: 'currentGlobalHotkeysConfigSelector',
get: ({ get }) => {
const focusStack = get(focusStackState);
@@ -17,5 +17,4 @@ export const currentGlobalHotkeysConfigSelector = selector<GlobalHotkeysConfig>(
return lastFocusStackItem.globalHotkeysConfig;
},
},
);
});
@@ -1,7 +1,7 @@
import { type FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
import { createState } from '@/ui/utilities/state/utils/createState';
import { createStateV2 } from '@/ui/utilities/state/jotai/utils/createStateV2';
export const focusStackState = createState<FocusStackItem[]>({
export const focusStackState = createStateV2<FocusStackItem[]>({
key: 'focusStackState',
defaultValue: [],
});
@@ -1,12 +1,14 @@
import { useCallback } from 'react';
import { useGlobalHotkeysCallback } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeysCallback';
import { pendingHotkeyState } from '@/ui/utilities/hotkey/states/internal/pendingHotkeysState';
import { useStore } from 'jotai';
import { useHotkeys } from 'react-hotkeys-hook';
import {
type HotkeyCallback,
type Keys,
type Options,
} from 'react-hotkeys-hook/dist/types';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
type UseHotkeysOptionsWithoutBuggyOptions = Omit<Options, 'enabled'>;
@@ -24,6 +26,8 @@ export const useGlobalHotkeys = ({
dependencies?: unknown[];
options?: UseHotkeysOptionsWithoutBuggyOptions;
}) => {
const store = useStore();
const callGlobalHotkeysCallback = useGlobalHotkeysCallback(dependencies);
const enableOnContentEditable = isDefined(options?.enableOnContentEditable)
@@ -42,20 +46,17 @@ export const useGlobalHotkeys = ({
? options.ignoreModifiers === true
: false;
const handleCallback = useRecoilCallback(
({ snapshot, set }) =>
async (keyboardEvent: KeyboardEvent, hotkeysEvent: any) => {
const pendingHotkey = snapshot
.getLoadable(pendingHotkeyState)
.getValue();
const handleCallback = useCallback(
async (keyboardEvent: KeyboardEvent, hotkeysEvent: any) => {
const pendingHotkey = store.get(pendingHotkeyState.atom);
if (!pendingHotkey) {
callback(keyboardEvent, hotkeysEvent);
}
if (!pendingHotkey) {
callback(keyboardEvent, hotkeysEvent);
}
set(pendingHotkeyState, null);
},
[callback],
store.set(pendingHotkeyState.atom, null);
},
[callback, store],
);
return useHotkeys(
@@ -1,83 +1,86 @@
import { useCallback } from 'react';
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
import { currentGlobalHotkeysConfigSelector } from '@/ui/utilities/focus/states/currentGlobalHotkeysConfigSelector';
import { useStore } from 'jotai';
import {
type Hotkey,
type OptionsOrDependencyArray,
} from 'react-hotkeys-hook/dist/types';
import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug';
export const useGlobalHotkeysCallback = (
dependencies?: OptionsOrDependencyArray,
) => {
const store = useStore();
const dependencyArray = Array.isArray(dependencies) ? dependencies : [];
return useRecoilCallback(
({ snapshot }) =>
({
callback,
containsModifier,
hotkeysEvent,
keyboardEvent,
preventDefault,
}: {
keyboardEvent: KeyboardEvent;
hotkeysEvent: Hotkey;
containsModifier: boolean;
callback: (keyboardEvent: KeyboardEvent, hotkeysEvent: Hotkey) => void;
preventDefault?: boolean;
}) => {
const currentGlobalHotkeysConfig = snapshot
.getLoadable(currentGlobalHotkeysConfigSelector)
.getValue();
return useCallback(
({
callback,
containsModifier,
hotkeysEvent,
keyboardEvent,
preventDefault,
}: {
keyboardEvent: KeyboardEvent;
hotkeysEvent: Hotkey;
containsModifier: boolean;
callback: (keyboardEvent: KeyboardEvent, hotkeysEvent: Hotkey) => void;
preventDefault?: boolean;
}) => {
const currentGlobalHotkeysConfig = store.get(
currentGlobalHotkeysConfigSelector.atom,
);
if (
containsModifier &&
!currentGlobalHotkeysConfig.enableGlobalHotkeysWithModifiers
) {
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because global hotkeys with modifiers are disabled`,
'color: gray; ',
);
}
return;
if (
containsModifier &&
!currentGlobalHotkeysConfig.enableGlobalHotkeysWithModifiers
) {
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because global hotkeys with modifiers are disabled`,
'color: gray; ',
);
}
if (
!containsModifier &&
!currentGlobalHotkeysConfig.enableGlobalHotkeysConflictingWithKeyboard
) {
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because global hotkeys conflicting with keyboard are disabled`,
'color: gray; ',
);
}
return;
return;
}
if (
!containsModifier &&
!currentGlobalHotkeysConfig.enableGlobalHotkeysConflictingWithKeyboard
) {
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because global hotkeys conflicting with keyboard are disabled`,
'color: gray; ',
);
}
return;
}
if (preventDefault === true) {
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: %cI prevent global default for hotkey (${hotkeysEvent.keys})`,
'color: gray;',
);
}
if (preventDefault === true) {
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: %cI prevent global default for hotkey (${hotkeysEvent.keys})`,
'color: gray;',
);
}
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
keyboardEvent.stopImmediatePropagation();
}
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
keyboardEvent.stopImmediatePropagation();
}
return callback(keyboardEvent, hotkeysEvent);
},
return callback(keyboardEvent, hotkeysEvent);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
dependencyArray,
[...dependencyArray, store],
);
};
@@ -1,10 +1,10 @@
import { type Options, useHotkeys } from 'react-hotkeys-hook';
import { type Keys } from 'react-hotkeys-hook/dist/types';
import { useRecoilState } from 'recoil';
import { pendingHotkeyState } from '@/ui/utilities/hotkey/states/internal/pendingHotkeysState';
import { useGlobalHotkeysCallback } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeysCallback';
import { useRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilStateV2';
import { isDefined } from 'twenty-shared/utils';
export const useGlobalHotkeysSequence = (
@@ -18,7 +18,8 @@ export const useGlobalHotkeysSequence = (
},
deps: any[] = [],
) => {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
const [pendingHotkey, setPendingHotkey] =
useRecoilStateV2(pendingHotkeyState);
const callGlobalHotkeysCallback = useGlobalHotkeysCallback();
@@ -1,12 +1,12 @@
import { useHotkeysOnFocusedElementCallback } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElementCallback';
import { pendingHotkeyState } from '@/ui/utilities/hotkey/states/internal/pendingHotkeysState';
import { useRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilStateV2';
import { useHotkeys } from 'react-hotkeys-hook';
import {
type HotkeyCallback,
type Keys,
type Options,
} from 'react-hotkeys-hook/dist/types';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
type UseHotkeysOptionsWithoutBuggyOptions = Omit<Options, 'enabled'>;
@@ -24,7 +24,8 @@ export const useHotkeysOnFocusedElement = ({
dependencies?: unknown[];
options?: UseHotkeysOptionsWithoutBuggyOptions;
}) => {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
const [pendingHotkey, setPendingHotkey] =
useRecoilStateV2(pendingHotkeyState);
const callScopedHotkeyCallback =
useHotkeysOnFocusedElementCallback(dependencies);
@@ -1,74 +1,75 @@
import { useCallback } from 'react';
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { useStore } from 'jotai';
import {
type Hotkey,
type OptionsOrDependencyArray,
} from 'react-hotkeys-hook/dist/types';
import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
export const useHotkeysOnFocusedElementCallback = (
dependencies?: OptionsOrDependencyArray,
) => {
const store = useStore();
const dependencyArray = Array.isArray(dependencies) ? dependencies : [];
return useRecoilCallback(
({ snapshot }) =>
({
callback,
hotkeysEvent,
keyboardEvent,
focusId,
preventDefault,
}: {
keyboardEvent: KeyboardEvent;
hotkeysEvent: Hotkey;
callback: (keyboardEvent: KeyboardEvent, hotkeysEvent: Hotkey) => void;
focusId: string;
preventDefault?: boolean;
}) => {
const currentFocusId = snapshot
.getLoadable(currentFocusIdSelector)
.getValue();
if (currentFocusId !== focusId) {
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because I'm in [${focusId}] and the current focus identifier is [${currentFocusId}]`,
'color: gray; ',
);
}
return;
}
return useCallback(
({
callback,
hotkeysEvent,
keyboardEvent,
focusId,
preventDefault,
}: {
keyboardEvent: KeyboardEvent;
hotkeysEvent: Hotkey;
callback: (keyboardEvent: KeyboardEvent, hotkeysEvent: Hotkey) => void;
focusId: string;
preventDefault?: boolean;
}) => {
const currentFocusId = store.get(currentFocusIdSelector.atom);
if (currentFocusId !== focusId) {
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: %cI can call hotkey (${
`DEBUG: %cI can't call hotkey (${
hotkeysEvent.keys
}) because I'm in [${focusId}] and the current focus identifier is [${currentFocusId}]`,
'color: green;',
'color: gray; ',
);
}
if (preventDefault === true) {
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: %cI prevent default for hotkey (${hotkeysEvent.keys})`,
'color: gray;',
);
}
return;
}
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
keyboardEvent.stopImmediatePropagation();
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: %cI can call hotkey (${
hotkeysEvent.keys
}) because I'm in [${focusId}] and the current focus identifier is [${currentFocusId}]`,
'color: green;',
);
}
if (preventDefault === true) {
if (DEBUG_FOCUS_STACK) {
logDebug(
`DEBUG: %cI prevent default for hotkey (${hotkeysEvent.keys})`,
'color: gray;',
);
}
return callback(keyboardEvent, hotkeysEvent);
},
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();
keyboardEvent.stopImmediatePropagation();
}
return callback(keyboardEvent, hotkeysEvent);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
dependencyArray,
[...dependencyArray, store],
);
};
@@ -1,7 +1,7 @@
import { type Keys } from 'react-hotkeys-hook/dist/types';
import { createState } from '@/ui/utilities/state/utils/createState';
import { createStateV2 } from '@/ui/utilities/state/jotai/utils/createStateV2';
export const pendingHotkeyState = createState<Keys | null>({
export const pendingHotkeyState = createStateV2<Keys | null>({
key: 'pendingHotkeyState',
defaultValue: null,
});
@@ -1,5 +1,5 @@
import { createState } from '@/ui/utilities/state/utils/createState';
export const currentPageLocationState = createState<string>({
import { createStateV2 } from '@/ui/utilities/state/jotai/utils/createStateV2';
export const currentPageLocationState = createStateV2<string>({
key: 'currentPageLocationState',
defaultValue: '',
});
@@ -1,26 +1,38 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { Provider as JotaiProvider } from 'jotai';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { clickOutsideListenerIsActivatedComponentState } from '@/ui/utilities/pointer-event/states/clickOutsideListenerIsActivatedComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilComponentValueV2';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
const componentId = 'componentId';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
);
describe('useClickOutsideListener', () => {
beforeEach(() => {
resetJotaiStore();
});
it('should toggle the click outside listener activation state', async () => {
const { result } = renderHook(
() => {
return {
useClickOutside: useClickOutsideListener(componentId),
isActivated: useRecoilComponentValue(
isActivated: useRecoilComponentValueV2(
clickOutsideListenerIsActivatedComponentState,
componentId,
),
};
},
{
wrapper: RecoilRoot,
wrapper: Wrapper,
},
);
@@ -1,34 +1,31 @@
import { useRecoilCallback } from 'recoil';
import { useCallback } from 'react';
import { clickOutsideListenerIsActivatedComponentState } from '@/ui/utilities/pointer-event/states/clickOutsideListenerIsActivatedComponentState';
import { clickOutsideListenerMouseDownHappenedComponentState } from '@/ui/utilities/pointer-event/states/clickOutsideListenerMouseDownHappenedComponentState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useStore } from 'jotai';
export const useClickOutsideListener = (instanceId: string) => {
const clickOutsideListenerIsActivatedState = useRecoilComponentCallbackState(
clickOutsideListenerIsActivatedComponentState,
instanceId,
);
const store = useStore();
const clickOutsideListenerMouseDownHappenedState =
useRecoilComponentCallbackState(
clickOutsideListenerMouseDownHappenedComponentState,
instanceId,
);
const toggleClickOutside = useCallback(
(activated: boolean) => {
store.set(
clickOutsideListenerIsActivatedComponentState.atomFamily({
instanceId,
}),
activated,
);
const toggleClickOutside = useRecoilCallback(
({ set }) =>
(activated: boolean) => {
set(clickOutsideListenerIsActivatedState, activated);
if (!activated) {
set(clickOutsideListenerMouseDownHappenedState, false);
}
},
[
clickOutsideListenerIsActivatedState,
clickOutsideListenerMouseDownHappenedState,
],
if (!activated) {
store.set(
clickOutsideListenerMouseDownHappenedComponentState.atomFamily({
instanceId,
}),
false,
);
}
},
[instanceId, store],
);
return {
@@ -1,9 +1,9 @@
import { useCallback, useEffect, type RefObject } from 'react';
import { clickOutsideListenerIsActivatedComponentState } from '@/ui/utilities/pointer-event/states/clickOutsideListenerIsActivatedComponentState';
import { clickOutsideListenerIsMouseDownInsideComponentState } from '@/ui/utilities/pointer-event/states/clickOutsideListenerIsMouseDownInsideComponentState';
import { clickOutsideListenerMouseDownHappenedComponentState } from '@/ui/utilities/pointer-event/states/clickOutsideListenerMouseDownHappenedComponentState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useEffect, type RefObject } from 'react';
import { useRecoilCallback } from 'recoil';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
const CLICK_OUTSIDE_DEBUG_MODE = false;
@@ -23,132 +23,120 @@ export const useListenClickOutside = <T extends Element>({
listenerId,
enabled = true,
}: ClickOutsideListenerProps<T>) => {
const clickOutsideListenerIsMouseDownInsideState =
useRecoilComponentCallbackState(
clickOutsideListenerIsMouseDownInsideComponentState,
listenerId,
);
const clickOutsideListenerIsActivatedState = useRecoilComponentCallbackState(
clickOutsideListenerIsActivatedComponentState,
listenerId,
);
const clickOutsideListenerMouseDownHappenedState =
useRecoilComponentCallbackState(
clickOutsideListenerMouseDownHappenedComponentState,
listenerId,
);
const store = useStore();
const handleMouseDown = useRecoilCallback(
({ snapshot, set }) =>
(event: MouseEvent | TouchEvent) => {
const clickOutsideListenerIsActivated = snapshot
.getLoadable(clickOutsideListenerIsActivatedState)
.getValue();
const handleMouseDown = useCallback(
(event: MouseEvent | TouchEvent) => {
const clickOutsideListenerIsActivated = store.get(
clickOutsideListenerIsActivatedComponentState.atomFamily({
instanceId: listenerId,
}),
);
set(clickOutsideListenerMouseDownHappenedState, true);
store.set(
clickOutsideListenerMouseDownHappenedComponentState.atomFamily({
instanceId: listenerId,
}),
true,
);
const isListening = clickOutsideListenerIsActivated && enabled;
const isListening = clickOutsideListenerIsActivated && enabled;
if (!isListening) {
return;
}
if (!isListening) {
return;
}
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
set(clickOutsideListenerIsMouseDownInsideState, clickedOnAtLeastOneRef);
},
[
clickOutsideListenerIsActivatedState,
clickOutsideListenerMouseDownHappenedState,
enabled,
refs,
clickOutsideListenerIsMouseDownInsideState,
],
store.set(
clickOutsideListenerIsMouseDownInsideComponentState.atomFamily({
instanceId: listenerId,
}),
clickedOnAtLeastOneRef,
);
},
[listenerId, enabled, refs, store],
);
const handleClickOutside = useRecoilCallback(
({ snapshot }) =>
(event: MouseEvent | TouchEvent) => {
const clickOutsideListenerIsActivated = snapshot
.getLoadable(clickOutsideListenerIsActivatedState)
.getValue();
const handleClickOutside = useCallback(
(event: MouseEvent | TouchEvent) => {
const clickOutsideListenerIsActivated = store.get(
clickOutsideListenerIsActivatedComponentState.atomFamily({
instanceId: listenerId,
}),
);
const isListening = clickOutsideListenerIsActivated && enabled;
const isListening = clickOutsideListenerIsActivated && enabled;
const isMouseDownInside = snapshot
.getLoadable(clickOutsideListenerIsMouseDownInsideState)
.getValue();
const isMouseDownInside = store.get(
clickOutsideListenerIsMouseDownInsideComponentState.atomFamily({
instanceId: listenerId,
}),
);
const hasMouseDownHappened = snapshot
.getLoadable(clickOutsideListenerMouseDownHappenedState)
.getValue();
const hasMouseDownHappened = store.get(
clickOutsideListenerMouseDownHappenedComponentState.atomFamily({
instanceId: listenerId,
}),
);
const clickedElement = event.target as HTMLElement;
let isClickedOnExcluded = false;
let currentElement: HTMLElement | null = clickedElement;
const clickedElement = event.target as HTMLElement;
let isClickedOnExcluded = false;
let currentElement: HTMLElement | null = clickedElement;
while (currentElement) {
const currentDataAttributes = currentElement.dataset;
const isGloballyExcluded =
currentDataAttributes?.globallyPreventClickOutside === 'true';
while (currentElement) {
const currentDataAttributes = currentElement.dataset;
const isGloballyExcluded =
currentDataAttributes?.globallyPreventClickOutside === 'true';
const clickOutsideId = currentDataAttributes?.clickOutsideId;
const clickOutsideId = currentDataAttributes?.clickOutsideId;
isClickedOnExcluded =
isGloballyExcluded ||
(isDefined(clickOutsideId) &&
isDefined(excludedClickOutsideIds) &&
excludedClickOutsideIds.includes(clickOutsideId));
isClickedOnExcluded =
isGloballyExcluded ||
(isDefined(clickOutsideId) &&
isDefined(excludedClickOutsideIds) &&
excludedClickOutsideIds.includes(clickOutsideId));
if (isClickedOnExcluded) {
break;
}
currentElement = currentElement.parentElement;
if (isClickedOnExcluded) {
break;
}
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
currentElement = currentElement.parentElement;
}
const shouldTrigger =
isListening &&
hasMouseDownHappened &&
!clickedOnAtLeastOneRef &&
!isMouseDownInside &&
!isClickedOnExcluded;
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
if (CLICK_OUTSIDE_DEBUG_MODE) {
// eslint-disable-next-line no-console
console.log('click outside compare ref', {
listenerId,
shouldTrigger,
clickedOnAtLeastOneRef,
isMouseDownInside,
isListening,
hasMouseDownHappened,
isClickedOnExcluded,
enabled,
event,
});
}
const shouldTrigger =
isListening &&
hasMouseDownHappened &&
!clickedOnAtLeastOneRef &&
!isMouseDownInside &&
!isClickedOnExcluded;
if (shouldTrigger) {
callback(event);
}
},
[
clickOutsideListenerIsActivatedState,
enabled,
clickOutsideListenerIsMouseDownInsideState,
clickOutsideListenerMouseDownHappenedState,
refs,
excludedClickOutsideIds,
callback,
listenerId,
],
if (CLICK_OUTSIDE_DEBUG_MODE) {
// eslint-disable-next-line no-console
console.log('click outside compare ref', {
listenerId,
shouldTrigger,
clickedOnAtLeastOneRef,
isMouseDownInside,
isListening,
hasMouseDownHappened,
isClickedOnExcluded,
enabled,
event,
});
}
if (shouldTrigger) {
callback(event);
}
},
[listenerId, enabled, refs, excludedClickOutsideIds, callback, store],
);
useEffect(() => {
@@ -1,8 +1,8 @@
import { ClickOutsideListenerComponentInstanceContext } from '@/ui/utilities/pointer-event/states/contexts/ClickOutsideListenerComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { createComponentStateV2 } from '@/ui/utilities/state/jotai/utils/createComponentStateV2';
export const clickOutsideListenerIsActivatedComponentState =
createComponentState<boolean>({
createComponentStateV2<boolean>({
key: 'clickOutsideListenerIsActivatedComponentState',
defaultValue: true,
componentInstanceContext: ClickOutsideListenerComponentInstanceContext,
@@ -1,8 +1,8 @@
import { ClickOutsideListenerComponentInstanceContext } from '@/ui/utilities/pointer-event/states/contexts/ClickOutsideListenerComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { createComponentStateV2 } from '@/ui/utilities/state/jotai/utils/createComponentStateV2';
export const clickOutsideListenerIsMouseDownInsideComponentState =
createComponentState<boolean>({
createComponentStateV2<boolean>({
key: 'clickOutsideListenerIsMouseDownInsideComponentState',
defaultValue: false,
componentInstanceContext: ClickOutsideListenerComponentInstanceContext,
@@ -1,8 +1,8 @@
import { ClickOutsideListenerComponentInstanceContext } from '@/ui/utilities/pointer-event/states/contexts/ClickOutsideListenerComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { createComponentStateV2 } from '@/ui/utilities/state/jotai/utils/createComponentStateV2';
export const clickOutsideListenerMouseDownHappenedComponentState =
createComponentState<boolean>({
createComponentStateV2<boolean>({
key: 'clickOutsideListenerMouseDownHappenedComponentState',
defaultValue: false,
componentInstanceContext: ClickOutsideListenerComponentInstanceContext,
@@ -5,7 +5,7 @@ import { ScrollWrapperComponentInstanceContext } from '@/ui/utilities/scroll/sta
import { scrollWrapperScrollBottomComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollBottomComponentState';
import { scrollWrapperScrollLeftComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollLeftComponentState';
import { scrollWrapperScrollTopComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollTopComponentState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetRecoilComponentStateV2';
const StyledScrollWrapper = styled.div`
&.scroll-wrapper-x-enabled {
@@ -35,17 +35,17 @@ export const ScrollWrapper = ({
defaultEnableXScroll = true,
defaultEnableYScroll = true,
}: ScrollWrapperProps) => {
const setScrollTop = useSetRecoilComponentState(
const setScrollTop = useSetRecoilComponentStateV2(
scrollWrapperScrollTopComponentState,
componentInstanceId,
);
const setScrollLeft = useSetRecoilComponentState(
const setScrollLeft = useSetRecoilComponentStateV2(
scrollWrapperScrollLeftComponentState,
componentInstanceId,
);
const setScrollBottom = useSetRecoilComponentState(
const setScrollBottom = useSetRecoilComponentStateV2(
scrollWrapperScrollBottomComponentState,
componentInstanceId,
);
@@ -1,6 +1,6 @@
import { SCROLL_RESTORATION_TOP_THRESHOLD_PX } from '@/ui/utilities/scroll/constants/ScrollRestorationTopThreshold';
import { scrollWrapperScrollTopComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollTopComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilComponentValueV2';
import { useCallback, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
@@ -10,7 +10,7 @@ export const useScrollRestoration = (componentInstanceId: string) => {
const storageKey = `scroll-${location.pathname}`;
const [isRestoring, setIsRestoring] = useState(false);
const scrollTop = useRecoilComponentValue(
const scrollTop = useRecoilComponentValueV2(
scrollWrapperScrollTopComponentState,
componentInstanceId,
);
@@ -1,8 +1,8 @@
import { ScrollWrapperComponentInstanceContext } from '@/ui/utilities/scroll/states/contexts/ScrollWrapperComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { createComponentStateV2 } from '@/ui/utilities/state/jotai/utils/createComponentStateV2';
export const scrollWrapperScrollBottomComponentState =
createComponentState<number>({
createComponentStateV2<number>({
key: 'scrollWrapperScrollBottomComponentState',
defaultValue: 0,
componentInstanceContext: ScrollWrapperComponentInstanceContext,
@@ -1,8 +1,8 @@
import { ScrollWrapperComponentInstanceContext } from '@/ui/utilities/scroll/states/contexts/ScrollWrapperComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { createComponentStateV2 } from '@/ui/utilities/state/jotai/utils/createComponentStateV2';
export const scrollWrapperScrollLeftComponentState =
createComponentState<number>({
createComponentStateV2<number>({
key: 'scrollWrapperScrollLeftComponentState',
defaultValue: 0,
componentInstanceContext: ScrollWrapperComponentInstanceContext,
@@ -1,8 +1,8 @@
import { ScrollWrapperComponentInstanceContext } from '@/ui/utilities/scroll/states/contexts/ScrollWrapperComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { createComponentStateV2 } from '@/ui/utilities/state/jotai/utils/createComponentStateV2';
export const scrollWrapperScrollTopComponentState =
createComponentState<number>({
createComponentStateV2<number>({
key: 'scrollWrapperScrollTopComponentState',
defaultValue: 0,
componentInstanceContext: ScrollWrapperComponentInstanceContext,
@@ -0,0 +1,427 @@
# Recoil → Jotai Migration Plan
## Overview
- **429 state files** + **69 selector files** = ~500 total Recoil-based state definitions
- **5 already migrated** (dropdown component states)
- Organized into **14 PRs** by module area, starting from lowest-level (fewest dependencies) to highest-level
## State Creation Patterns (Recoil → Jotai)
| Recoil Pattern | Count | Jotai Equivalent |
|---|---|---|
| `createState()` | ~100 global states | `createStateV2()` → Jotai `atom()` |
| `createComponentState()` | ~200 component states | `createComponentStateV2()` → Jotai `atom()` with instance cache |
| `createComponentFamilyState()` | ~27 component family states | New `createComponentFamilyStateV2()` → Jotai `atom()` with two-level cache |
| `createComponentSelector()` | ~28 component selectors | New `createComponentSelectorV2()` → Jotai derived `atom()` |
| `createComponentFamilySelector()` | ~9 component family selectors | New `createComponentFamilySelectorV2()` |
| Raw `atom()` from recoil | 3 | Jotai `atom()` via `createStateV2()` |
| Raw `atomFamily()` from recoil | 3 | Jotai `atom()` via `createFamilyStateV2()` |
| Raw `selector()` from recoil | 11 | Jotai derived `atom()` via `createSelectorV2()` |
| Raw `selectorFamily()` from recoil | ~21 | Jotai derived `atom()` via family pattern |
## Hook Migration
| Recoil Hook | Jotai Equivalent |
|---|---|
| `useRecoilValue()` | `useRecoilValueV2()` (already exists, wraps `useAtomValue`) |
| `useRecoilState()` | `useRecoilStateV2()` (already exists, wraps `useAtom`) |
| `useSetRecoilState()` | `useSetRecoilStateV2()` (already exists, wraps `useSetAtom`) |
| `useResetRecoilState()` | `jotaiStore.set(atom, defaultValue)` or new `useResetAtom` wrapper |
| `useRecoilComponentValue()` | `useRecoilComponentValueV2()` (already exists) |
| `useSetRecoilComponentState()` | `useSetRecoilComponentStateV2()` (already exists) |
| `useRecoilComponentState()` | `useRecoilComponentStateV2()` (already exists) |
| `useRecoilCallback()` | `useCallback()` + `jotaiStore.get/set` |
## Infrastructure Needed Before Migration
Before starting the PRs below, these Jotai V2 utilities need to be created (if not already):
- [x] `createStateV2()` — global atom
- [x] `createFamilyStateV2()` — global atom family
- [x] `createSelectorV2()` — global derived atom
- [x] `createComponentStateV2()` — instance-scoped atom
- [ ] `createComponentFamilyStateV2()` — instance-scoped atom family
- [ ] `createComponentSelectorV2()` — instance-scoped derived atom
- [ ] `createComponentFamilySelectorV2()` — instance-scoped derived atom family
- [ ] `createFamilySelectorV2()` — global derived atom family (for raw selectorFamily replacements)
---
## PR 1: UI Utilities — Focus, Hotkeys, Loading, Pointer Events, Scroll
Low-level utilities used across the entire app. No dependencies on other app states.
### States (16 files)
```
ui/utilities/focus/states/focusStackState.ts
ui/utilities/hotkey/states/internal/pendingHotkeysState.ts
ui/utilities/loading-state/states/currentPageLocationState.ts
ui/utilities/pointer-event/states/clickOutsideListenerIsActivatedComponentState.ts
ui/utilities/pointer-event/states/clickOutsideListenerIsMouseDownInsideComponentState.ts
ui/utilities/pointer-event/states/clickOutsideListenerMouseDownHappenedComponentState.ts
ui/utilities/scroll/states/scrollWrapperScrollBottomComponentState.ts
ui/utilities/scroll/states/scrollWrapperScrollLeftComponentState.ts
ui/utilities/scroll/states/scrollWrapperScrollTopComponentState.ts
```
### Selectors (3 files)
```
ui/utilities/focus/states/currentFocusIdSelector.ts
ui/utilities/focus/states/currentFocusedItemSelector.ts
ui/utilities/focus/states/currentGlobalHotkeysConfigSelector.ts
```
### Notes
- `focusStackState` is used by the dropdown hooks — needs to be migrated to unblock removing `useRecoilCallback` from dropdown hooks
- Focus selectors depend on `focusStackState`
---
## PR 2: UI Layout — Selectable List, Modal, Tab List, Table
Core layout primitives used by record-table, record-board, and many other modules.
### States (7 files)
```
ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState.ts
ui/layout/selectable-list/states/selectableItemIdsComponentState.ts
ui/layout/selectable-list/states/selectedItemIdComponentState.ts
ui/layout/modal/states/isModalOpenedComponentState.ts
ui/layout/tab-list/states/activeTabIdComponentState.ts
ui/layout/table/states/sortedFieldByTableFamilyState.ts
```
### Selectors (1 file)
```
ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector.ts
```
---
## PR 3: UI Navigation, Theme, Input, Feedback, Field
### States (12 files)
```
ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState.ts
ui/navigation/navigation-drawer/states/isNavigationSectionOpenFamilyState.ts
ui/navigation/states/isNavigationDrawerExpanded.ts (raw Recoil atom)
ui/navigation/states/navigationDrawerWidthState.ts
ui/navigation/states/navigationMemorizedUrlState.ts
ui/navigation/states/shouldNavigateBackToMemorizedUrlOnSaveState.ts
ui/navigation/step-bar/states/stepBarInternalState.ts
ui/theme/states/persistedColorSchemeState.ts
ui/input/states/iconPickerState.ts
ui/input/states/iconPickerVisibleCountState.ts
ui/feedback/dialog-manager/states/dialogInternalComponentState.ts
ui/feedback/snack-bar-manager/states/snackBarInternalComponentState.ts
ui/field/display/states/filePreviewState.ts
```
---
## PR 4: Auth, Domain Manager, App, Captcha, Chrome Extension
Core authentication and app-level states. Very widely imported (~195 external imports for auth).
### States (25 files)
```
auth/states/availableWorkspacesState.ts
auth/states/billingCheckoutSessionState.ts
auth/states/currentUserState.ts
auth/states/currentUserWorkspaceState.ts
auth/states/currentWorkspaceDeletedMembersState.ts
auth/states/currentWorkspaceMemberState.ts
auth/states/currentWorkspaceMembersState.ts
auth/states/currentWorkspaceState.ts
auth/states/isCurrentUserLoadedState.ts
auth/states/lastAuthenticatedMethodState.ts
auth/states/loginTokenState.ts
auth/states/previousUrlState.ts
auth/states/signInUpModeState.ts
auth/states/signInUpStepState.ts
auth/states/tokenPairState.ts
auth/states/workspacePublicDataState.ts
domain-manager/states/domainConfigurationState.ts
domain-manager/states/lastAuthenticatedWorkspaceDomainState.ts
app/states/isAppEffectRedirectEnabledState.ts
app/states/verifyEmailRedirectPathState.ts
captcha/states/captchaTokenState.ts
captcha/states/isCaptchaScriptLoadedState.ts
captcha/states/isRequestingCaptchaTokenState.ts
chrome-extension-sidecar/states/isLoadingTokensFromExtensionState.ts
workspace/states/workspaceAuthBypassProvidersState.ts
workspace/states/workspaceAuthProvidersState.ts
workspace/states/workspaceBypassModeState.ts
```
### Selectors (2 files)
```
auth/states/isImpersonatingState.ts (raw Recoil selector)
auth/states/objectPermissionsFamilySelector.ts
```
---
## PR 5: Client Config
26 simple global states, mostly boolean feature flags. Low risk.
### States (26 files)
```
client-config/states/aiModelsState.ts
client-config/states/apiConfigState.ts
client-config/states/appVersionState.ts
client-config/states/authProvidersState.ts
client-config/states/billingState.ts
client-config/states/calendarBookingPageIdState.ts
client-config/states/canManageFeatureFlagsState.ts
client-config/states/captchaState.ts
client-config/states/chromeExtensionIdState.ts
client-config/states/clientConfigApiStatusState.ts
client-config/states/isAnalyticsEnabledState.ts
client-config/states/isAttachmentPreviewEnabledState.ts
client-config/states/isClickHouseConfiguredState.ts
client-config/states/isCloudflareIntegrationEnabledState.ts
client-config/states/isConfigVariablesInDbEnabledState.ts
client-config/states/isDeveloperDefaultSignInPrefilledState.ts
client-config/states/isEmailVerificationRequiredState.ts
client-config/states/isEmailingDomainsEnabledState.ts
client-config/states/isGoogleCalendarEnabledState.ts
client-config/states/isGoogleMessagingEnabledState.ts
client-config/states/isImapSmtpCaldavEnabledState.ts
client-config/states/isMicrosoftCalendarEnabledState.ts
client-config/states/isMicrosoftMessagingEnabledState.ts
client-config/states/isMultiWorkspaceEnabledState.ts
client-config/states/sentryConfigState.ts
client-config/states/supportChatState.ts
```
---
## PR 6: Object Metadata
Core metadata states used by record-table, record-board, views, etc.
### States (3 files)
```
object-metadata/states/lastFieldMetadataItemUpdateState.ts
object-metadata/states/objectMetadataItemsState.ts
object-metadata/states/shouldAppBeLoadingState.ts
```
### Selectors (12 files)
```
object-metadata/states/availableFieldMetadataItemsForFilterFamilySelector.ts
object-metadata/states/availableFieldMetadataItemsForSortFamilySelector.ts
object-metadata/states/fieldMetadataItemByIdSelector.ts
object-metadata/states/flattenedFieldMetadataItemsSelector.ts
object-metadata/states/flattenedReadableFieldMetadataItemIdsSelector.ts
object-metadata/states/isFieldMetadataItemFilterableAndSortableSelector.ts
object-metadata/states/isFieldMetadataItemLabelIdentifierSelector.ts
object-metadata/states/labelIdentifierFieldMetadataItemSelector.ts
object-metadata/states/objectMetadataItemFamilySelector.ts
object-metadata/states/objectMetadataItemsByNamePluralMapSelector.ts
object-metadata/states/objectMetadataItemsByNameSingularMapSelector.ts
object-metadata/states/objectMetadataItemsBySingularNameSelector.ts
```
### Notes
- Object metadata selectors are raw `selectorFamily()` from Recoil — needs `createFamilySelectorV2()` utility
- Very widely depended on (~135 external imports)
---
## PR 7: Object Record — Record Store
The central record store, depended on by almost everything (~190 external imports).
### States (2 files)
```
object-record/states/recordStoreFamilyState.ts (raw atomFamily)
object-record/record-store/states/recordStoreFamilyState.ts
```
### Selectors (6 files)
```
object-record/record-store/states/selectors/recordStoreFamilySelector.ts
object-record/record-store/states/selectors/recordStoreFieldValueSelector.ts
object-record/record-store/states/selectors/recordStoreIdentifierSelector.ts
object-record/record-store/states/selectors/recordStoreIdentifiersSelector.ts
object-record/record-store/states/selectors/recordStoreMorphOneToManyValueWithObjectNameFamilySelector.ts
object-record/record-store/states/selectors/recordStoreRecordsSelector.ts
```
---
## PR 8: Object Record — Filters, Sorts, Groups, Drag, Field, Calendar, Board, Picker
### States (~80 files)
```
object-record/object-filter-dropdown/states/ (12 files)
object-record/object-sort-dropdown/states/ (3 files)
object-record/record-filter/states/ (2 files)
object-record/record-filter-group/states/ (1 file)
object-record/record-sort/states/ (1 file)
object-record/record-group/states/ (3 files)
object-record/record-drag/states/ (7 files)
object-record/record-field/states/ (7 files)
object-record/record-field-list/states/ (2 files)
object-record/record-calendar/states/ (6 files)
object-record/record-board/states/ (18 files)
object-record/record-picker/states/ (15 files)
object-record/record-right-drawer/states/ (2 files)
object-record/record-merge/states/ (2 files)
object-record/record-title-cell/states/ (1 file)
object-record/advanced-filter/states/ (1 selector)
```
### Selectors (~20 files)
```
object-record/record-group/states/selectors/ (6 files)
object-record/record-index/states/selectors/ (5 files)
object-record/record-board/states/selectors/ (2 files)
object-record/record-calendar/states/selectors/ (2 files)
object-record/record-picker/states/selectors/ (5 files)
object-record/record-field/states/ (1 selector)
object-record/object-filter-dropdown/states/ (1 selector)
```
---
## PR 9: Object Record — Record Table
Largest single module (40 states + 10 selectors).
### States (~40 files)
```
object-record/record-table/states/ (all component states)
object-record/record-table/record-table-row/states/
object-record/record-table/record-table-footer/states/
object-record/record-table/record-table-section/states/
object-record/record-table/virtualization/states/
```
### Selectors (~10 files)
```
object-record/record-table/states/selectors/
object-record/record-table/record-table-row/states/
object-record/record-table/virtualization/states/
```
---
## PR 10: Object Record — Record Index
### States (~17 files)
```
object-record/record-index/states/ (all files)
```
### Selectors (~5 files)
```
object-record/record-index/states/selectors/ (all files)
```
---
## PR 11: Views, Favorites, Navigation, Prefetch, Localization
### States (~30 files)
```
views/states/ (10 files)
views/view-picker/states/ (13 files)
favorites/favorite-folder-picker/states/ (4 files)
navigation/states/ (3 files)
prefetch/states/ (4 files)
localization/states/ (1 file)
information-banner/states/ (1 file)
```
### Selectors (~8 files)
```
views/states/selectors/ (6 files)
favorites/states/selectors/ (1 file)
favorites/favorite-folder-picker/states/selectors/ (1 file)
```
---
## PR 12: Context Store, Command Menu, Action Menu
### States (~22 files)
```
context-store/states/ (10 files)
command-menu/states/ (10 files)
command-menu/pages/*/states/ (10 files)
action-menu/states/ (1 file)
action-menu/actions/states/ (1 file)
```
---
## PR 13: Workflow, Page Layout, AI, Activities
### States (~45 files)
```
workflow/states/ (6 files)
workflow/workflow-diagram/states/ (12 files)
workflow/workflow-steps/states/ (7 files)
workflow/workflow-variables/states/ (2 files)
page-layout/states/ (17 files)
page-layout/widgets/states/ (10 files)
ai/states/ (2 files)
activities/states/ (5 files)
blocknote-editor/states/ (1 file)
```
### Selectors (~3 files)
```
workflow/states/selectors/ (2 files)
workflow/workflow-steps/filters/states/ (1 selector)
page-layout/states/selectors/ (1 file)
```
---
## PR 14: Settings, SSE, Spreadsheet Import, Misc
Leaf modules with few external consumers.
### States (~30 files)
```
settings/admin-panel/ (4 files)
settings/data-model/ (2 files)
settings/developers/ (1 file)
settings/domains/ (3 files)
settings/logic-functions/ (1 file)
settings/playground/ (1 file)
settings/roles/ (4 files)
settings/security/ (2 files)
sse-db-event/states/ (9 files)
spreadsheet-import/states/ (4 files)
```
### Selectors (1 file)
```
settings/roles/states/settingsAllRolesSelector.ts
```
---
## Testing Strategy
Each PR should:
1. Migrate state definitions (swap `createState``createStateV2`, etc.)
2. Update all consumer hooks (swap `useRecoilValue``useRecoilValueV2`, etc.)
3. Update `useRecoilCallback``useCallback` + `jotaiStore.get/set`
4. Run `npx nx typecheck twenty-front`
5. Run `npx nx test twenty-front` for affected test files
6. Verify affected Storybook stories
## Notes
- The `beforeEach: resetJotaiStore()` in `.storybook/preview.tsx` handles Storybook isolation globally
- Unit tests need `JotaiProvider store={jotaiStore}` in their wrappers and `beforeEach` resets for affected atoms
- Some states use `effects` (e.g., `cookieStorageEffect`, `localStorageEffect`) — these need Jotai `onMount` equivalents
- States with `useResetRecoilState` need a wrapper or direct `jotaiStore.set(atom, defaultValue)`
@@ -0,0 +1,31 @@
import { useAtom } from 'jotai';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import { type ComponentFamilyStateV2 } from '@/ui/utilities/state/jotai/types/ComponentFamilyStateV2';
export const useRecoilComponentFamilyStateV2 = <StateType, FamilyKey>(
componentState: ComponentFamilyStateV2<StateType, FamilyKey>,
familyKey: FamilyKey,
instanceIdFromProps?: string,
): [
StateType,
(value: StateType | ((prev: StateType) => StateType)) => void,
] => {
const componentInstanceContext = globalComponentInstanceContextMap.get(
componentState.key,
);
if (!componentInstanceContext) {
throw new Error(
`Instance context for key "${componentState.key}" is not defined`,
);
}
const instanceId = useAvailableComponentInstanceIdOrThrow(
componentInstanceContext,
instanceIdFromProps,
);
return useAtom(componentState.atomFamily({ instanceId, familyKey }));
};
@@ -0,0 +1,28 @@
import { useAtomValue } from 'jotai';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import { type ComponentFamilyStateV2 } from '@/ui/utilities/state/jotai/types/ComponentFamilyStateV2';
export const useRecoilComponentFamilyValueV2 = <StateType, FamilyKey>(
componentState: ComponentFamilyStateV2<StateType, FamilyKey>,
familyKey: FamilyKey,
instanceIdFromProps?: string,
): StateType => {
const componentInstanceContext = globalComponentInstanceContextMap.get(
componentState.key,
);
if (!componentInstanceContext) {
throw new Error(
`Instance context for key "${componentState.key}" is not defined`,
);
}
const instanceId = useAvailableComponentInstanceIdOrThrow(
componentInstanceContext,
instanceIdFromProps,
);
return useAtomValue(componentState.atomFamily({ instanceId, familyKey }));
};
@@ -0,0 +1,28 @@
import { useSetAtom } from 'jotai';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import { type ComponentFamilyStateV2 } from '@/ui/utilities/state/jotai/types/ComponentFamilyStateV2';
export const useSetRecoilComponentFamilyStateV2 = <ValueType, FamilyKey>(
componentState: ComponentFamilyStateV2<ValueType, FamilyKey>,
familyKey: FamilyKey,
instanceIdFromProps?: string,
): ((value: ValueType | ((prev: ValueType) => ValueType)) => void) => {
const componentInstanceContext = globalComponentInstanceContextMap.get(
componentState.key,
);
if (!componentInstanceContext) {
throw new Error(
`Instance context for key "${componentState.key}" is not defined`,
);
}
const instanceId = useAvailableComponentInstanceIdOrThrow(
componentInstanceContext,
instanceIdFromProps,
);
return useSetAtom(componentState.atomFamily({ instanceId, familyKey }));
};
@@ -0,0 +1,21 @@
import { type WritableAtom } from 'jotai';
import { type ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey';
type JotaiWritableAtom<ValueType> = WritableAtom<
ValueType,
[ValueType | ((prev: ValueType) => ValueType)],
void
>;
export type ComponentFamilyStateKey<FamilyKey> = ComponentStateKey & {
familyKey: FamilyKey;
};
export type ComponentFamilyStateV2<ValueType, FamilyKey> = {
type: 'ComponentFamilyStateV2';
key: string;
atomFamily: (
key: ComponentFamilyStateKey<FamilyKey>,
) => JotaiWritableAtom<ValueType>;
};
@@ -0,0 +1,57 @@
import { atom } from 'jotai';
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import {
type ComponentFamilyStateKey,
type ComponentFamilyStateV2,
} from '@/ui/utilities/state/jotai/types/ComponentFamilyStateV2';
import { isDefined } from 'twenty-shared/utils';
export const createComponentFamilyStateV2 = <ValueType, FamilyKey>({
key,
defaultValue,
componentInstanceContext,
}: {
key: string;
defaultValue: ValueType;
componentInstanceContext: ComponentInstanceStateContext<any> | null;
}): ComponentFamilyStateV2<ValueType, FamilyKey> => {
if (isDefined(componentInstanceContext)) {
globalComponentInstanceContextMap.set(key, componentInstanceContext);
}
const atomCache = new Map<
string,
ReturnType<ComponentFamilyStateV2<ValueType, FamilyKey>['atomFamily']>
>();
const familyFunction = ({
instanceId,
familyKey,
}: ComponentFamilyStateKey<FamilyKey>): ReturnType<
ComponentFamilyStateV2<ValueType, FamilyKey>['atomFamily']
> => {
const familyKeyStr =
typeof familyKey === 'string' ? familyKey : JSON.stringify(familyKey);
const cacheKey = `${instanceId}__${familyKeyStr}`;
const existing = atomCache.get(cacheKey);
if (existing !== undefined) {
return existing;
}
const baseAtom = atom(defaultValue);
baseAtom.debugLabel = `${key}__${cacheKey}`;
atomCache.set(cacheKey, baseAtom);
return baseAtom;
};
return {
type: 'ComponentFamilyStateV2',
key,
atomFamily: familyFunction,
};
};