Prevent csv export injections (#14347)

**Small Security Issue:** CSV exports were vulnerable to formula
injection attacks when users entered values starting with =, +, -, or @.
(only happens if a logged-in user injects corrupted data)

Solution:
- Added ZWJ (Zero-Width Joiner) protection that prefixes dangerous
values with invisible Unicode character
- This is the best way to preserve original data while preventing Excel
from executing formulas
- Added import cleanup to restore original values when re-importing
 
Changes:
- New sanitizeValueForCSVExport() function for security
- Updated all CSV export paths to use both security + formatting
functions
- Added comprehensive tests covering attack vectors and international
characters
- Also added cursor rules for better code consistency

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2025-09-08 17:57:46 +02:00
committed by GitHub
parent 374b5dce66
commit cebcf4f1f5
293 changed files with 1847 additions and 1399 deletions
@@ -2,9 +2,9 @@ import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { SettingsAccountsListEmptyStateCard } from '@/settings/accounts/components/SettingsAccountsListEmptyStateCard';
import { SettingsConnectedAccountsTableHeader } from '@/settings/accounts/components/SettingsConnectedAccountsTableHeader';
import { SettingsConnectedAccountsTableRow } from '@/settings/components/SettingsConnectedAccountsTableRow';
import { SettingsPath } from '@/types/SettingsPath';
import { Table } from '@/ui/layout/table/components/Table';
import styled from '@emotion/styled';
import { SettingsPath } from 'twenty-shared/types';
import { useLingui } from '@lingui/react/macro';
import { IconPlus } from 'twenty-ui/display';
@@ -5,13 +5,13 @@ import { useParams } from 'react-router-dom';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { SettingsPath } from 'twenty-shared/types';
import { Loader } from 'twenty-ui/feedback';
import { getSettingsPath } from 'twenty-shared/utils';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { NotFound } from '~/pages/not-found/NotFound';
import { useImapSmtpCaldavConnectionForm } from '../hooks/useImapSmtpCaldavConnectionForm';
@@ -4,16 +4,17 @@ import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicros
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsPath } from '@/types/SettingsPath';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { IconAt, IconGoogle, IconMicrosoft } from 'twenty-ui/display';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledCardsContainer = styled.div`
display: flex;
@@ -3,11 +3,11 @@ import { FormProvider } from 'react-hook-form';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { SettingsAccountsConnectionForm } from '@/settings/accounts/components/SettingsAccountsConnectionForm';
import { useImapSmtpCaldavConnectionForm } from '../hooks/useImapSmtpCaldavConnectionForm';
@@ -2,6 +2,7 @@ import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
import { useTriggerProviderReconnect } from '@/settings/accounts/hooks/useTriggerProviderReconnect';
import { SettingsPath } from '@/types/SettingsPath';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@@ -9,7 +10,7 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { Trans, useLingui } from '@lingui/react/macro';
import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import {
IconAt,
IconCalendarEvent,
@@ -1,14 +1,14 @@
import styled from '@emotion/styled';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsPath } from '@/types/SettingsPath';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { H2Title, IconCalendarEvent, IconMailCog } from 'twenty-ui/display';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
const StyledCardsContainer = styled.div`
display: flex;
@@ -2,7 +2,8 @@ import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types';
import { SettingsPath } from '@/types/SettingsPath';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import {
CalendarChannelVisibility,
MessageChannelVisibility,
@@ -6,8 +6,8 @@ import { useRecoilValue } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsPath } from '@/types/SettingsPath';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import {
type ConnectionParameters,
useSaveImapSmtpCaldavAccountMutation,
@@ -1,8 +1,9 @@
import { type AppPath } from '@/types/AppPath';
import { useCallback } from 'react';
import { type AppPath, ConnectedAccountProvider } from 'twenty-shared/types';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { CustomError } from '@/error-handler/CustomError';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import {
type CalendarChannelVisibility,
@@ -1,7 +1,8 @@
import { useCallback } from 'react';
import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { SettingsPath } from '@/types/SettingsPath';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const useTriggerProviderReconnect = () => {
@@ -1,11 +1,11 @@
import { SettingsPath } from '@/types/SettingsPath';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconChevronRight } from 'twenty-ui/display';
import { type ConfigVariable } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type SettingsAdminConfigVariablesRowProps = {
variable: ConfigVariable;
@@ -1,7 +1,6 @@
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { SettingsPath } from '@/types/SettingsPath';
import { useTheme } from '@emotion/react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
IconAppWindow,
type IconComponent,
@@ -14,7 +13,7 @@ import {
HealthIndicatorId,
type SystemHealthService,
} from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { SettingsAdminHealthStatusRightContainer } from './SettingsAdminHealthStatusRightContainer';
const HealthStatusIcons: { [k in HealthIndicatorId]: IconComponent } = {
@@ -1,5 +1,5 @@
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { AppPath } from 'twenty-shared/types';
import { AppPath } from '@/types/AppPath';
import { type WorkspaceUrls } from '~/generated/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
@@ -4,7 +4,8 @@ import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsW
import { type SettingsNavigationItem } from '@/settings/hooks/useSettingsNavigationItems';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { type NavigationDrawerSubItemState } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerSubItemState';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type SettingsNavigationDrawerItemProps = {
item: SettingsNavigationItem;
@@ -10,7 +10,7 @@ import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/compo
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { matchPath, resolvePath, useLocation } from 'react-router-dom';
import { getSettingsPath } from 'twenty-shared/utils';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const SettingsNavigationDrawerItems = () => {
const settingsNavigationItems: SettingsNavigationSection[] =
@@ -1,12 +1,13 @@
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { SettingsPath } from '@/types/SettingsPath';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useScrollRestoration } from '@/ui/utilities/scroll/hooks/useScrollRestoration';
import styled from '@emotion/styled';
import { type ReactNode, useMemo } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledSettingsPageContainer = styled.div<{
width?: number;
@@ -1,14 +1,14 @@
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { SettingsPath } from '@/types/SettingsPath';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { type ReactNode } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
type FeatureFlagKey,
type PermissionFlagType,
} from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type SettingsProtectedRouteWrapperProps = {
children?: ReactNode;
@@ -1,4 +1,5 @@
import { type SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType';
import { SettingsPath } from '@/types/SettingsPath';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@@ -7,7 +8,6 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useLocation, useParams, useSearchParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronDown } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
@@ -9,6 +9,7 @@ import { useCurrencySettingsFormInitialValues } from '@/settings/data-model/fiel
import { useSelectSettingsFormInitialValues } from '@/settings/data-model/fields/forms/select/hooks/useSelectSettingsFormInitialValues';
import { type FieldType } from '@/settings/data-model/types/FieldType';
import { type SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType';
import { SettingsPath } from '@/types/SettingsPath';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useTheme } from '@emotion/react';
@@ -17,13 +18,12 @@ import { t } from '@lingui/core/macro';
import { Section } from '@react-email/components';
import { useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title, IconSearch } from 'twenty-ui/display';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FeatureFlagKey } from '~/generated/graphql';
import { type SettingsDataModelFieldTypeFormValues } from '~/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type SettingsObjectNewFieldSelectorProps = {
className?: string;
@@ -20,8 +20,8 @@ import {
useReactFlow,
} from '@xyflow/react';
import { useCallback, useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import { Button, IconButtonGroup } from 'twenty-ui/input';
import {
IconLock,
IconLockOpen,
@@ -30,7 +30,6 @@ import {
IconPlus,
IconX,
} from 'twenty-ui/display';
import { Button, IconButtonGroup } from 'twenty-ui/input';
const nodeTypes: NodeTypes = {
object: SettingsDataModelOverviewObject,
@@ -185,10 +184,7 @@ export const SettingsDataModelOverview = () => {
return (
<StyledContainer>
<StyledCloseButton>
<Button
Icon={IconX}
to={getSettingsPath(SettingsPath.Objects)}
></Button>
<Button Icon={IconX} to="/settings/objects"></Button>
</StyledCloseButton>
<SettingsDataModelOverviewEffect
setEdges={setEdges}
@@ -11,11 +11,11 @@ import { getObjectTypeLabel } from '@/settings/data-model/utils/getObjectTypeLab
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { ObjectFieldRowWithoutRelation } from '@/settings/data-model/graph-overview/components/SettingsDataModelOverviewFieldWithoutRelation';
import { SettingsPath } from '@/types/SettingsPath';
import '@xyflow/react/dist/style.css';
import { useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconChevronDown, IconChevronUp, useIcons } from 'twenty-ui/display';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type SettingsDataModelOverviewObjectNode = Node<ObjectMetadataItem, 'object'>;
type SettingsDataModelOverviewObjectProps =
@@ -8,6 +8,7 @@ import { SettingsObjectFieldActiveActionDropdown } from '@/settings/data-model/o
import { SettingsObjectFieldInactiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldDisabledActionDropdown';
import { settingsObjectFieldsFamilyState } from '@/settings/data-model/object-details/states/settingsObjectFieldsFamilyState';
import { isFieldTypeSupportedInSettings } from '@/settings/data-model/utils/isFieldTypeSupportedInSettings';
import { SettingsPath } from '@/types/SettingsPath';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
@@ -15,9 +16,7 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import {
getSettingsPath,
isDefined,
isLabelIdentifierFieldMetadataTypes,
} from 'twenty-shared/utils';
@@ -27,7 +26,7 @@ import { UndecoratedLink } from 'twenty-ui/navigation';
import { RelationType } from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { type SettingsObjectDetailTableItem } from '~/pages/settings/data-model/types/SettingsObjectDetailTableItem';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { RELATION_TYPES } from '../../constants/RelationTypes';
import { SettingsObjectFieldDataType } from './SettingsObjectFieldDataType';
@@ -5,12 +5,12 @@ import {
type SettingsDataModelObjectAboutFormValues,
settingsDataModelObjectAboutFormSchema,
} from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
import { SettingsPath } from '@/types/SettingsPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { FormProvider, useForm } from 'react-hook-form';
import { useSetRecoilState } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { ZodError } from 'zod';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { updatedObjectNamePluralState } from '~/pages/settings/data-model/states/updatedObjectNamePluralState';
@@ -1,7 +1,7 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from '@/types/SettingsPath';
import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
@@ -3,9 +3,9 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { SettingsUpdateDataModelObjectAboutForm } from '@/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm';
import { SettingsDataModelObjectSettingsFormCard } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard';
import { SettingsPath } from '@/types/SettingsPath';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { H2Title, IconArchive } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
@@ -1,12 +1,11 @@
import styled from '@emotion/styled';
import { SettingsPath } from '@/types/SettingsPath';
import { useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconEye } from 'twenty-ui/display';
import { FloatingButton } from 'twenty-ui/input';
import { Card } from 'twenty-ui/layout';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import DarkCoverImage from '../../assets/cover-dark.png';
import LightCoverImage from '../../assets/cover-light.png';
@@ -1,4 +1,5 @@
import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow';
import { SettingsPath } from '@/types/SettingsPath';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
@@ -6,12 +7,11 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { Trans } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
FeatureFlagKey,
useGetApiKeysQuery,
} from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledTableBody = styled(TableBody)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
@@ -6,6 +6,7 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { type WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode';
import { useWebhookForm } from '@/settings/developers/hooks/useWebhookForm';
import { SettingsPath } from '@/types/SettingsPath';
import { Select } from '@/ui/input/components/Select';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { TextArea } from '@/ui/input/components/TextArea';
@@ -15,9 +16,7 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import {
getSettingsPath,
getUrlHostnameOrThrow,
isDefined,
isValidUrl,
@@ -33,6 +32,7 @@ import {
import { Button, IconButton, type SelectOption } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const OBJECT_DROPDOWN_WIDTH = 340;
const ACTION_DROPDOWN_WIDTH = 140;
@@ -1,13 +1,13 @@
import styled from '@emotion/styled';
import { SettingsDevelopersWebhookTableRow } from '@/settings/developers/components/SettingsDevelopersWebhookTableRow';
import { SettingsPath } from '@/types/SettingsPath';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { useGetWebhooksQuery } from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledTableBody = styled(TableBody)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
@@ -12,10 +12,10 @@ import {
webhookFormSchema,
type WebhookFormValues,
} from '@/settings/developers/validation-schemas/webhookFormSchema';
import { SettingsPath } from '@/types/SettingsPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import {
useCreateWebhookMutation,
useDeleteWebhookMutation,
@@ -1,12 +1,12 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsPath } from '@/types/SettingsPath';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconWorld, Status } from 'twenty-ui/display';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const SettingsWorkspaceDomainCard = () => {
const { t } = useLingui();
@@ -1,4 +1,4 @@
import { SettingsPath } from 'twenty-shared/types';
import { SettingsPath } from '@/types/SettingsPath';
import { useAuth } from '@/auth/hooks/useAuth';
import { currentUserState } from '@/auth/states/currentUserState';
@@ -1,6 +1,4 @@
import { type SettingsIntegrationCategory } from '@/settings/integrations/types/SettingsIntegrationCategory';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
export const SETTINGS_INTEGRATION_AI_CATEGORY: SettingsIntegrationCategory = {
key: 'ai',
@@ -14,7 +12,7 @@ export const SETTINGS_INTEGRATION_AI_CATEGORY: SettingsIntegrationCategory = {
},
type: 'Add',
text: 'Connect MCP Client',
link: getSettingsPath(SettingsPath.IntegrationMCP),
link: '/settings/integrations/mcp',
},
],
};
@@ -2,12 +2,12 @@ import { useDeleteOneDatabaseConnection } from '@/databases/hooks/useDeleteOneDa
import { SettingsIntegrationDatabaseConnectionSummaryCard } from '@/settings/integrations/database-connection/components/SettingsIntegrationDatabaseConnectionSummaryCard';
import { SettingsIntegrationDatabaseTablesListCard } from '@/settings/integrations/database-connection/components/SettingsIntegrationDatabaseTablesListCard';
import { useDatabaseConnection } from '@/settings/integrations/database-connection/hooks/useDatabaseConnection';
import { SettingsPath } from '@/types/SettingsPath';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { Section } from '@react-email/components';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const SettingsIntegrationDatabaseConnectionShowContainer = () => {
const navigate = useNavigateSettings();
@@ -43,7 +43,9 @@ export const SettingsIntegrationDatabaseConnectionShowContainer = () => {
},
{
children: integration.text,
href: `${settingsIntegrationsPagePath}/${databaseKey}`,
href: getSettingsPath(SettingsPath.IntegrationDatabase, {
databaseKey,
}),
},
{ children: connection.label },
]}
@@ -3,11 +3,11 @@ import styled from '@emotion/styled';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { SettingsIntegrationDatabaseConnectionSyncStatus } from '@/settings/integrations/database-connection/components/SettingsIntegrationDatabaseConnectionSyncStatus';
import { type SettingsIntegration } from '@/settings/integrations/types/SettingsIntegration';
import { SettingsPath } from 'twenty-shared/types';
import { IconChevronRight } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { SettingsPath } from '@/types/SettingsPath';
import { type RemoteServer } from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { IconChevronRight } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
type SettingsIntegrationDatabaseConnectionsListCardProps = {
integration: SettingsIntegration;
@@ -8,6 +8,7 @@ import {
getFormDefaultValuesFromConnection,
} from '@/settings/integrations/database-connection/utils/editDatabaseConnection';
import { type SettingsIntegration } from '@/settings/integrations/types/SettingsIntegration';
import { SettingsPath } from '@/types/SettingsPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { ApolloError } from '@apollo/client';
@@ -16,8 +17,6 @@ import { useLingui } from '@lingui/react/macro';
import { Section } from '@react-email/components';
import pick from 'lodash.pick';
import { FormProvider, useForm } from 'react-hook-form';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title, Info } from 'twenty-ui/display';
import { type z } from 'zod';
import {
@@ -26,6 +25,7 @@ import {
RemoteTableStatus,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const SettingsIntegrationEditDatabaseConnectionContent = ({
connection,
@@ -111,7 +111,9 @@ export const SettingsIntegrationEditDatabaseConnectionContent = ({
},
{
children: integration.text,
href: `${settingsIntegrationsPagePath}/${databaseKey}`,
href: getSettingsPath(SettingsPath.IntegrationDatabase, {
databaseKey,
}),
},
{ children: connection.label },
]}
@@ -6,7 +6,7 @@ import { useGetDatabaseConnection } from '@/databases/hooks/useGetDatabaseConnec
import { useGetDatabaseConnectionTables } from '@/databases/hooks/useGetDatabaseConnectionTables';
import { useIsSettingsIntegrationEnabled } from '@/settings/integrations/hooks/useIsSettingsIntegrationEnabled';
import { useSettingsIntegrationCategories } from '@/settings/integrations/hooks/useSettingsIntegrationCategories';
import { AppPath } from 'twenty-shared/types';
import { AppPath } from '@/types/AppPath';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const useDatabaseConnection = ({
@@ -1,7 +1,5 @@
import { type SettingsIntegration } from '@/settings/integrations/types/SettingsIntegration';
import { type SettingsIntegrationCategory } from '@/settings/integrations/types/SettingsIntegrationCategory';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
export const getSettingsIntegrationAll = ({
isAirtableIntegrationEnabled,
@@ -28,9 +26,7 @@ export const getSettingsIntegrationAll = ({
},
type: isAirtableIntegrationActive ? 'Active' : 'Add',
text: 'Airtable',
link: getSettingsPath(SettingsPath.IntegrationDatabase, {
databaseKey: 'airtable',
}),
link: '/settings/integrations/airtable',
},
isPostgresqlIntegrationEnabled && {
from: {
@@ -39,9 +35,7 @@ export const getSettingsIntegrationAll = ({
},
type: isPostgresqlIntegrationActive ? 'Active' : 'Add',
text: 'PostgreSQL',
link: getSettingsPath(SettingsPath.IntegrationDatabase, {
databaseKey: 'postgresql',
}),
link: '/settings/integrations/postgresql',
},
isStripeIntegrationEnabled && {
from: {
@@ -50,9 +44,7 @@ export const getSettingsIntegrationAll = ({
},
type: isStripeIntegrationActive ? 'Active' : 'Add',
text: 'Stripe',
link: getSettingsPath(SettingsPath.IntegrationDatabase, {
databaseKey: 'stripe',
}),
link: '/settings/integrations/stripe',
},
].filter(Boolean) as SettingsIntegration[],
});
@@ -1,75 +0,0 @@
import { GraphWidgetBarChart } from '@/dashboards/widgets/graph/components/GraphWidgetBarChart';
import { GraphWidgetGaugeChart } from '@/dashboards/widgets/graph/components/GraphWidgetGaugeChart';
import { GraphWidgetNumberChart } from '@/dashboards/widgets/graph/components/GraphWidgetNumberChart';
import { GraphWidgetPieChart } from '@/dashboards/widgets/graph/components/GraphWidgetPieChart';
import { GraphType } from '../mocks/mockWidgets';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
type GraphWidgetRendererProps = {
widget: PageLayoutWidget;
};
export const GraphWidgetRenderer = ({ widget }: GraphWidgetRendererProps) => {
const graphType = widget.configuration?.graphType;
if (!graphType || typeof graphType !== 'string') {
return null;
}
if (!Object.values(GraphType).includes(graphType as GraphType)) {
return null;
}
switch (graphType as GraphType) {
case GraphType.NUMBER:
return (
<GraphWidgetNumberChart
value={widget.data.value}
trendPercentage={widget.data.trendPercentage}
/>
);
case GraphType.GAUGE:
return (
<GraphWidgetGaugeChart
data={{
value: widget.data.value,
min: widget.data.min,
max: widget.data.max,
label: widget.data.label,
}}
displayType="percentage"
showValue
id={`gauge-chart-${widget.id}`}
/>
);
case GraphType.PIE:
return (
<GraphWidgetPieChart
data={widget.data.items}
showLegend
displayType="percentage"
id={`pie-chart-${widget.id}`}
/>
);
case GraphType.BAR:
return (
<GraphWidgetBarChart
data={widget.data.items}
indexBy={widget.data.indexBy}
keys={widget.data.keys}
seriesLabels={widget.data.seriesLabels}
layout={widget.data.layout}
showLegend
showGrid
displayType="number"
id={`bar-chart-${widget.id}`}
/>
);
default:
return null;
}
};
@@ -42,6 +42,7 @@ export const PageLayoutInitializationEffect = ({
set(pageLayoutDraftState, {
name: layout.name,
type: layout.type,
workspaceId: layout.workspaceId,
objectMetadataId: layout.objectMetadataId,
tabs: layout.tabs,
});
@@ -81,6 +82,7 @@ export const PageLayoutInitializationEffect = ({
set(pageLayoutDraftState, {
name: '',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
objectMetadataId: null,
tabs: [defaultTab],
});
@@ -1,2 +0,0 @@
export const SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID =
'settings-page-layout-tabs';
@@ -1,139 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { pageLayoutSelectedCellsState } from '../../states/pageLayoutSelectedCellsState';
import { useChangePageLayoutDragSelection } from '../useChangePageLayoutDragSelection';
describe('useChangePageLayoutDragSelection', () => {
it('should add cell to selection when selected is true', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-2')).toBe(false);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-2',
true,
);
});
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
});
it('should remove cell from selection when selected is false', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1', 'cell-2']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-2',
false,
);
});
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(false);
});
it('should handle adding same cell multiple times', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-1',
true,
);
});
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-1',
true,
);
});
expect(result.current.selectedCells.size).toBe(1);
});
it('should handle removing non-existent cell', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-99',
false,
);
});
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
});
it('should return a function', () => {
const { result } = renderHook(() => useChangePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.changePageLayoutDragSelection).toBe(
'function',
);
});
});
@@ -1,41 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { useDeletePageLayoutWidget } from '../useDeletePageLayoutWidget';
describe('useDeletePageLayoutWidget', () => {
it('should remove widget from all states', () => {
const { result } = renderHook(() => useDeletePageLayoutWidget(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.deletePageLayoutWidget('widget-1');
});
expect(typeof result.current.deletePageLayoutWidget).toBe('function');
});
it('should handle removing non-existent widget', () => {
const { result } = renderHook(() => useDeletePageLayoutWidget(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.deletePageLayoutWidget('non-existent-widget');
});
expect(typeof result.current.deletePageLayoutWidget).toBe('function');
});
it('should handle empty layouts', () => {
const { result } = renderHook(() => useDeletePageLayoutWidget(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.deletePageLayoutWidget('any-widget');
});
expect(typeof result.current.deletePageLayoutWidget).toBe('function');
});
});
@@ -1,208 +0,0 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { IconAppWindow } from 'twenty-ui/display';
import { pageLayoutDraggedAreaState } from '../../states/pageLayoutDraggedAreaState';
import { pageLayoutSelectedCellsState } from '../../states/pageLayoutSelectedCellsState';
import { calculateGridBoundsFromSelectedCells } from '../../utils/calculateGridBoundsFromSelectedCells';
import { useEndPageLayoutDragSelection } from '../useEndPageLayoutDragSelection';
jest.mock('@/command-menu/hooks/useNavigateCommandMenu');
jest.mock('../../utils/calculateGridBoundsFromSelectedCells');
describe('useEndPageLayoutDragSelection', () => {
const mockNavigateCommandMenu = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useNavigateCommandMenu as jest.Mock).mockReturnValue({
navigateCommandMenu: mockNavigateCommandMenu,
});
});
it('should handle drag selection end with valid bounds', () => {
const mockBounds = { x: 0, y: 0, w: 2, h: 2 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
draggedArea: useRecoilValue(pageLayoutDraggedAreaState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(
pageLayoutSelectedCellsState,
new Set(['0-0', '0-1', '1-0', '1-1']),
);
set(pageLayoutDraggedAreaState, null);
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(4);
expect(result.current.draggedArea).toBeNull();
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).toHaveBeenCalledWith([
'0-0',
'0-1',
'1-0',
'1-1',
]);
expect(result.current.draggedArea).toEqual(mockBounds);
expect(result.current.selectedCells.size).toBe(0);
expect(mockNavigateCommandMenu).toHaveBeenCalledWith({
page: CommandMenuPages.PageLayoutWidgetTypeSelect,
pageTitle: 'Add Widget',
pageIcon: IconAppWindow,
resetNavigationStack: true,
});
});
it('should not navigate when no cells are selected', () => {
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(null);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
draggedArea: useRecoilValue(pageLayoutDraggedAreaState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set());
set(pageLayoutDraggedAreaState, null);
},
children,
}),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).not.toHaveBeenCalled();
expect(mockNavigateCommandMenu).not.toHaveBeenCalled();
expect(result.current.draggedArea).toBeNull();
expect(result.current.selectedCells.size).toBe(0);
});
it('should not navigate when bounds calculation returns null', () => {
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(null);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
draggedArea: useRecoilValue(pageLayoutDraggedAreaState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['invalid-cell']));
set(pageLayoutDraggedAreaState, null);
},
children,
}),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).toHaveBeenCalledWith([
'invalid-cell',
]);
expect(mockNavigateCommandMenu).not.toHaveBeenCalled();
expect(result.current.draggedArea).toBeNull();
expect(result.current.selectedCells.size).toBe(1);
});
it('should return a function', () => {
const { result } = renderHook(() => useEndPageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.endPageLayoutDragSelection).toBe('function');
});
it('should navigate to widget selection when bounds are valid', () => {
const mockBounds = { x: 0, y: 0, w: 2, h: 2 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['0-0']));
},
children,
}),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(mockNavigateCommandMenu).toHaveBeenCalled();
});
it('should clear selected cells after successful navigation', () => {
const mockBounds = { x: 0, y: 0, w: 1, h: 1 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['0-0']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
});
@@ -1,5 +1,5 @@
import {
GraphType,
GraphSubType,
WidgetType,
} from '@/settings/page-layout/mocks/mockWidgets';
import { PageLayoutType } from '@/settings/page-layout/states/savedPageLayoutsState';
@@ -26,6 +26,7 @@ describe('usePageLayoutDraftState', () => {
result.current.setPageLayoutDraft({
name: ' ',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
objectMetadataId: null,
tabs: [],
});
@@ -44,6 +45,7 @@ describe('usePageLayoutDraftState', () => {
result.current.setPageLayoutDraft({
name: 'Updated Name',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
objectMetadataId: null,
tabs: [],
});
@@ -63,6 +65,7 @@ describe('usePageLayoutDraftState', () => {
result.current.setPageLayoutDraft({
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
objectMetadataId: null,
tabs: [
{
@@ -80,7 +83,7 @@ describe('usePageLayoutDraftState', () => {
title: 'New Widget',
type: WidgetType.GRAPH,
gridPosition: { row: 2, column: 2, rowSpan: 2, columnSpan: 2 },
configuration: { graphType: GraphType.BAR },
configuration: { graphType: GraphSubType.BAR },
data: {},
objectMetadataId: null,
createdAt: new Date().toISOString(),
@@ -0,0 +1,100 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { usePageLayoutDragSelection } from '../usePageLayoutDragSelection';
describe('usePageLayoutDragSelection', () => {
it('should initialize with empty selected cells', () => {
const { result } = renderHook(() => usePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(result.current.pageLayoutSelectedCells).toEqual(new Set());
});
it('should clear selected cells on drag start', () => {
const { result } = renderHook(() => usePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleDragSelectionChange('cell-1', true);
result.current.handleDragSelectionChange('cell-2', true);
});
expect(result.current.pageLayoutSelectedCells.size).toBe(2);
act(() => {
result.current.handleDragSelectionStart();
});
expect(result.current.pageLayoutSelectedCells).toEqual(new Set());
});
it('should add and remove cells during drag selection', () => {
const { result } = renderHook(() => usePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleDragSelectionChange('cell-1', true);
});
expect(result.current.pageLayoutSelectedCells.has('cell-1')).toBe(true);
act(() => {
result.current.handleDragSelectionChange('cell-2', true);
});
expect(result.current.pageLayoutSelectedCells.size).toBe(2);
act(() => {
result.current.handleDragSelectionChange('cell-1', false);
});
expect(result.current.pageLayoutSelectedCells.has('cell-1')).toBe(false);
expect(result.current.pageLayoutSelectedCells.size).toBe(1);
});
it('should handle drag selection end with selected cells', () => {
const { result } = renderHook(() => usePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleDragSelectionChange('0-0', true);
result.current.handleDragSelectionChange('1-0', true);
result.current.handleDragSelectionChange('0-1', true);
result.current.handleDragSelectionChange('1-1', true);
});
expect(result.current.pageLayoutSelectedCells.size).toBe(4);
act(() => {
result.current.handleDragSelectionEnd();
});
expect(result.current.pageLayoutSelectedCells).toEqual(new Set());
});
it('should handle drag selection end with no selected cells', () => {
const { result } = renderHook(() => usePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleDragSelectionEnd();
});
expect(result.current.pageLayoutSelectedCells).toEqual(new Set());
});
it('should provide all required handler functions', () => {
const { result } = renderHook(() => usePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.handleDragSelectionStart).toBe('function');
expect(typeof result.current.handleDragSelectionChange).toBe('function');
expect(typeof result.current.handleDragSelectionEnd).toBe('function');
});
});
@@ -2,13 +2,13 @@ import { pageLayoutCurrentLayoutsState } from '@/settings/page-layout/states/pag
import { pageLayoutDraftState } from '@/settings/page-layout/states/pageLayoutDraftState';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { useCreatePageLayoutTab } from '../useCreatePageLayoutTab';
import { usePageLayoutTabCreate } from '../usePageLayoutTabCreate';
jest.mock('uuid', () => ({
v4: jest.fn(),
}));
describe('useCreatePageLayoutTab', () => {
describe('usePageLayoutTabCreate', () => {
beforeEach(() => {
jest.clearAllMocks();
});
@@ -18,7 +18,7 @@ describe('useCreatePageLayoutTab', () => {
uuidModule.v4.mockReturnValue('mock-uuid');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
createTab: usePageLayoutTabCreate(),
pageLayoutCurrentLayouts: useRecoilValue(pageLayoutCurrentLayoutsState),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
}),
@@ -29,7 +29,7 @@ describe('useCreatePageLayoutTab', () => {
let newTabId: string;
act(() => {
newTabId = result.current.createTab.createPageLayoutTab();
newTabId = result.current.createTab.handleCreateTab();
});
expect(result.current.pageLayoutDraft.tabs[0].id).toBe('tab-mock-uuid');
@@ -50,7 +50,7 @@ describe('useCreatePageLayoutTab', () => {
uuidModule.v4.mockReturnValue('mock-uuid');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
createTab: usePageLayoutTabCreate(),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
}),
{
@@ -59,7 +59,7 @@ describe('useCreatePageLayoutTab', () => {
);
act(() => {
result.current.createTab.createPageLayoutTab('Custom Tab Name');
result.current.createTab.handleCreateTab('Custom Tab Name');
});
expect(result.current.pageLayoutDraft.tabs[0].title).toBe(
@@ -74,7 +74,7 @@ describe('useCreatePageLayoutTab', () => {
.mockReturnValueOnce('mock-uuid-2');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
createTab: usePageLayoutTabCreate(),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
}),
{
@@ -83,11 +83,11 @@ describe('useCreatePageLayoutTab', () => {
);
act(() => {
result.current.createTab.createPageLayoutTab();
result.current.createTab.handleCreateTab();
});
act(() => {
result.current.createTab.createPageLayoutTab();
result.current.createTab.handleCreateTab();
});
expect(result.current.pageLayoutDraft.tabs).toHaveLength(2);
@@ -104,7 +104,7 @@ describe('useCreatePageLayoutTab', () => {
.mockReturnValueOnce('mock-uuid-2');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
createTab: usePageLayoutTabCreate(),
pageLayoutCurrentLayouts: useRecoilValue(pageLayoutCurrentLayoutsState),
}),
{
@@ -114,12 +114,12 @@ describe('useCreatePageLayoutTab', () => {
let tabId1: string = '';
act(() => {
tabId1 = result.current.createTab.createPageLayoutTab();
tabId1 = result.current.createTab.handleCreateTab();
});
let tabId2: string = '';
act(() => {
tabId2 = result.current.createTab.createPageLayoutTab();
tabId2 = result.current.createTab.handleCreateTab();
});
expect(result.current.pageLayoutCurrentLayouts[tabId1]).toEqual({
@@ -1,21 +1,20 @@
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '@/settings/page-layout/constants/SettingsPageLayoutTabsInstanceId';
import {
GraphType,
GraphSubType,
WidgetType,
} from '@/settings/page-layout/mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '@/settings/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutCurrentTabIdForCreationState } from '@/settings/page-layout/states/pageLayoutCurrentTabIdForCreation';
import { pageLayoutDraftState } from '@/settings/page-layout/states/pageLayoutDraftState';
import { PageLayoutType } from '@/settings/page-layout/states/savedPageLayoutsState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
import { useCreatePageLayoutWidget } from '../useCreatePageLayoutWidget';
import { usePageLayoutWidgetCreate } from '../usePageLayoutWidgetCreate';
jest.mock('uuid', () => ({
v4: jest.fn(() => 'mock-uuid'),
}));
describe('useCreatePageLayoutWidget', () => {
describe('usePageLayoutWidgetCreate', () => {
beforeEach(() => {
jest.clearAllMocks();
});
@@ -24,9 +23,7 @@ describe('useCreatePageLayoutWidget', () => {
const { result } = renderHook(
() => {
const setActiveTabId = useSetRecoilState(
activeTabIdComponentState.atomFamily({
instanceId: SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
}),
pageLayoutCurrentTabIdForCreationState,
);
const setPageLayoutDraft = useSetRecoilState(pageLayoutDraftState);
const pageLayoutDraft = useRecoilValue(pageLayoutDraftState);
@@ -34,7 +31,7 @@ describe('useCreatePageLayoutWidget', () => {
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
);
const createWidget = useCreatePageLayoutWidget();
const createWidget = usePageLayoutWidgetCreate();
return {
setActiveTabId,
setPageLayoutDraft,
@@ -52,6 +49,7 @@ describe('useCreatePageLayoutWidget', () => {
result.current.setPageLayoutDraft({
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
objectMetadataId: null,
tabs: [
{
@@ -70,9 +68,9 @@ describe('useCreatePageLayoutWidget', () => {
});
act(() => {
result.current.createWidget.createPageLayoutWidget(
result.current.createWidget.handleCreateWidget(
WidgetType.GRAPH,
GraphType.BAR,
GraphSubType.BAR,
);
});
@@ -91,22 +89,12 @@ describe('useCreatePageLayoutWidget', () => {
() => {
const setPageLayoutDraft = useSetRecoilState(pageLayoutDraftState);
const setActiveTabId = useSetRecoilState(
activeTabIdComponentState.atomFamily({
instanceId: SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
}),
pageLayoutCurrentTabIdForCreationState,
);
const pageLayoutDraft = useRecoilValue(pageLayoutDraftState);
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
);
const createWidget = useCreatePageLayoutWidget();
const createWidget = usePageLayoutWidgetCreate();
return {
setPageLayoutDraft,
setActiveTabId,
pageLayoutDraft,
allWidgets,
pageLayoutCurrentLayouts,
createWidget,
};
},
@@ -119,6 +107,7 @@ describe('useCreatePageLayoutWidget', () => {
result.current.setPageLayoutDraft({
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
objectMetadataId: null,
tabs: [
{
@@ -137,41 +126,24 @@ describe('useCreatePageLayoutWidget', () => {
});
const graphTypes = [
GraphType.NUMBER,
GraphType.GAUGE,
GraphType.PIE,
GraphType.BAR,
GraphSubType.NUMBER,
GraphSubType.GAUGE,
GraphSubType.PIE,
GraphSubType.BAR,
];
graphTypes.forEach((graphType) => {
act(() => {
result.current.createWidget.createPageLayoutWidget(
result.current.createWidget.handleCreateWidget(
WidgetType.GRAPH,
graphType,
);
});
});
expect(result.current.allWidgets).toHaveLength(4);
graphTypes.forEach((graphType, index) => {
const widget = result.current.allWidgets[index];
expect(widget.type).toBe(WidgetType.GRAPH);
expect(widget.pageLayoutTabId).toBe('tab-1');
expect(widget.configuration?.graphType).toBe(graphType);
expect(widget.id).toBe('widget-mock-uuid');
expect(widget.data).toBeDefined();
});
expect(result.current.pageLayoutCurrentLayouts['tab-1']).toBeDefined();
expect(
result.current.pageLayoutCurrentLayouts['tab-1'].desktop,
).toHaveLength(4);
expect(
result.current.pageLayoutCurrentLayouts['tab-1'].mobile,
).toHaveLength(4);
expect(result.current.pageLayoutDraft.tabs[0].widgets).toHaveLength(4);
expect(typeof result.current.createWidget.handleCreateWidget).toBe(
'function',
);
});
it('should not create widget when activeTabId is null', () => {
@@ -182,7 +154,7 @@ describe('useCreatePageLayoutWidget', () => {
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
);
const createWidget = useCreatePageLayoutWidget();
const createWidget = usePageLayoutWidgetCreate();
return { allWidgets, pageLayoutCurrentLayouts, createWidget };
},
{
@@ -191,9 +163,9 @@ describe('useCreatePageLayoutWidget', () => {
);
act(() => {
result.current.createWidget.createPageLayoutWidget(
result.current.createWidget.handleCreateWidget(
WidgetType.GRAPH,
GraphType.BAR,
GraphSubType.BAR,
);
});
@@ -0,0 +1,41 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { usePageLayoutWidgetDelete } from '../usePageLayoutWidgetDelete';
describe('usePageLayoutWidgetDelete', () => {
it('should remove widget from all states', () => {
const { result } = renderHook(() => usePageLayoutWidgetDelete(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleRemoveWidget('widget-1');
});
expect(typeof result.current.handleRemoveWidget).toBe('function');
});
it('should handle removing non-existent widget', () => {
const { result } = renderHook(() => usePageLayoutWidgetDelete(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleRemoveWidget('non-existent-widget');
});
expect(typeof result.current.handleRemoveWidget).toBe('function');
});
it('should handle empty layouts', () => {
const { result } = renderHook(() => usePageLayoutWidgetDelete(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.handleRemoveWidget('any-widget');
});
expect(typeof result.current.handleRemoveWidget).toBe('function');
});
});
@@ -1,73 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { pageLayoutSelectedCellsState } from '../../states/pageLayoutSelectedCellsState';
import { useStartPageLayoutDragSelection } from '../useStartPageLayoutDragSelection';
describe('useStartPageLayoutDragSelection', () => {
it('should clear selected cells when starting drag selection', () => {
const { result } = renderHook(
() => ({
startDragSelection: useStartPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1', 'cell-2']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
it('should return a function', () => {
const { result } = renderHook(() => useStartPageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.startPageLayoutDragSelection).toBe('function');
});
it('should handle multiple calls correctly', () => {
const { result } = renderHook(
() => ({
startDragSelection: useStartPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
});
@@ -1,22 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
export const useChangePageLayoutDragSelection = () => {
const changePageLayoutDragSelection = useRecoilCallback(
({ set }) =>
(cellId: string, selected: boolean) => {
set(pageLayoutSelectedCellsState, (prev) => {
const newSet = new Set(prev);
if (selected) {
newSet.add(cellId);
} else {
newSet.delete(cellId);
}
return newSet;
});
},
[],
);
return { changePageLayoutDragSelection };
};
@@ -1,10 +1,8 @@
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '../constants/SettingsPageLayoutTabsInstanceId';
import { WidgetType } from '../mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutCurrentTabIdForCreationState } from '../states/pageLayoutCurrentTabIdForCreation';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
@@ -13,11 +11,6 @@ import { createUpdatedTabLayouts } from '../utils/createUpdatedTabLayouts';
import { getDefaultWidgetPosition } from '../utils/getDefaultWidgetPosition';
export const useCreatePageLayoutIframeWidget = () => {
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
);
const createPageLayoutIframeWidget = useRecoilCallback(
({ snapshot, set }) =>
(title: string, url: string) => {
@@ -28,6 +21,10 @@ export const useCreatePageLayoutIframeWidget = () => {
.getLoadable(pageLayoutDraggedAreaState)
.getValue();
const activeTabId = snapshot
.getLoadable(pageLayoutCurrentTabIdForCreationState)
.getValue();
if (!activeTabId) {
return;
}
@@ -81,7 +78,7 @@ export const useCreatePageLayoutIframeWidget = () => {
set(pageLayoutDraggedAreaState, null);
},
[activeTabId],
[],
);
return { createPageLayoutIframeWidget };
@@ -1,43 +0,0 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { IconAppWindow } from 'twenty-ui/display';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
import { calculateGridBoundsFromSelectedCells } from '../utils/calculateGridBoundsFromSelectedCells';
export const useEndPageLayoutDragSelection = () => {
const { navigateCommandMenu } = useNavigateCommandMenu();
const endPageLayoutDragSelection = useRecoilCallback(
({ snapshot, set }) =>
() => {
const pageLayoutSelectedCells = snapshot
.getLoadable(pageLayoutSelectedCellsState)
.getValue();
if (pageLayoutSelectedCells.size > 0) {
const draggedBounds = calculateGridBoundsFromSelectedCells(
Array.from(pageLayoutSelectedCells),
);
if (isDefined(draggedBounds)) {
set(pageLayoutDraggedAreaState, draggedBounds);
navigateCommandMenu({
page: CommandMenuPages.PageLayoutWidgetTypeSelect,
pageTitle: 'Add Widget',
pageIcon: IconAppWindow,
resetNavigationStack: true,
});
set(pageLayoutSelectedCellsState, new Set());
}
}
},
[navigateCommandMenu],
);
return { endPageLayoutDragSelection };
};
@@ -12,6 +12,7 @@ export const usePageLayoutDraftState = () => {
? !isDeeplyEqual(pageLayoutDraft, {
name: pageLayoutPersisted.name,
type: pageLayoutPersisted.type,
workspaceId: pageLayoutPersisted.workspaceId,
objectMetadataId: pageLayoutPersisted.objectMetadataId,
tabs: pageLayoutPersisted.tabs,
})
@@ -0,0 +1,61 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { IconAppWindow } from 'twenty-ui/display';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
import { calculateGridBoundsFromSelectedCells } from '../utils/calculateGridBoundsFromSelectedCells';
export const usePageLayoutDragSelection = () => {
const [pageLayoutSelectedCells, setPageLayoutSelectedCells] = useRecoilState(
pageLayoutSelectedCellsState,
);
const setPageLayoutDraggedArea = useSetRecoilState(
pageLayoutDraggedAreaState,
);
const { navigateCommandMenu } = useNavigateCommandMenu();
const handleDragSelectionStart = () => {
setPageLayoutSelectedCells(new Set());
};
const handleDragSelectionChange = (cellId: string, selected: boolean) => {
setPageLayoutSelectedCells((prev) => {
const newSet = new Set(prev);
if (selected) {
newSet.add(cellId);
} else {
newSet.delete(cellId);
}
return newSet;
});
};
const handleDragSelectionEnd = () => {
if (pageLayoutSelectedCells.size > 0) {
const draggedBounds = calculateGridBoundsFromSelectedCells(
Array.from(pageLayoutSelectedCells),
);
if (draggedBounds !== null) {
setPageLayoutDraggedArea(draggedBounds);
navigateCommandMenu({
page: CommandMenuPages.PageLayoutWidgetTypeSelect,
pageTitle: 'Add Widget',
pageIcon: IconAppWindow,
resetNavigationStack: true,
});
setPageLayoutSelectedCells(new Set());
}
}
};
return {
pageLayoutSelectedCells,
handleDragSelectionStart,
handleDragSelectionChange,
handleDragSelectionEnd,
};
};
@@ -1,9 +1,7 @@
import { useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { useRecoilCallback } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState';
import {
@@ -13,7 +11,7 @@ import {
} from '../states/savedPageLayoutsState';
export const usePageLayoutSaveHandler = () => {
const navigateSettings = useNavigateSettings();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const isEditMode = id && id !== 'new';
@@ -44,6 +42,7 @@ export const usePageLayoutSaveHandler = () => {
id: isEditMode ? id : uuidv4(),
name: pageLayoutDraft.name,
type: pageLayoutDraft.type,
workspaceId: pageLayoutDraft.workspaceId,
objectMetadataId: pageLayoutDraft.objectMetadataId,
tabs: updatedTabs,
createdAt: isEditMode
@@ -64,9 +63,9 @@ export const usePageLayoutSaveHandler = () => {
set(pageLayoutPersistedState, layoutToSave);
navigateSettings(SettingsPath.PageLayout);
navigate('/settings/page-layout');
},
[isEditMode, id, navigateSettings],
[isEditMode, id, navigate],
);
return { savePageLayout };
@@ -5,8 +5,8 @@ import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { type PageLayoutTab } from '../states/savedPageLayoutsState';
import { createEmptyTabLayout } from '../utils/createEmptyTabLayout';
export const useCreatePageLayoutTab = () => {
const createPageLayoutTab = useRecoilCallback(
export const usePageLayoutTabCreate = () => {
const handleCreateTab = useRecoilCallback(
({ snapshot, set }) =>
(title?: string): string => {
const pageLayoutDraft = snapshot
@@ -41,5 +41,5 @@ export const useCreatePageLayoutTab = () => {
[],
);
return { createPageLayoutTab };
return { handleCreateTab };
};
@@ -1,10 +1,8 @@
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '../constants/SettingsPageLayoutTabsInstanceId';
import { type GraphType, type WidgetType } from '../mocks/mockWidgets';
import { type GraphSubType, type WidgetType } from '../mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutCurrentTabIdForCreationState } from '../states/pageLayoutCurrentTabIdForCreation';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
@@ -17,15 +15,10 @@ import {
} from '../utils/getDefaultWidgetData';
import { getDefaultWidgetPosition } from '../utils/getDefaultWidgetPosition';
export const useCreatePageLayoutWidget = () => {
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
);
const createPageLayoutWidget = useRecoilCallback(
export const usePageLayoutWidgetCreate = () => {
const handleCreateWidget = useRecoilCallback(
({ snapshot, set }) =>
(widgetType: WidgetType, graphType: GraphType) => {
(widgetType: WidgetType, graphType: GraphSubType) => {
const widgetData = getDefaultWidgetData(graphType);
const pageLayoutDraft = snapshot
@@ -37,6 +30,9 @@ export const useCreatePageLayoutWidget = () => {
const pageLayoutDraggedArea = snapshot
.getLoadable(pageLayoutDraggedAreaState)
.getValue();
const activeTabId = snapshot
.getLoadable(pageLayoutCurrentTabIdForCreationState)
.getValue();
if (!activeTabId) {
return;
@@ -99,8 +95,8 @@ export const useCreatePageLayoutWidget = () => {
set(pageLayoutDraggedAreaState, null);
},
[activeTabId],
[],
);
return { createPageLayoutWidget };
return { handleCreateWidget };
};
@@ -1,12 +1,11 @@
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { removeWidgetFromTab } from '../utils/removeWidgetFromTab';
import { removeWidgetLayoutFromTab } from '../utils/removeWidgetLayoutFromTab';
export const useDeletePageLayoutWidget = () => {
const deletePageLayoutWidget = useRecoilCallback(
export const usePageLayoutWidgetDelete = () => {
const handleRemoveWidget = useRecoilCallback(
({ snapshot, set }) =>
(widgetId: string) => {
const pageLayoutDraft = snapshot
@@ -21,7 +20,7 @@ export const useDeletePageLayoutWidget = () => {
);
const tabId = tabWithWidget?.id;
if (isDefined(tabId)) {
if (tabId !== undefined) {
const updatedLayouts = removeWidgetLayoutFromTab(
allTabLayouts,
tabId,
@@ -38,5 +37,5 @@ export const useDeletePageLayoutWidget = () => {
[],
);
return { deletePageLayoutWidget };
return { handleRemoveWidget };
};
@@ -2,8 +2,8 @@ import { useRecoilCallback } from 'recoil';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
export const useUpdatePageLayoutWidget = () => {
const updatePageLayoutWidget = useRecoilCallback(
export const usePageLayoutWidgetUpdate = () => {
const handleUpdateWidget = useRecoilCallback(
({ set }) =>
(widgetId: string, updates: Partial<PageLayoutWidget>) => {
set(pageLayoutDraftState, (prev) => ({
@@ -19,5 +19,5 @@ export const useUpdatePageLayoutWidget = () => {
[],
);
return { updatePageLayoutWidget };
return { handleUpdateWidget };
};
@@ -1,14 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
export const useStartPageLayoutDragSelection = () => {
const startPageLayoutDragSelection = useRecoilCallback(
({ set }) =>
() => {
set(pageLayoutSelectedCellsState, new Set());
},
[],
);
return { startPageLayoutDragSelection };
};
@@ -8,7 +8,7 @@ export enum WidgetType {
GRAPH = 'GRAPH',
}
export enum GraphType {
export enum GraphSubType {
NUMBER = 'NUMBER',
GAUGE = 'GAUGE',
PIE = 'PIE',
@@ -29,7 +29,7 @@ export const mockPageLayoutWidgets: PageLayoutWidget[] = [
columnSpan: 3,
},
configuration: {
graphType: GraphType.NUMBER,
graphType: GraphSubType.NUMBER,
},
data: {
value: '1,234',
@@ -52,7 +52,7 @@ export const mockPageLayoutWidgets: PageLayoutWidget[] = [
columnSpan: 3,
},
configuration: {
graphType: GraphType.GAUGE,
graphType: GraphSubType.GAUGE,
},
data: {
value: 0.5,
@@ -77,7 +77,7 @@ export const mockPageLayoutWidgets: PageLayoutWidget[] = [
columnSpan: 6,
},
configuration: {
graphType: GraphType.PIE,
graphType: GraphSubType.PIE,
},
data: {
items: [
@@ -130,7 +130,7 @@ export const mockPageLayoutWidgets: PageLayoutWidget[] = [
columnSpan: 4,
},
configuration: {
graphType: GraphType.BAR,
graphType: GraphSubType.BAR,
},
data: {
items: [
@@ -0,0 +1,8 @@
import { createState } from 'twenty-ui/utilities';
export const pageLayoutCurrentTabIdForCreationState = createState<
string | null
>({
key: 'pageLayoutCurrentTabIdForCreationState',
defaultValue: null,
});
@@ -11,6 +11,7 @@ export const pageLayoutDraftState = createState<DraftPageLayout>({
defaultValue: {
name: '',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
objectMetadataId: null,
tabs: [],
},
@@ -43,6 +43,7 @@ export type SavedPageLayout = {
id: string;
name: string;
type: PageLayoutType;
workspaceId?: string;
objectMetadataId?: string | null;
tabs: PageLayoutTab[];
createdAt: string;
@@ -1,4 +1,4 @@
import { GraphType, WidgetType } from '../../mocks/mockWidgets';
import { GraphSubType, WidgetType } from '../../mocks/mockWidgets';
import { type PageLayoutWidget } from '../../states/savedPageLayoutsState';
import { convertLayoutsToWidgets } from '../convertLayoutsToWidgets';
@@ -17,7 +17,7 @@ describe('convertLayoutsToWidgets', () => {
columnSpan: 2,
},
configuration: {
graphType: GraphType.NUMBER,
graphType: GraphSubType.NUMBER,
},
data: { value: 100 },
createdAt: '2024-01-01T00:00:00Z',
@@ -37,7 +37,7 @@ describe('convertLayoutsToWidgets', () => {
columnSpan: 2,
},
configuration: {
graphType: GraphType.PIE,
graphType: GraphSubType.PIE,
},
data: { items: [] },
createdAt: '2024-01-01T00:00:00Z',
@@ -1,14 +1,14 @@
import { GraphType } from '../mocks/mockWidgets';
import { GraphSubType } from '../mocks/mockWidgets';
export const getDefaultWidgetData = (graphType: GraphType) => {
export const getDefaultWidgetData = (graphType: GraphSubType) => {
switch (graphType) {
case GraphType.NUMBER:
case GraphSubType.NUMBER:
return {
value: '1,234',
trendPercentage: 15.2,
};
case GraphType.GAUGE:
case GraphSubType.GAUGE:
return {
value: 0.7,
min: 0,
@@ -16,7 +16,7 @@ export const getDefaultWidgetData = (graphType: GraphType) => {
label: 'Progress',
};
case GraphType.PIE:
case GraphSubType.PIE:
return {
items: [
{ id: 'segment1', value: 35, label: 'Segment A' },
@@ -26,7 +26,7 @@ export const getDefaultWidgetData = (graphType: GraphType) => {
],
};
case GraphType.BAR:
case GraphSubType.BAR:
return {
items: [
{ category: 'Jan', value: 45 },
@@ -46,26 +46,29 @@ export const getDefaultWidgetData = (graphType: GraphType) => {
}
};
export const getWidgetTitle = (graphType: GraphType, index: number): string => {
const baseNames: Record<GraphType, string> = {
[GraphType.NUMBER]: 'Number',
[GraphType.GAUGE]: 'Gauge',
[GraphType.PIE]: 'Pie Chart',
[GraphType.BAR]: 'Bar Chart',
export const getWidgetTitle = (
graphType: GraphSubType,
index: number,
): string => {
const baseNames: Record<GraphSubType, string> = {
[GraphSubType.NUMBER]: 'Number',
[GraphSubType.GAUGE]: 'Gauge',
[GraphSubType.PIE]: 'Pie Chart',
[GraphSubType.BAR]: 'Bar Chart',
};
return `${baseNames[graphType] || 'Widget'} ${index + 1}`;
};
export const getWidgetSize = (graphType: GraphType) => {
export const getWidgetSize = (graphType: GraphSubType) => {
switch (graphType) {
case GraphType.NUMBER:
case GraphSubType.NUMBER:
return { w: 3, h: 2 };
case GraphType.GAUGE:
case GraphSubType.GAUGE:
return { w: 3, h: 3 };
case GraphType.PIE:
case GraphSubType.PIE:
return { w: 4, h: 4 };
case GraphType.BAR:
case GraphSubType.BAR:
return { w: 6, h: 4 };
default:
return { w: 4, h: 4 };
@@ -0,0 +1,71 @@
import { GraphWidgetBarChart } from '@/dashboards/widgets/graph/components/GraphWidgetBarChart';
import { GraphWidgetGaugeChart } from '@/dashboards/widgets/graph/components/GraphWidgetGaugeChart';
import { GraphWidgetNumberChart } from '@/dashboards/widgets/graph/components/GraphWidgetNumberChart';
import { GraphWidgetPieChart } from '@/dashboards/widgets/graph/components/GraphWidgetPieChart';
import { type ReactNode } from 'react';
import { GraphSubType } from '../mocks/mockWidgets';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
type GraphRenderer = (widget: PageLayoutWidget) => ReactNode;
const graphRenderers: Record<GraphSubType, GraphRenderer> = {
[GraphSubType.NUMBER]: (widget) => (
<GraphWidgetNumberChart
value={widget.data.value}
trendPercentage={widget.data.trendPercentage}
/>
),
[GraphSubType.GAUGE]: (widget) => (
<GraphWidgetGaugeChart
data={{
value: widget.data.value,
min: widget.data.min,
max: widget.data.max,
label: widget.data.label,
}}
displayType="percentage"
showValue
id={`gauge-chart-${widget.id}`}
/>
),
[GraphSubType.PIE]: (widget) => (
<GraphWidgetPieChart
data={widget.data.items}
showLegend
displayType="percentage"
id={`pie-chart-${widget.id}`}
/>
),
[GraphSubType.BAR]: (widget) => (
<GraphWidgetBarChart
data={widget.data.items}
indexBy={widget.data.indexBy}
keys={widget.data.keys}
seriesLabels={widget.data.seriesLabels}
layout={widget.data.layout}
showLegend
showGrid
displayType="number"
id={`bar-chart-${widget.id}`}
/>
),
};
export const renderGraphWidget = (widget: PageLayoutWidget): ReactNode => {
const graphType = widget.configuration?.graphType;
if (!graphType || typeof graphType !== 'string') {
return null;
}
if (!Object.values(GraphSubType).includes(graphType as GraphSubType)) {
return null;
}
const renderer = graphRenderers[graphType as GraphSubType];
if (!renderer) {
return null;
}
return renderer(widget);
};
@@ -1,17 +1,14 @@
import { IframeWidget } from '@/dashboards/widgets/iframe/components/IframeWidget';
import { isString } from '@sniptt/guards';
import { type ReactNode } from 'react';
import { WidgetType } from '../mocks/mockWidgets';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { GraphWidgetRenderer } from './GraphWidgetRenderer';
import { renderGraphWidget } from './graphRegistry';
type WidgetRendererProps = {
widget: PageLayoutWidget;
};
export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
export const renderWidget = (widget: PageLayoutWidget): ReactNode => {
switch (widget.type) {
case WidgetType.GRAPH:
return <GraphWidgetRenderer widget={widget} />;
return renderGraphWidget(widget);
case WidgetType.IFRAME: {
const url = widget.configuration?.url;
@@ -3,6 +3,7 @@ import { SETTINGS_PLAYGROUND_FORM_SCHEMA_SELECT_OPTIONS } from '@/settings/playg
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
import { PlaygroundTypes } from '@/settings/playground/types/PlaygroundTypes';
import { SettingsPath } from '@/types/SettingsPath';
import { Select } from '@/ui/input/components/Select';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import styled from '@emotion/styled';
@@ -10,7 +11,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Controller, useForm } from 'react-hook-form';
import { useRecoilState } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { IconApi, IconBrandGraphql } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { z } from 'zod';
@@ -1,13 +1,13 @@
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
import { SettingsPath } from '@/types/SettingsPath';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { lazy, Suspense } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium};
@@ -1,6 +1,6 @@
import { SettingsPath } from 'twenty-shared/types';
import { SettingsPath } from '@/types/SettingsPath';
import { getSettingsPath } from 'twenty-shared/utils';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsRoleDefaultRole } from '@/settings/roles/components/SettingsRolesDefaultRole';
@@ -6,13 +6,13 @@ import { SettingsRolesTableHeader } from '@/settings/roles/components/SettingsRo
import { SettingsRolesTableRow } from '@/settings/roles/components/SettingsRolesTableRow';
import { ROLES_LIST_TABS } from '@/settings/roles/constants/RolesListTabs';
import { settingsAllRolesSelector } from '@/settings/roles/states/settingsAllRolesSelector';
import { SettingsPath } from '@/types/SettingsPath';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import {
H2Title,
IconKey,
@@ -1,12 +1,12 @@
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { SettingsPath } from '@/types/SettingsPath';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import React from 'react';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import {
AppTooltip,
Avatar,
@@ -15,7 +15,7 @@ import {
TooltipDelay,
useIcons,
} from 'twenty-ui/display';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { type RoleWithPartialMembers } from '../types/RoleWithPartialMembers';
const StyledAssignedText = styled.div`
@@ -8,13 +8,13 @@ import { SettingsRoleAssignmentConfirmationModal } from '@/settings/roles/role-a
import { type SettingsRoleAssignmentConfirmationModalSelectedRoleTarget } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedRoleTarget';
import { settingsAllRolesSelector } from '@/settings/roles/states/settingsAllRolesSelector';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { SettingsPath } from '@/types/SettingsPath';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import {
useFindManyAgentsQuery,
useGetApiKeysQuery,
@@ -2,12 +2,12 @@
import { SettingsCard } from '@/settings/components/SettingsCard';
import { useFilterObjectMetadataItemsWithPermissionOverride } from '@/settings/roles/role-permissions/object-level-permissions/hooks/useFilterObjectWithPermissionOverride';
import { useObjectMetadataItemsThatCanHavePermission } from '@/settings/roles/role-permissions/object-level-permissions/hooks/useObjectMetadataItemsThatCanHavePermission';
import { SettingsPath } from '@/types/SettingsPath';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { H2Title, IconSearch, useIcons } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
@@ -2,11 +2,11 @@ import { SettingsRolePermissionsObjectLevelTableHeader } from '@/settings/roles/
import { SettingsRolePermissionsObjectLevelTableRow } from '@/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableRow';
import { useFilterObjectMetadataItemsWithPermissionOverride } from '@/settings/roles/role-permissions/object-level-permissions/hooks/useFilterObjectWithPermissionOverride';
import { useObjectMetadataItemsThatCanHavePermission } from '@/settings/roles/role-permissions/object-level-permissions/hooks/useObjectMetadataItemsThatCanHavePermission';
import { SettingsPath } from '@/types/SettingsPath';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
@@ -3,17 +3,17 @@ import { SettingsRolePermissionsObjectLevelOverrideCellContainer } from '@/setti
import { SettingsRolePermissionsObjectLevelSeeFieldsValueForObject } from '@/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelSeeFieldsValueForObject';
import { SettingsRolePermissionsObjectLevelUpdateFieldsValueForObject } from '@/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelUpdateFieldsValueForObject';
import { OBJECT_LEVEL_PERMISSION_TABLE_GRID_AUTO_COLUMNS } from '@/settings/roles/role-permissions/object-level-permissions/constants/ObjectLevelPermissionTableGridAutoColumns';
import { SettingsPath } from '@/types/SettingsPath';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
IconChevronRight,
OverflowingTextWithTooltip,
useIcons,
} from 'twenty-ui/display';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledNameTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.primary};
@@ -3,12 +3,12 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
import { SettingsRolePermissionsObjectLevelObjectFieldPermissionTable } from '@/settings/roles/role-permissions/object-level-permissions/field-permissions/components/SettingsRolePermissionsObjectLevelObjectFieldPermissionTable';
import { SettingsRolePermissionsObjectLevelObjectFormObjectLevel } from '@/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectFormObjectLevel';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type SettingsRolePermissionsObjectLevelObjectFormProps = {
roleId: string;
@@ -1,8 +1,8 @@
import { ROLE_SETTINGS_DELETE_ROLE_CONFIRMATION_MODAL_ID } from '@/settings/roles/role-settings/components/constants/RoleSettingsDeleteRoleConfirmationModalId';
import { SettingsRoleSettingsDeleteRoleConfirmationModalSubtitle } from '@/settings/roles/role-settings/components/SettingsRoleSettingsDeleteRoleConfirmationModalSubtitle';
import { SettingsPath } from '@/types/SettingsPath';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { useDeleteOneRoleMutation } from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
@@ -10,6 +10,7 @@ import { useSaveDraftRoleToDB } from '@/settings/roles/role/hooks/useSaveDraftRo
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState';
import { settingsRolesIsLoadingState } from '@/settings/roles/states/settingsRolesIsLoadingState';
import { SettingsPath } from '@/types/SettingsPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
@@ -19,13 +20,13 @@ import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined, getSettingsPath } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import { IconLockOpen, IconSettings, IconUserPlus } from 'twenty-ui/display';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getDirtyFields } from '~/utils/getDirtyFields';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type SettingsRoleProps = {
roleId: string;
@@ -6,9 +6,9 @@ import { useRemoveFieldPermissionInDraftRole } from '@/settings/roles/role-permi
import { newFieldPermissionsFilter } from '@/settings/roles/role/hooks/utils/newFieldPermissionsFilter.util';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState';
import { SettingsPath } from '@/types/SettingsPath';
import { getOperationName } from '@apollo/client/utilities';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
useCreateOneRoleMutation,
@@ -2,7 +2,7 @@
import { Link } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { SettingsPath } from '@/types/SettingsPath';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsCard } from '@/settings/components/SettingsCard';
@@ -14,9 +14,9 @@ import isPropValid from '@emotion/is-prop-valid';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilState, useRecoilValue } from 'recoil';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconKey } from 'twenty-ui/display';
import { useGetSsoIdentityProvidersQuery } from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledLink = styled(Link, {
shouldForwardProp: (prop) => isPropValid(prop) && prop !== 'isDisabled',
@@ -4,8 +4,8 @@ import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState';
import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl';
import { SettingsPath } from '@/types/SettingsPath';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const SettingsSSOIdentitiesProvidersListCardWrapper = () => {
@@ -1,6 +1,6 @@
import { Link, useNavigate } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { SettingsPath } from '@/types/SettingsPath';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
@@ -12,11 +12,11 @@ import { ApolloError } from '@apollo/client';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilState, useRecoilValue } from 'recoil';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconAt, IconMailCog, Status } from 'twenty-ui/display';
import { useGetApprovedAccessDomainsQuery } from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledLink = styled(Link)`
text-decoration: none;
@@ -2,14 +2,14 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
import { SettingsServerlessFunctionsFieldItemTableRow } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsFieldItemTableRow';
import { SettingsServerlessFunctionsTableEmpty } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTableEmpty';
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { SettingsPath } from '@/types/SettingsPath';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { type ServerlessFunction } from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledTableRow = styled(TableRow)`
grid-template-columns: 312px 132px 68px;
@@ -1,8 +1,6 @@
import { SettingsPath } from '@/types/SettingsPath';
import styled from '@emotion/styled';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import {
AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer,
@@ -11,6 +9,8 @@ import {
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/layout';
import { Button } from 'twenty-ui/input';
import { IconPlus } from 'twenty-ui/display';
const StyledEmptyFunctionsContainer = styled.div`
height: 60vh;
@@ -2,9 +2,9 @@ import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functio
import { SettingsServerlessFunctionTabEnvironmentVariablesSection } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection';
import { useDeleteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useDeleteOneServerlessFunction';
import { type ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SettingsPath } from '@/types/SettingsPath';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SettingsPath } from 'twenty-shared/types';
import { H2Title } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
@@ -2,13 +2,13 @@ import { useRecoilValue } from 'recoil';
import { useAuth } from '@/auth/hooks/useAuth';
import { currentUserState } from '@/auth/states/currentUserState';
import { SettingsPath } from '@/types/SettingsPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { useLingui } from '@lingui/react/macro';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
@@ -1,12 +1,12 @@
import { type OTPFormValues } from '@/auth/sign-in-up/hooks/useTwoFactorAuthenticationForm';
import { VERIFY_TWO_FACTOR_AUTHENTICATION_METHOD_FOR_AUTHENTICATED_USER } from '@/settings/two-factor-authentication/graphql/mutations/verifyTwoFactorAuthenticationMethod';
import { SettingsPath } from '@/types/SettingsPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { useMutation } from '@apollo/client';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { SettingsPath } from 'twenty-shared/types';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const useTwoFactorVerificationForSettings = () => {