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

View File

@ -13,7 +13,7 @@ import { RecoilRoot } from 'recoil';
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { ApolloCoreClientMockedProvider } from '@/object-metadata/hooks/__mocks__/ApolloCoreClientMockedProvider';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider';
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
import { UserProviderEffect } from '@/users/components/UserProviderEffect';
import { ClientConfigProvider } from '~/modules/client-config/components/ClientConfigProvider';
@ -77,7 +77,7 @@ await dynamicActivate(SOURCE_LOCALE);
const Providers = () => {
return (
<RecoilRoot>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<SnackBarComponentInstanceContextProvider snackBarComponentInstanceId="snack-bar-manager">
<RecoilDebugObserverEffect />
<ApolloProvider client={mockedApolloClient}>
<I18nProvider i18n={i18n}>
@ -125,7 +125,7 @@ const Providers = () => {
</ClientConfigProvider>
</I18nProvider>
</ApolloProvider>
</SnackBarProviderScope>
</SnackBarComponentInstanceContextProvider>
</RecoilRoot>
);
};

View File

@ -1,9 +1,9 @@
import { Decorator } from '@storybook/react';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider';
export const SnackBarDecorator: Decorator = (Story) => (
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<SnackBarComponentInstanceContextProvider snackBarComponentInstanceId="snack-bar-manager">
<Story />
</SnackBarProviderScope>
</SnackBarComponentInstanceContextProvider>
);

View File

@ -6,7 +6,7 @@ import { ContextStoreComponentInstanceContext } from '@/context-store/states/con
import { RecordFilterGroupsComponentInstanceContext } from '@/object-record/record-filter-group/states/context/RecordFilterGroupsComponentInstanceContext';
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { InMemoryCache } from '@apollo/client';
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
@ -25,7 +25,7 @@ export const getJestMetadataAndApolloMocksWrapper = ({
}) => {
return ({ children }: { children: ReactNode }) => (
<RecoilRoot initializeState={onInitializeRecoilSnapshot}>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<SnackBarComponentInstanceContextProvider snackBarComponentInstanceId="snack-bar-manager">
<MockedProvider mocks={apolloMocks} addTypename={false} cache={cache}>
<RecordFilterGroupsComponentInstanceContext.Provider
value={{ instanceId: 'instanceId' }}
@ -53,7 +53,7 @@ export const getJestMetadataAndApolloMocksWrapper = ({
</RecordFiltersComponentInstanceContext.Provider>
</RecordFilterGroupsComponentInstanceContext.Provider>
</MockedProvider>
</SnackBarProviderScope>
</SnackBarComponentInstanceContextProvider>
</RecoilRoot>
);
};

View File

@ -0,0 +1,40 @@
import { createApolloStoreFieldName } from '~/utils/createApolloStoreFieldName';
describe('createApolloStoreFieldName', () => {
it('should create field name with simple variables', () => {
const result = createApolloStoreFieldName({
fieldName: 'users',
fieldVariables: { limit: 10 },
});
expect(result).toBe('users({"limit":10})');
});
it('should create field name with complex variables', () => {
const result = createApolloStoreFieldName({
fieldName: 'companies',
fieldVariables: {
filter: { name: { ilike: '%test%' } },
orderBy: [{ createdAt: 'DESC' }],
first: 20,
},
});
expect(result).toBe(
'companies({"filter":{"name":{"ilike":"%test%"}},"orderBy":[{"createdAt":"DESC"}],"first":20})',
);
});
it('should create field name with null and undefined values', () => {
const result = createApolloStoreFieldName({
fieldName: 'records',
fieldVariables: {
id: null,
name: undefined,
active: true,
},
});
expect(result).toBe('records({"id":null,"active":true})');
});
});

View File

@ -0,0 +1,188 @@
import { sortByProperty } from '~/utils/array/sortByProperty';
interface User {
id: number;
name: string;
age: number;
score: number;
}
describe('sortByProperty', () => {
const users: User[] = [
{ id: 1, name: 'John', age: 30, score: 85.5 },
{ id: 2, name: 'Alice', age: 25, score: 92.0 },
{ id: 3, name: 'Bob', age: 35, score: 78.3 },
{ id: 4, name: 'Charlie', age: 28, score: 88.7 },
];
describe('string property sorting', () => {
it('should sort by string property ascending', () => {
const sorted = [...users].sort(sortByProperty('name', 'asc'));
expect(sorted.map((u) => u.name)).toEqual([
'Alice',
'Bob',
'Charlie',
'John',
]);
});
it('should sort by string property descending', () => {
const sorted = [...users].sort(sortByProperty('name', 'desc'));
expect(sorted.map((u) => u.name)).toEqual([
'John',
'Charlie',
'Bob',
'Alice',
]);
});
it('should default to ascending when no direction specified', () => {
const sorted = [...users].sort(sortByProperty('name'));
expect(sorted.map((u) => u.name)).toEqual([
'Alice',
'Bob',
'Charlie',
'John',
]);
});
});
describe('number property sorting', () => {
it('should sort by number property ascending', () => {
const sorted = [...users].sort(sortByProperty('age', 'asc'));
expect(sorted.map((u) => u.age)).toEqual([25, 28, 30, 35]);
});
it('should sort by number property descending', () => {
const sorted = [...users].sort(sortByProperty('age', 'desc'));
expect(sorted.map((u) => u.age)).toEqual([35, 30, 28, 25]);
});
it('should sort by decimal number property', () => {
const sorted = [...users].sort(sortByProperty('score', 'asc'));
expect(sorted.map((u) => u.score)).toEqual([78.3, 85.5, 88.7, 92.0]);
});
});
describe('edge cases', () => {
it('should handle empty array', () => {
const sorted: User[] = [].sort(sortByProperty('name', 'asc'));
expect(sorted).toEqual([]);
});
it('should handle single item array', () => {
const singleUser = [{ name: 'John', age: 30 }];
const sorted = [...singleUser].sort(sortByProperty('name', 'asc'));
expect(sorted).toEqual(singleUser);
});
it('should handle identical values', () => {
const identicalUsers = [
{ name: 'John', age: 30 },
{ name: 'John', age: 30 },
{ name: 'John', age: 30 },
];
const sorted = [...identicalUsers].sort(sortByProperty('age', 'asc'));
expect(sorted).toEqual(identicalUsers);
});
it('should handle mixed case strings', () => {
const mixedCaseUsers = [
{ name: 'john' },
{ name: 'ALICE' },
{ name: 'Bob' },
{ name: 'charlie' },
];
const sorted = [...mixedCaseUsers].sort(sortByProperty('name', 'asc'));
expect(sorted.map((u) => u.name)).toEqual([
'ALICE',
'Bob',
'charlie',
'john',
]);
});
it('should handle negative numbers', () => {
const numbersArray = [
{ value: -10 },
{ value: 5 },
{ value: -3 },
{ value: 0 },
{ value: 15 },
];
const sorted = [...numbersArray].sort(sortByProperty('value', 'asc'));
expect(sorted.map((n) => n.value)).toEqual([-10, -3, 0, 5, 15]);
});
});
describe('error handling', () => {
it('should throw error for unsupported property type', () => {
const objectsWithUnsupportedType = [
{ data: { nested: 'value' } },
{ data: { nested: 'other' } },
];
expect(() => {
[...objectsWithUnsupportedType].sort(sortByProperty('data', 'asc'));
}).toThrow(
'Property type not supported in sortByProperty, only string and number are supported',
);
});
it('should throw error for array property type', () => {
const objectsWithArrays = [{ tags: ['a', 'b'] }, { tags: ['c', 'd'] }];
expect(() => {
[...objectsWithArrays].sort(sortByProperty('tags', 'asc'));
}).toThrow(
'Property type not supported in sortByProperty, only string and number are supported',
);
});
it('should throw error for boolean property type', () => {
const objectsWithBoolean = [{ active: true }, { active: false }];
expect(() => {
[...objectsWithBoolean].sort(sortByProperty('active', 'asc'));
}).toThrow(
'Property type not supported in sortByProperty, only string and number are supported',
);
});
});
describe('type safety', () => {
it('should work with different object shapes', () => {
const products = [
{ name: 'Laptop', price: 999.99, category: 'Electronics' },
{ name: 'Book', price: 19.99, category: 'Education' },
{ name: 'Chair', price: 149.5, category: 'Furniture' },
];
const sortedByName = [...products].sort(sortByProperty('name', 'asc'));
expect(sortedByName.map((p) => p.name)).toEqual([
'Book',
'Chair',
'Laptop',
]);
const sortedByPrice = [...products].sort(sortByProperty('price', 'desc'));
expect(sortedByPrice.map((p) => p.price)).toEqual([999.99, 149.5, 19.99]);
});
});
});

View File

@ -0,0 +1,124 @@
import { getFileNameAndExtension } from '~/utils/file/getFileNameAndExtension';
describe('getFileNameAndExtension', () => {
it('should split filename with extension correctly', () => {
const result = getFileNameAndExtension('document.pdf');
expect(result).toEqual({
name: 'document',
extension: '.pdf',
});
});
it('should handle files with multiple dots', () => {
const result = getFileNameAndExtension('my.file.name.txt');
expect(result).toEqual({
name: 'my.file.name',
extension: '.txt',
});
});
it('should handle files with no extension', () => {
const result = getFileNameAndExtension('README');
expect(result).toEqual({
name: '',
extension: 'README',
});
});
it('should handle files starting with dot', () => {
const result = getFileNameAndExtension('.gitignore');
expect(result).toEqual({
name: '',
extension: '.gitignore',
});
});
it('should handle hidden files with extension', () => {
const result = getFileNameAndExtension('.env.local');
expect(result).toEqual({
name: '.env',
extension: '.local',
});
});
it('should handle empty string', () => {
const result = getFileNameAndExtension('');
expect(result).toEqual({
name: '',
extension: '',
});
});
it('should handle files with long extensions', () => {
const result = getFileNameAndExtension('archive.tar.gz');
expect(result).toEqual({
name: 'archive.tar',
extension: '.gz',
});
});
it('should handle files with special characters', () => {
const result = getFileNameAndExtension('file-name_with@special#chars.xlsx');
expect(result).toEqual({
name: 'file-name_with@special#chars',
extension: '.xlsx',
});
});
it('should handle files with spaces', () => {
const result = getFileNameAndExtension('my document file.docx');
expect(result).toEqual({
name: 'my document file',
extension: '.docx',
});
});
it('should handle files with path separators in name', () => {
const result = getFileNameAndExtension('folder/subfolder/file.txt');
expect(result).toEqual({
name: 'folder/subfolder/file',
extension: '.txt',
});
});
it('should handle files with only extension', () => {
const result = getFileNameAndExtension('.txt');
expect(result).toEqual({
name: '',
extension: '.txt',
});
});
it('should handle files with multiple consecutive dots', () => {
const result = getFileNameAndExtension('file...txt');
expect(result).toEqual({
name: 'file..',
extension: '.txt',
});
});
it('should handle very long filenames', () => {
const longName = 'a'.repeat(100);
const result = getFileNameAndExtension(`${longName}.pdf`);
expect(result).toEqual({
name: longName,
extension: '.pdf',
});
});
it('should handle files with numbers', () => {
const result = getFileNameAndExtension('report-2023-12-01.csv');
expect(result).toEqual({
name: 'report-2023-12-01',
extension: '.csv',
});
});
it('should handle uppercase extensions', () => {
const result = getFileNameAndExtension('DOCUMENT.PDF');
expect(result).toEqual({
name: 'DOCUMENT',
extension: '.PDF',
});
});
});

View File

@ -0,0 +1,80 @@
import { splitFullName } from '~/utils/format/spiltFullName';
describe('splitFullName', () => {
it('should split a full name with two parts', () => {
const result = splitFullName('John Doe');
expect(result).toEqual(['John', 'Doe']);
});
it('should handle names with extra whitespace', () => {
const result = splitFullName(' John Doe ');
expect(result).toEqual(['John', 'Doe']);
});
it('should handle single name', () => {
const result = splitFullName('John');
expect(result).toEqual(['John', '']);
});
it('should handle empty string', () => {
const result = splitFullName('');
expect(result).toEqual(['', '']);
});
it('should handle only whitespace', () => {
const result = splitFullName(' ');
expect(result).toEqual(['', '']);
});
it('should handle names with more than two parts', () => {
const result = splitFullName('John Michael Doe');
expect(result).toEqual(['John', 'Michael']);
});
it('should handle names with multiple middle names', () => {
const result = splitFullName('John Michael Christopher Doe');
expect(result).toEqual(['John', 'Michael']);
});
it('should handle names with hyphenated parts', () => {
const result = splitFullName('Mary-Jane Watson-Smith');
expect(result).toEqual(['Mary-Jane', 'Watson-Smith']);
});
it('should handle names with apostrophes', () => {
const result = splitFullName("John O'Connor");
expect(result).toEqual(['John', "O'Connor"]);
});
it('should handle names with special characters', () => {
const result = splitFullName('José García-López');
expect(result).toEqual(['José', 'García-López']);
});
it('should handle names with multiple consecutive spaces', () => {
const result = splitFullName('John Doe');
expect(result).toEqual(['John', 'Doe']);
});
it('should handle names with tabs and newlines', () => {
const result = splitFullName('John\t\nDoe');
expect(result).toEqual(['John', 'Doe']);
});
it('should handle very long names', () => {
const result = splitFullName(
'John Michael Christopher Alexander Doe Smith Johnson',
);
expect(result).toEqual(['John', 'Michael']);
});
it('should handle single character names', () => {
const result = splitFullName('J D');
expect(result).toEqual(['J', 'D']);
});
it('should handle names with numbers', () => {
const result = splitFullName('John Doe II');
expect(result).toEqual(['John', 'Doe']);
});
});