Refactor snackbar old component scoped state (#13183)

This PR refactors the snackbar modules that was using legacy versions of
our state management.

We replace the old states with new ones and also the old scoped context
with component instance context.
This commit is contained in:
Lucas Bordeau
2025-07-11 18:05:09 +02:00
committed by GitHub
parent 69a6f4471e
commit bba1b296c1
23 changed files with 554 additions and 178 deletions
@@ -8,7 +8,7 @@ import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTa
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members';
@@ -112,9 +112,9 @@ const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider cache={cache}>
<JestObjectMetadataItemSetter>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<SnackBarComponentInstanceContextProvider snackBarComponentInstanceId="snack-bar-manager">
{children}
</SnackBarProviderScope>
</SnackBarComponentInstanceContextProvider>
</JestObjectMetadataItemSetter>
</MockedProvider>
</RecoilRoot>
@@ -4,7 +4,7 @@ import { RecoilDebugObserverEffect } from '@/debug/components/RecoilDebugObserve
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
import { AppRootErrorFallback } from '@/error-handler/components/AppRootErrorFallback';
import { ExceptionHandlerProvider } from '@/error-handler/components/ExceptionHandlerProvider';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
@@ -25,7 +25,7 @@ export const App = () => {
<I18nProvider i18n={i18n}>
<RecoilDebugObserverEffect />
<ApolloDevLogEffect />
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<SnackBarComponentInstanceContextProvider snackBarComponentInstanceId="snack-bar-manager">
<IconsProvider>
<ExceptionHandlerProvider>
<HelmetProvider>
@@ -37,7 +37,7 @@ export const App = () => {
</HelmetProvider>
</ExceptionHandlerProvider>
</IconsProvider>
</SnackBarProviderScope>
</SnackBarComponentInstanceContextProvider>
</I18nProvider>
</AppErrorBoundary>
</RecoilRoot>
@@ -2,7 +2,7 @@ import { useAuth } from '@/auth/hooks/useAuth';
import { billingState } from '@/client-config/states/billingState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { useApolloClient } from '@apollo/client';
import { MockedProvider } from '@apollo/client/testing';
@@ -14,8 +14,8 @@ import { RecoilRoot, useRecoilValue } from 'recoil';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { renderHook } from '@testing-library/react';
import { iconsState } from 'twenty-ui/display';
import { email, mocks, password, results, token } from '../__mocks__/useAuth';
import { SupportDriver } from '~/generated/graphql';
import { email, mocks, password, results, token } from '../__mocks__/useAuth';
const redirectSpy = jest.fn();
@@ -35,9 +35,9 @@ const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider mocks={Object.values(mocks)} addTypename={false}>
<RecoilRoot>
<MemoryRouter>
<SnackBarProviderScope snackBarManagerScopeId="test-scope-id">
<SnackBarComponentInstanceContextProvider snackBarComponentInstanceId="test-scope-id">
{children}
</SnackBarProviderScope>
</SnackBarComponentInstanceContextProvider>
</MemoryRouter>
</RecoilRoot>
</MockedProvider>
@@ -4,7 +4,7 @@ import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider';
import {
query,
@@ -27,9 +27,9 @@ const mocks = [
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider mocks={mocks} addTypename={false}>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<SnackBarComponentInstanceContextProvider snackBarComponentInstanceId="snack-bar-manager">
{children}
</SnackBarProviderScope>
</SnackBarComponentInstanceContextProvider>
</MockedProvider>
</RecoilRoot>
);
@@ -5,7 +5,7 @@ import { RecoilRoot, useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider';
import { MultipleRecordPickerRecords } from '@/object-record/record-picker/multiple-record-picker/types/MultipleRecordPickerRecords';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
@@ -55,9 +55,9 @@ const mocks = [
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider mocks={mocks} addTypename={false}>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<SnackBarComponentInstanceContextProvider snackBarComponentInstanceId="snack-bar-manager">
{children}
</SnackBarProviderScope>
</SnackBarComponentInstanceContextProvider>
</MockedProvider>
</RecoilRoot>
);
@@ -14,7 +14,7 @@ import { currentUserState } from '@/auth/states/currentUserState';
import { billingState } from '@/client-config/states/billingState';
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
import { useSettingsPermissionMap } from '@/settings/roles/hooks/useSettingsPermissionMap';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider';
const mockCurrentUser = {
id: 'fake-user-id',
@@ -45,9 +45,9 @@ const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider>
<RecoilRoot initializeState={initializeState}>
<MemoryRouter>
<SnackBarProviderScope snackBarManagerScopeId="test-scope-id">
<SnackBarComponentInstanceContextProvider snackBarComponentInstanceId="test-scope-id">
{children}
</SnackBarProviderScope>
</SnackBarComponentInstanceContextProvider>
</MemoryRouter>
</RecoilRoot>
</MockedProvider>
@@ -1,13 +1,14 @@
import styled from '@emotion/styled';
import { AnimatePresence, motion } from 'framer-motion';
import { useSnackBarManagerScopedStates } from '@/ui/feedback/snack-bar-manager/hooks/internal/useSnackBarManagerScopedStates';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { snackBarInternalComponentState } from '@/ui/feedback/snack-bar-manager/states/snackBarInternalComponentState';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { SnackBar } from './SnackBar';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { SnackBar } from './SnackBar';
const StyledSnackBarContainer = styled.div`
bottom: ${({ theme }) => theme.spacing(3)};
@@ -26,7 +27,10 @@ const StyledSnackBarContainer = styled.div`
`;
export const SnackBarProvider = ({ children }: React.PropsWithChildren) => {
const { snackBarInternal } = useSnackBarManagerScopedStates();
const snackBarInternal = useRecoilComponentValueV2(
snackBarInternalComponentState,
);
const { handleSnackBarClose } = useSnackBar();
const isMobile = useIsMobile();
@@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const SnackBarComponentInstanceContext =
createComponentInstanceContext();
@@ -1,45 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { useSnackBarManagerScopedStates } from '@/ui/feedback/snack-bar-manager/hooks/internal/useSnackBarManagerScopedStates';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { SnackBarState } from '@/ui/feedback/snack-bar-manager/states/snackBarInternalScopedState';
const snackBarManagerScopeId = 'snack-bar-manager';
const Wrapper = ({ children }: { children: React.ReactNode }) => {
return (
<RecoilRoot>
<SnackBarProviderScope snackBarManagerScopeId={snackBarManagerScopeId}>
{children}
</SnackBarProviderScope>
</RecoilRoot>
);
};
describe('useSnackBarManagerScopedStates', () => {
it('should return snackbar state and a function to update the state', async () => {
const { result } = renderHook(
() =>
useSnackBarManagerScopedStates({
snackBarManagerScopeId,
}),
{ wrapper: Wrapper },
);
const defaultState = { maxQueue: 3, queue: [] };
expect(result.current.snackBarInternal).toEqual(defaultState);
const newSnackBarState: SnackBarState = {
maxQueue: 5,
queue: [{ id: 'testid', role: 'alert', message: 'TEST MESSAGE' }],
};
act(() => {
result.current.setSnackBarInternal(newSnackBarState);
});
expect(result.current.snackBarInternal).toEqual(newSnackBarState);
});
});
@@ -1,24 +0,0 @@
import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext';
import { snackBarInternalScopedState } from '@/ui/feedback/snack-bar-manager/states/snackBarInternalScopedState';
import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
type useSnackBarManagerScopedStatesProps = {
snackBarManagerScopeId?: string;
};
export const useSnackBarManagerScopedStates = (
props?: useSnackBarManagerScopedStatesProps,
) => {
const scopeId = useAvailableScopeIdOrThrow(
SnackBarManagerScopeInternalContext,
props?.snackBarManagerScopeId,
);
const [snackBarInternal, setSnackBarInternal] = useRecoilScopedStateV2(
snackBarInternalScopedState,
scopeId,
);
return { snackBarInternal, setSnackBarInternal };
};
@@ -3,59 +3,69 @@ import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext';
import { SnackBarComponentInstanceContext } from '@/ui/feedback/snack-bar-manager/contexts/SnackBarComponentInstanceContext';
import {
snackBarInternalScopedState,
snackBarInternalComponentState,
SnackBarOptions,
} from '@/ui/feedback/snack-bar-manager/states/snackBarInternalScopedState';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
} from '@/ui/feedback/snack-bar-manager/states/snackBarInternalComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { getErrorMessageFromApolloError } from '~/utils/get-error-message-from-apollo-error.util';
export const useSnackBar = () => {
const scopeId = useAvailableScopeIdOrThrow(
SnackBarManagerScopeInternalContext,
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
SnackBarComponentInstanceContext,
);
const handleSnackBarClose = useRecoilCallback(
({ set }) =>
(id: string) => {
set(snackBarInternalScopedState({ scopeId }), (prevState) => ({
...prevState,
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
}));
set(
snackBarInternalComponentState.atomFamily({
instanceId: componentInstanceId,
}),
(prevState) => ({
...prevState,
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
}),
);
},
[scopeId],
[componentInstanceId],
);
const setSnackBarQueue = useRecoilCallback(
({ set }) =>
(newValue: SnackBarOptions) =>
set(snackBarInternalScopedState({ scopeId }), (prev) => {
if (
isDefined(newValue.dedupeKey) &&
prev.queue.some(
(snackBar) => snackBar.dedupeKey === newValue.dedupeKey,
)
) {
return prev;
}
set(
snackBarInternalComponentState.atomFamily({
instanceId: componentInstanceId,
}),
(prev) => {
if (
isDefined(newValue.dedupeKey) &&
prev.queue.some(
(snackBar) => snackBar.dedupeKey === newValue.dedupeKey,
)
) {
return prev;
}
if (prev.queue.length >= prev.maxQueue) {
return {
...prev,
queue: [...prev.queue.slice(1), newValue] as SnackBarOptions[],
};
}
if (prev.queue.length >= prev.maxQueue) {
return {
...prev,
queue: [...prev.queue.slice(1), newValue] as SnackBarOptions[],
queue: [...prev.queue, newValue] as SnackBarOptions[],
};
}
return {
...prev,
queue: [...prev.queue, newValue] as SnackBarOptions[],
};
}),
[scopeId],
},
),
[componentInstanceId],
);
const enqueueSuccessSnackBar = useCallback(
@@ -0,0 +1,21 @@
import { ReactNode } from 'react';
import { SnackBarComponentInstanceContext } from '@/ui/feedback/snack-bar-manager/contexts/SnackBarComponentInstanceContext';
type SnackBarComponentInstanceContextProviderProps = {
children: ReactNode;
snackBarComponentInstanceId: string;
};
export const SnackBarComponentInstanceContextProvider = ({
children,
snackBarComponentInstanceId,
}: SnackBarComponentInstanceContextProviderProps) => {
return (
<SnackBarComponentInstanceContext.Provider
value={{ instanceId: snackBarComponentInstanceId }}
>
{children}
</SnackBarComponentInstanceContext.Provider>
);
};
@@ -1,21 +0,0 @@
import { ReactNode } from 'react';
import { SnackBarManagerScopeInternalContext } from './scope-internal-context/SnackBarManagerScopeInternalContext';
type SnackBarProviderScopeProps = {
children: ReactNode;
snackBarManagerScopeId: string;
};
export const SnackBarProviderScope = ({
children,
snackBarManagerScopeId,
}: SnackBarProviderScopeProps) => {
return (
<SnackBarManagerScopeInternalContext.Provider
value={{ scopeId: snackBarManagerScopeId }}
>
{children}
</SnackBarManagerScopeInternalContext.Provider>
);
};
@@ -1,7 +0,0 @@
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
import { RecoilComponentStateKey } from '@/ui/utilities/state/component-state/types/RecoilComponentStateKey';
type SnackBarManagerScopeInternalContextProps = RecoilComponentStateKey;
export const SnackBarManagerScopeInternalContext =
createScopeInternalContext<SnackBarManagerScopeInternalContextProps>();
@@ -0,0 +1,22 @@
import { SnackBarComponentInstanceContext } from '@/ui/feedback/snack-bar-manager/contexts/SnackBarComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { SnackBarProps } from '../components/SnackBar';
export type SnackBarOptions = SnackBarProps & {
id: string;
};
export type SnackBarState = {
maxQueue: number;
queue: SnackBarOptions[];
};
export const snackBarInternalComponentState =
createComponentStateV2<SnackBarState>({
key: 'snackBarState',
defaultValue: {
maxQueue: 3,
queue: [],
},
componentInstanceContext: SnackBarComponentInstanceContext,
});
@@ -1,20 +0,0 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { SnackBarProps } from '../components/SnackBar';
export type SnackBarOptions = SnackBarProps & {
id: string;
};
export type SnackBarState = {
maxQueue: number;
queue: SnackBarOptions[];
};
export const snackBarInternalScopedState = createComponentState<SnackBarState>({
key: 'snackBarState',
defaultValue: {
maxQueue: 3,
queue: [],
},
});