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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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>

View File

@ -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();

View File

@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const SnackBarComponentInstanceContext =
createComponentInstanceContext();

View File

@ -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);
});
});

View File

@ -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 };
};

View File

@ -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(

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>();

View File

@ -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,
});

View File

@ -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: [],
},
});