Merge branch 'main' into feat/new-seeds

This commit is contained in:
Lucas Bordeau 2024-12-20 10:46:35 +01:00
commit 59478a08bb
131 changed files with 2130 additions and 486 deletions

View File

@ -1,7 +1,8 @@
name: CI demo check
name: CI Demo check
on:
schedule:
- cron: '30 7,19 * * *'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -11,6 +12,9 @@ jobs:
test:
timeout-minutes: 15
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/twenty-e2e-testing
steps:
- uses: actions/checkout@v4
with:
@ -27,7 +31,7 @@ jobs:
- name: Run Playwright tests
id: test
run: yarn playwright test --grep @demo-only
run: yarn playwright test --grep "@demo-only"
- name: Upload report after tests
uses: actions/upload-artifact@v4

View File

@ -8,19 +8,17 @@ import { contextStoreFiltersComponentState } from '@/context-store/states/contex
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useCallback, useContext, useState } from 'react';
import { IconTrash, isDefined } from 'twenty-ui';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
export const useDeleteMultipleRecordsAction = ({
objectMetadataItem,
@ -40,9 +38,6 @@ export const useDeleteMultipleRecordsAction = ({
objectNameSingular: objectMetadataItem.nameSingular,
});
const { sortedFavorites: favorites } = useFavorites();
const { deleteFavorite } = useDeleteFavorite();
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
);
@ -76,26 +71,8 @@ export const useDeleteMultipleRecordsAction = ({
resetTableRowSelection();
for (const recordIdToDelete of recordIdsToDelete) {
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordIdToDelete,
);
if (foundFavorite !== undefined) {
deleteFavorite(foundFavorite.id);
}
}
await deleteManyRecords(recordIdsToDelete, {
delayInMsBetweenRequests: 50,
});
}, [
deleteFavorite,
deleteManyRecords,
favorites,
fetchAllRecordIds,
resetTableRowSelection,
]);
await deleteManyRecords(recordIdsToDelete);
}, [deleteManyRecords, fetchAllRecordIds, resetTableRowSelection]);
const isRemoteObject = objectMetadataItem.isRemote;
@ -105,7 +82,7 @@ export const useDeleteMultipleRecordsAction = ({
contextStoreNumberOfSelectedRecords < DELETE_MAX_COUNT &&
contextStoreNumberOfSelectedRecords > 0;
const { isInRightDrawer, onActionExecutedCallback } =
const { isInRightDrawer, onActionStartedCallback, onActionExecutedCallback } =
useContext(ActionMenuContext);
const registerDeleteMultipleRecordsAction = ({
@ -118,7 +95,8 @@ export const useDeleteMultipleRecordsAction = ({
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'delete-multiple-records',
label: 'Delete',
label: 'Delete records',
shortLabel: 'Delete',
position,
Icon: IconTrash,
accent: 'danger',
@ -132,9 +110,14 @@ export const useDeleteMultipleRecordsAction = ({
setIsOpen={setIsDeleteRecordsModalOpen}
title={'Delete Records'}
subtitle={`Are you sure you want to delete these records? They can be recovered from the Options menu.`}
onConfirmClick={() => {
handleDeleteClick();
onActionExecutedCallback?.();
onConfirmClick={async () => {
onActionStartedCallback?.({
key: 'delete-multiple-records',
});
await handleDeleteClick();
onActionExecutedCallback?.({
key: 'delete-multiple-records',
});
if (isInRightDrawer) {
closeRightDrawer();
}

View File

@ -36,6 +36,7 @@ export const useExportMultipleRecordsAction = ({
key: 'export-multiple-records',
position,
label: displayedExportProgress(progress),
shortLabel: 'Export',
Icon: IconDatabaseExport,
accent: 'default',
onClick: () => download(),

View File

@ -1,5 +1,6 @@
import { useAddToFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useAddToFavoritesSingleRecordAction';
import { useDeleteSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction';
import { useDestroySingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction';
import { useNavigateToNextRecordSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useNavigateToNextRecordSingleRecordAction';
import { useNavigateToPreviousRecordSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useNavigateToPreviousRecordSingleRecordAction';
import { useRemoveFromFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction';
@ -16,6 +17,7 @@ import {
IconHeart,
IconHeartOff,
IconTrash,
IconTrashX,
} from 'twenty-ui';
export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
@ -70,13 +72,29 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
],
actionHook: useDeleteSingleRecordAction,
},
destroySingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'destroy-single-record',
label: 'Permanently destroy record',
shortLabel: 'Destroy',
position: 3,
Icon: IconTrashX,
accent: 'danger',
isPinned: true,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
],
actionHook: useDestroySingleRecordAction,
},
navigateToPreviousRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'navigate-to-previous-record',
label: 'Navigate to previous record',
shortLabel: '',
position: 3,
position: 4,
isPinned: true,
Icon: IconChevronUp,
availableOn: [ActionAvailableOn.SHOW_PAGE],
@ -88,7 +106,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
key: 'navigate-to-next-record',
label: 'Navigate to next record',
shortLabel: '',
position: 4,
position: 5,
isPinned: true,
Icon: IconChevronDown,
availableOn: [ActionAvailableOn.SHOW_PAGE],

View File

@ -2,6 +2,7 @@ import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/acti
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isNull } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
@ -23,7 +24,8 @@ export const useAddToFavoritesSingleRecordAction: SingleRecordActionHookWithObje
isDefined(objectMetadataItem) &&
isDefined(selectedRecord) &&
!objectMetadataItem.isRemote &&
!isFavorite;
!isFavorite &&
isNull(selectedRecord.deletedAt);
const onClick = () => {
if (!shouldBeRegistered) {

View File

@ -3,10 +3,13 @@ import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { isNull } from '@sniptt/guards';
import { useCallback, useContext, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetadataItem =
@ -22,6 +25,8 @@ export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetada
objectNameSingular: objectMetadataItem.nameSingular,
});
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
const { sortedFavorites: favorites } = useFavorites();
const { deleteFavorite } = useDeleteFavorite();
@ -49,10 +54,10 @@ export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetada
const isRemoteObject = objectMetadataItem.isRemote;
const { isInRightDrawer, onActionExecutedCallback } =
useContext(ActionMenuContext);
const { isInRightDrawer } = useContext(ActionMenuContext);
const shouldBeRegistered = !isRemoteObject;
const shouldBeRegistered =
!isRemoteObject && isNull(selectedRecord?.deletedAt);
const onClick = () => {
if (!shouldBeRegistered) {
@ -75,7 +80,6 @@ export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetada
}
onConfirmClick={() => {
handleDeleteClick();
onActionExecutedCallback?.();
if (isInRightDrawer) {
closeRightDrawer();
}

View File

@ -0,0 +1,73 @@
import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { useCallback, useContext, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const useDestroySingleRecordAction: SingleRecordActionHookWithObjectMetadataItem =
({ recordId, objectMetadataItem }) => {
const [isDestroyRecordsModalOpen, setIsDestroyRecordsModalOpen] =
useState(false);
const { resetTableRowSelection } = useRecordTable({
recordTableId: objectMetadataItem.namePlural,
});
const { destroyOneRecord } = useDestroyOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
});
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
const { closeRightDrawer } = useRightDrawer();
const handleDeleteClick = useCallback(async () => {
resetTableRowSelection();
await destroyOneRecord(recordId);
}, [resetTableRowSelection, destroyOneRecord, recordId]);
const isRemoteObject = objectMetadataItem.isRemote;
const { isInRightDrawer, onActionExecutedCallback } =
useContext(ActionMenuContext);
const shouldBeRegistered =
!isRemoteObject && isDefined(selectedRecord?.deletedAt);
const onClick = () => {
if (!shouldBeRegistered) {
return;
}
setIsDestroyRecordsModalOpen(true);
};
return {
shouldBeRegistered,
onClick,
ConfirmationModal: (
<ConfirmationModal
isOpen={isDestroyRecordsModalOpen}
setIsOpen={setIsDestroyRecordsModalOpen}
title={'Permanently Destroy Record'}
subtitle={
'Are you sure you want to destroy this record? It cannot be recovered anymore.'
}
onConfirmClick={async () => {
await handleDeleteClick();
onActionExecutedCallback?.({ key: 'destroy-single-record' });
if (isInRightDrawer) {
closeRightDrawer();
}
}}
deleteButtonText={'Permanently Destroy Record'}
/>
),
};
};

View File

@ -8,11 +8,13 @@ import { RecordIndexActionMenuEffect } from '@/action-menu/components/RecordInde
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { isRecordIndexLoadMoreLockedComponentState } from '@/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useIsMobile } from 'twenty-ui';
export const RecordIndexActionMenu = () => {
export const RecordIndexActionMenu = ({ indexId }: { indexId: string }) => {
const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataIdComponentState,
);
@ -25,13 +27,27 @@ export const RecordIndexActionMenu = () => {
const isMobile = useIsMobile();
const setIsLoadMoreLocked = useSetRecoilComponentStateV2(
isRecordIndexLoadMoreLockedComponentState,
indexId,
);
return (
<>
{contextStoreCurrentObjectMetadataId && (
<ActionMenuContext.Provider
value={{
isInRightDrawer: false,
onActionExecutedCallback: () => {},
onActionStartedCallback: (action) => {
if (action.key === 'delete-multiple-records') {
setIsLoadMoreLocked(true);
}
},
onActionExecutedCallback: (action) => {
if (action.key === 'delete-multiple-records') {
setIsLoadMoreLocked(false);
}
},
}}
>
{isPageHeaderV2Enabled ? (

View File

@ -27,8 +27,8 @@ export const RecordIndexActionMenuButtons = () => {
size="small"
variant="secondary"
accent="default"
title={entry.label}
onClick={() => entry.onClick?.()}
title={entry.shortLabel}
onClick={entry.onClick}
ariaLabel={entry.label}
/>
))}

View File

@ -1,8 +1,3 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { PositionType } from '../types/PositionType';
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState';
@ -13,7 +8,10 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { MenuItem } from 'twenty-ui';
import { PositionType } from '../types/PositionType';
type StyledContainerProps = {
position: PositionType;

View File

@ -2,10 +2,12 @@ import { createContext } from 'react';
type ActionMenuContextType = {
isInRightDrawer: boolean;
onActionExecutedCallback: () => void;
onActionStartedCallback?: (action: { key: string }) => void;
onActionExecutedCallback?: (action: { key: string }) => void;
};
export const ActionMenuContext = createContext<ActionMenuContextType>({
isInRightDrawer: false,
onActionStartedCallback: () => {},
onActionExecutedCallback: () => {},
});

View File

@ -88,6 +88,7 @@ const mocks = [
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
position
@ -95,6 +96,7 @@ const mocks = [
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
workPreference
@ -246,6 +248,7 @@ const mocks = [
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
position
@ -253,6 +256,7 @@ const mocks = [
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
workPreference

View File

@ -0,0 +1,132 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges';
import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs';
import { RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
// TODO: add extensive unit tests for this function
// That will also serve as documentation
export const triggerUpdateRecordOptimisticEffectByBatch = ({
cache,
objectMetadataItem,
currentRecords,
updatedRecords,
objectMetadataItems,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
currentRecords: RecordGqlNode[];
updatedRecords: RecordGqlNode[];
objectMetadataItems: ObjectMetadataItem[];
}) => {
for (const [index, currentRecord] of currentRecords.entries()) {
triggerUpdateRelationsOptimisticEffect({
cache,
sourceObjectMetadataItem: objectMetadataItem,
currentSourceRecord: currentRecord,
updatedSourceRecord: updatedRecords[index],
objectMetadataItems,
});
}
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
rootQueryCachedResponse,
{ readField, storeFieldName, toReference },
) => {
const shouldSkip = !isObjectRecordConnectionWithRefs(
objectMetadataItem.nameSingular,
rootQueryCachedResponse,
);
if (shouldSkip) {
return rootQueryCachedResponse;
}
const rootQueryConnection = rootQueryCachedResponse;
const { fieldVariables: rootQueryVariables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
);
const rootQueryCurrentEdges =
readField<RecordGqlRefEdge[]>('edges', rootQueryConnection) ?? [];
let rootQueryNextEdges = [...rootQueryCurrentEdges];
const rootQueryFilter = rootQueryVariables?.filter;
const rootQueryOrderBy = rootQueryVariables?.orderBy;
for (const updatedRecord of updatedRecords) {
const updatedRecordMatchesThisRootQueryFilter =
isRecordMatchingFilter({
record: updatedRecord,
filter: rootQueryFilter ?? {},
objectMetadataItem,
});
const updatedRecordIndexInRootQueryEdges =
rootQueryCurrentEdges.findIndex(
(cachedEdge) =>
readField('id', cachedEdge.node) === updatedRecord.id,
);
const updatedRecordFoundInRootQueryEdges =
updatedRecordIndexInRootQueryEdges > -1;
const updatedRecordShouldBeAddedToRootQueryEdges =
updatedRecordMatchesThisRootQueryFilter &&
!updatedRecordFoundInRootQueryEdges;
const updatedRecordShouldBeRemovedFromRootQueryEdges =
!updatedRecordMatchesThisRootQueryFilter &&
updatedRecordFoundInRootQueryEdges;
if (updatedRecordShouldBeAddedToRootQueryEdges) {
const updatedRecordNodeReference = toReference(updatedRecord);
if (isDefined(updatedRecordNodeReference)) {
rootQueryNextEdges.push({
__typename: getEdgeTypename(objectMetadataItem.nameSingular),
node: updatedRecordNodeReference,
cursor: '',
});
}
}
if (updatedRecordShouldBeRemovedFromRootQueryEdges) {
rootQueryNextEdges.splice(updatedRecordIndexInRootQueryEdges, 1);
}
}
const rootQueryNextEdgesShouldBeSorted = isDefined(rootQueryOrderBy);
if (
rootQueryNextEdgesShouldBeSorted &&
Object.getOwnPropertyNames(rootQueryOrderBy).length > 0
) {
rootQueryNextEdges = sortCachedObjectEdges({
edges: rootQueryNextEdges,
orderBy: rootQueryOrderBy,
readCacheField: readField,
});
}
return {
...rootQueryConnection,
edges: rootQueryNextEdges,
};
},
},
});
};

View File

@ -70,6 +70,7 @@ type CommandGroupConfig = {
key?: string;
firstHotKey?: string;
secondHotKey?: string;
shouldCloseCommandMenuOnClick?: boolean;
};
};
@ -253,6 +254,7 @@ export const CommandMenu = () => {
id,
label: `${firstName} ${lastName}`,
to: `object/person/${id}`,
shouldCloseCommandMenuOnClick: true,
})),
[people],
);
@ -263,6 +265,7 @@ export const CommandMenu = () => {
id,
label: name ?? '',
to: `object/company/${id}`,
shouldCloseCommandMenuOnClick: true,
})),
[companies],
);
@ -273,6 +276,7 @@ export const CommandMenu = () => {
id,
label: name ?? '',
to: `object/opportunity/${id}`,
shouldCloseCommandMenuOnClick: true,
})),
[opportunities],
);
@ -284,6 +288,7 @@ export const CommandMenu = () => {
label: note.title ?? '',
to: '',
onCommandClick: () => openActivityRightDrawer(note.id),
shouldCloseCommandMenuOnClick: true,
})),
[notes, openActivityRightDrawer],
);
@ -295,6 +300,7 @@ export const CommandMenu = () => {
label: task.title ?? '',
to: '',
onCommandClick: () => openActivityRightDrawer(task.id),
shouldCloseCommandMenuOnClick: true,
})),
[tasks, openActivityRightDrawer],
);
@ -307,6 +313,7 @@ export const CommandMenu = () => {
id: objectRecord.record.id,
label: objectRecord.recordIdentifier.name,
to: `object/${objectRecord.objectMetadataItem.nameSingular}/${objectRecord.record.id}`,
shouldCloseCommandMenuOnClick: true,
})),
);
});
@ -488,6 +495,7 @@ export const CommandMenu = () => {
onClick: command.onCommandClick,
firstHotKey: command.firstHotKey,
secondHotKey: command.secondHotKey,
shouldCloseCommandMenuOnClick: command.shouldCloseCommandMenuOnClick,
}),
},
{
@ -501,6 +509,7 @@ export const CommandMenu = () => {
onClick: command.onCommandClick,
firstHotKey: command.firstHotKey,
secondHotKey: command.secondHotKey,
shouldCloseCommandMenuOnClick: command.shouldCloseCommandMenuOnClick,
}),
},
{
@ -520,6 +529,7 @@ export const CommandMenu = () => {
),
firstHotKey: person.firstHotKey,
secondHotKey: person.secondHotKey,
shouldCloseCommandMenuOnClick: true,
}),
},
{
@ -540,6 +550,7 @@ export const CommandMenu = () => {
),
firstHotKey: company.firstHotKey,
secondHotKey: company.secondHotKey,
shouldCloseCommandMenuOnClick: true,
}),
},
{
@ -557,6 +568,7 @@ export const CommandMenu = () => {
placeholder={opportunity.name ?? ''}
/>
),
shouldCloseCommandMenuOnClick: true,
}),
},
{
@ -567,6 +579,7 @@ export const CommandMenu = () => {
Icon: IconNotes,
label: note.title ?? '',
onClick: () => openActivityRightDrawer(note.id),
shouldCloseCommandMenuOnClick: true,
}),
},
{
@ -577,6 +590,7 @@ export const CommandMenu = () => {
Icon: IconCheckbox,
label: task.title ?? '',
onClick: () => openActivityRightDrawer(task.id),
shouldCloseCommandMenuOnClick: true,
}),
},
...Object.entries(customObjectRecordsMap).map(
@ -596,6 +610,7 @@ export const CommandMenu = () => {
placeholder={objectRecord.recordIdentifier.name ?? ''}
/>
),
shouldCloseCommandMenuOnClick: true,
}),
}),
),
@ -627,8 +642,17 @@ export const CommandMenu = () => {
].find((cmd) => cmd.id === itemId);
if (isDefined(command)) {
const { to, onCommandClick } = command;
onItemClick(onCommandClick, to);
const {
to,
onCommandClick,
shouldCloseCommandMenuOnClick,
} = command;
onItemClick({
shouldCloseCommandMenuOnClick,
onClick: onCommandClick,
to,
});
}
}}
>
@ -745,6 +769,9 @@ export const CommandMenu = () => {
secondHotKey={
workflowRunGlobalCommand.secondHotKey
}
shouldCloseCommandMenuOnClick={
workflowRunGlobalCommand.shouldCloseCommandMenuOnClick
}
/>
</SelectableItem>
),
@ -765,6 +792,7 @@ export const CommandMenu = () => {
key,
firstHotKey,
secondHotKey,
shouldCloseCommandMenuOnClick,
} = renderItem(item);
return (
<SelectableItem itemId={id} key={id}>
@ -777,6 +805,9 @@ export const CommandMenu = () => {
onClick={onClick}
firstHotKey={firstHotKey}
secondHotKey={secondHotKey}
shouldCloseCommandMenuOnClick={
shouldCloseCommandMenuOnClick
}
/>
</SelectableItem>
);

View File

@ -14,6 +14,7 @@ export type CommandMenuItemProps = {
Icon?: IconComponent;
firstHotKey?: string;
secondHotKey?: string;
shouldCloseCommandMenuOnClick?: boolean;
};
export const CommandMenuItem = ({
@ -24,6 +25,7 @@ export const CommandMenuItem = ({
Icon,
firstHotKey,
secondHotKey,
shouldCloseCommandMenuOnClick,
}: CommandMenuItemProps) => {
const { onItemClick } = useCommandMenu();
@ -40,7 +42,13 @@ export const CommandMenuItem = ({
text={label}
firstHotKey={firstHotKey}
secondHotKey={secondHotKey}
onClick={() => onItemClick(onClick, to)}
onClick={() =>
onItemClick({
shouldCloseCommandMenuOnClick,
onClick,
to,
})
}
isSelected={isSelectedItemId}
/>
);

View File

@ -17,6 +17,7 @@ export const COMMAND_MENU_NAVIGATE_COMMANDS: { [key: string]: Command } = {
firstHotKey: 'G',
secondHotKey: 'P',
Icon: IconUser,
shouldCloseCommandMenuOnClick: true,
},
companies: {
id: 'go-to-companies',
@ -26,6 +27,7 @@ export const COMMAND_MENU_NAVIGATE_COMMANDS: { [key: string]: Command } = {
firstHotKey: 'G',
secondHotKey: 'C',
Icon: IconBuildingSkyscraper,
shouldCloseCommandMenuOnClick: true,
},
opportunities: {
id: 'go-to-activities',
@ -35,6 +37,7 @@ export const COMMAND_MENU_NAVIGATE_COMMANDS: { [key: string]: Command } = {
firstHotKey: 'G',
secondHotKey: 'O',
Icon: IconTargetArrow,
shouldCloseCommandMenuOnClick: true,
},
settings: {
id: 'go-to-settings',
@ -44,6 +47,7 @@ export const COMMAND_MENU_NAVIGATE_COMMANDS: { [key: string]: Command } = {
firstHotKey: 'G',
secondHotKey: 'S',
Icon: IconSettings,
shouldCloseCommandMenuOnClick: true,
},
tasks: {
id: 'go-to-tasks',
@ -53,5 +57,6 @@ export const COMMAND_MENU_NAVIGATE_COMMANDS: { [key: string]: Command } = {
firstHotKey: 'G',
secondHotKey: 'T',
Icon: IconCheckbox,
shouldCloseCommandMenuOnClick: true,
},
};

View File

@ -82,7 +82,11 @@ describe('useCommandMenu', () => {
const onClickMock = jest.fn();
act(() => {
result.current.commandMenu.onItemClick(onClickMock, '/test');
result.current.commandMenu.onItemClick({
shouldCloseCommandMenuOnClick: true,
onClick: onClickMock,
to: '/test',
});
});
expect(result.current.isCommandMenuOpened).toBe(true);

View File

@ -216,8 +216,21 @@ export const useCommandMenu = () => {
);
const onItemClick = useCallback(
(onClick?: () => void, to?: string) => {
toggleCommandMenu();
({
shouldCloseCommandMenuOnClick,
onClick,
to,
}: {
shouldCloseCommandMenuOnClick?: boolean;
onClick?: () => void;
to?: string;
}) => {
if (
isDefined(shouldCloseCommandMenuOnClick) &&
shouldCloseCommandMenuOnClick
) {
toggleCommandMenu();
}
if (isDefined(onClick)) {
onClick();

View File

@ -21,4 +21,5 @@ export type Command = {
firstHotKey?: string;
secondHotKey?: string;
onCommandClick?: () => void;
shouldCloseCommandMenuOnClick?: boolean;
};

View File

@ -39,6 +39,7 @@ export const useFindManyRecordsSelectedInContextStore = ({
const { records, loading, totalCount } = useFindManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
filter: queryFilter,
withSoftDeleted: true,
orderBy: [
{
position: 'AscNullsFirst',

View File

@ -99,6 +99,7 @@ export const FavoriteFolderPicker = ({
toggleFolderSelection={toggleFolderSelection}
/>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<FavoriteFolderPickerFooter dropdownId={dropdownId} />
</DropdownMenu>
);

View File

@ -4,16 +4,9 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { IconPlus, MenuItem } from 'twenty-ui';
const StyledFooter = styled.div`
border-bottom-left-radius: ${({ theme }) => theme.border.radius.md};
border-bottom-right-radius: ${({ theme }) => theme.border.radius.md};
border-top: 1px solid ${({ theme }) => theme.border.color.light};
`;
export const FavoriteFolderPickerFooter = ({
dropdownId,
}: {
@ -30,20 +23,18 @@ export const FavoriteFolderPickerFooter = ({
const { closeDropdown } = useDropdown(dropdownId);
return (
<StyledFooter>
<DropdownMenuItemsContainer>
<MenuItem
className="add-folder"
onClick={() => {
setIsNavigationDrawerExpanded(true);
openNavigationSection();
setIsFavoriteFolderCreating(true);
closeDropdown();
}}
text="Add folder"
LeftIcon={() => <IconPlus size={theme.icon.size.md} />}
/>
</DropdownMenuItemsContainer>
</StyledFooter>
<DropdownMenuItemsContainer scrollable={false}>
<MenuItem
className="add-folder"
onClick={() => {
setIsNavigationDrawerExpanded(true);
openNavigationSection();
setIsFavoriteFolderCreating(true);
closeDropdown();
}}
text="Add folder"
LeftIcon={() => <IconPlus size={theme.icon.size.md} />}
/>
</DropdownMenuItemsContainer>
);
};

View File

@ -246,6 +246,7 @@ mutation UpdateOneFavorite(
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
position
@ -253,6 +254,7 @@ mutation UpdateOneFavorite(
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
workPreference
@ -532,6 +534,7 @@ export const mocks = [
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
position
@ -539,6 +542,7 @@ export const mocks = [
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
workPreference

View File

@ -198,6 +198,7 @@ phone
{
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
}
linkedinLink
{

View File

@ -48,6 +48,7 @@ describe('mapObjectMetadataToGraphQLQuery', () => {
{
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
}
createdAt
avatarUrl

View File

@ -157,6 +157,7 @@ ${mapObjectMetadataToGraphQLQuery({
{
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}`;
}

View File

@ -30,6 +30,7 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS = `
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
position
@ -37,6 +38,7 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS = `
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
workPreference
@ -229,6 +231,7 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = `
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
pointOfContactForOpportunities {
@ -305,6 +308,7 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = `
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
workPreference

View File

@ -38,6 +38,7 @@ export const responseData = {
},
phones: {
primaryPhoneCountryCode: '',
primaryPhoneCallingCode: '',
primaryPhoneNumber: '',
},
linkedinLink: {

View File

@ -43,6 +43,7 @@ export const responseData = {
},
phones: {
primaryPhoneCountryCode: '',
primaryPhoneCallingCode: '',
primaryPhoneNumber: '',
},
linkedinLink: {

View File

@ -178,6 +178,7 @@ const mocks: MockedResponse[] = [
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
position
@ -185,6 +186,7 @@ const mocks: MockedResponse[] = [
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
workPreference
@ -332,6 +334,7 @@ const mocks: MockedResponse[] = [
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
position
@ -339,6 +342,7 @@ const mocks: MockedResponse[] = [
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
workPreference

View File

@ -1,6 +1,7 @@
import { useApolloClient } from '@apollo/client';
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { triggerUpdateRecordOptimisticEffectByBatch } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
@ -8,6 +9,7 @@ import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordF
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
import { RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { useDeleteManyRecordsMutation } from '@/object-record/hooks/useDeleteManyRecordsMutation';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
@ -80,6 +82,9 @@ export const useDeleteManyRecords = ({
.map((idToDelete) => getRecordFromCache(idToDelete, apolloClient.cache))
.filter(isDefined);
const cachedRecordsWithConnection: RecordGqlNode[] = [];
const optimisticRecordsWithConnection: RecordGqlNode[] = [];
if (!options?.skipOptimisticEffect) {
cachedRecords.forEach((cachedRecord) => {
if (!cachedRecord || !cachedRecord.id) {
@ -112,20 +117,23 @@ export const useDeleteManyRecords = ({
return null;
}
cachedRecordsWithConnection.push(cachedRecordWithConnection);
optimisticRecordsWithConnection.push(optimisticRecordWithConnection);
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloClient.cache,
record: computedOptimisticRecord,
});
});
triggerUpdateRecordOptimisticEffect({
cache: apolloClient.cache,
objectMetadataItem,
currentRecord: cachedRecordWithConnection,
updatedRecord: optimisticRecordWithConnection,
objectMetadataItems,
});
triggerUpdateRecordOptimisticEffectByBatch({
cache: apolloClient.cache,
objectMetadataItem,
currentRecords: cachedRecordsWithConnection,
updatedRecords: optimisticRecordsWithConnection,
objectMetadataItems,
});
}

View File

@ -20,6 +20,7 @@ export type UseFindManyRecordsParams<T> = ObjectMetadataItemIdentifier &
skip?: boolean;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
fetchPolicy?: WatchQueryFetchPolicy;
withSoftDeleted?: boolean;
};
export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
@ -33,6 +34,7 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
onError,
onCompleted,
cursorFilter,
withSoftDeleted = false,
}: UseFindManyRecordsParams<T>) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
@ -61,11 +63,18 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
onCompleted,
});
const withSoftDeleterFilter = {
or: [{ deletedAt: { is: 'NULL' } }, { deletedAt: { is: 'NOT_NULL' } }],
};
const { data, loading, error, fetchMore } =
useQuery<RecordGqlOperationFindManyResult>(findManyRecordsQuery, {
skip: skip || !objectMetadataItem,
variables: {
filter,
filter: {
...filter,
...(withSoftDeleted ? withSoftDeleterFilter : {}),
},
orderBy,
lastCursor: cursorFilter?.cursor ?? undefined,
limit: cursorFilter?.limit ?? limit,

View File

@ -62,7 +62,7 @@ export const ObjectOptionsDropdownFieldsContent = () => {
showDragGrip={true}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer withoutScrollWrapper>
<DropdownMenuItemsContainer scrollable={false}>
<MenuItemNavigate
onClick={() => onContentChange('hiddenFields')}
LeftIcon={IconEyeOff}

View File

@ -87,7 +87,7 @@ export const ObjectOptionsDropdownHiddenFieldsContent = () => {
closeDropdown();
}}
>
<DropdownMenuItemsContainer withoutScrollWrapper>
<DropdownMenuItemsContainer scrollable={false}>
<MenuItem LeftIcon={IconSettings} text="Edit Fields" />
</DropdownMenuItemsContainer>
</UndecoratedLink>

View File

@ -98,7 +98,7 @@ export const ObjectOptionsDropdownMenuContent = () => {
{/** TODO: Should be removed when view settings contains more options */}
{viewType === ViewType.Kanban && (
<>
<DropdownMenuItemsContainer withoutScrollWrapper>
<DropdownMenuItemsContainer scrollable={false}>
<MenuItem
onClick={() => onContentChange('viewSettings')}
LeftIcon={IconLayout}
@ -109,7 +109,7 @@ export const ObjectOptionsDropdownMenuContent = () => {
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItemsContainer withoutScrollWrapper>
<DropdownMenuItemsContainer scrollable={false}>
<MenuItem
onClick={() => onContentChange('fields')}
LeftIcon={IconTag}

View File

@ -7,6 +7,8 @@ import { GRAY_SCALE } from 'twenty-ui';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { isRecordBoardFetchingRecordsByColumnFamilyState } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsByColumnFamilyState';
import { recordBoardShouldFetchMoreInColumnComponentFamilyState } from '@/object-record/record-board/states/recordBoardShouldFetchMoreInColumnComponentFamilyState';
import { isRecordIndexLoadMoreLockedComponentState } from '@/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentFamilyStateV2';
const StyledText = styled.div`
@ -31,11 +33,23 @@ export const RecordBoardColumnFetchMoreLoader = () => {
columnDefinition.id,
);
const isLoadMoreLocked = useRecoilComponentValueV2(
isRecordIndexLoadMoreLockedComponentState,
);
const { ref, inView } = useInView();
useEffect(() => {
if (isLoadMoreLocked) {
return;
}
setShouldFetchMore(inView);
}, [setShouldFetchMore, inView]);
}, [setShouldFetchMore, inView, isLoadMoreLocked]);
if (isLoadMoreLocked) {
return null;
}
return (
<div ref={ref}>

View File

@ -1,7 +1,6 @@
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import styled from '@emotion/styled';
import { AppTooltip, Tag, TooltipDelay } from 'twenty-ui';
import { formatNumber } from '~/utils/format/number';
const StyledButton = styled(StyledHeaderDropdownButton)`
padding: 0;
@ -19,10 +18,7 @@ export const RecordBoardColumnHeaderAggregateDropdownButton = ({
return (
<div id={dropdownId}>
<StyledButton>
<Tag
text={value ? formatNumber(Number(value)) : '-'}
color={'transparent'}
/>
<Tag text={value ? value.toString() : '-'} color={'transparent'} />
<AppTooltip
anchorSelect={`#${dropdownId}`}
content={tooltip}

View File

@ -74,7 +74,7 @@ export const useAggregateRecordsForRecordBoardColumn = () => {
skip: !isAggregateQueryEnabled,
});
const { value, label } = computeAggregateValueAndLabel({
const { value, labelWithFieldName } = computeAggregateValueAndLabel({
data,
objectMetadataItem,
fieldMetadataId: recordIndexKanbanAggregateOperation?.fieldMetadataId,
@ -84,6 +84,6 @@ export const useAggregateRecordsForRecordBoardColumn = () => {
return {
aggregateValue: isAggregateQueryEnabled ? value : recordCount,
aggregateLabel: isDefined(value) ? label : undefined,
aggregateLabel: isDefined(value) ? labelWithFieldName : undefined,
};
};

View File

@ -47,8 +47,81 @@ describe('computeAggregateValueAndLabel', () => {
});
expect(result).toEqual({
value: 2,
label: 'Sum of amount',
value: '2',
label: 'Sum',
labelWithFieldName: 'Sum of amount',
});
});
it('should handle number field as percentage', () => {
const mockObjectMetadataWithPercentageField: ObjectMetadataItem = {
id: '123',
fields: [
{
id: MOCK_FIELD_ID,
name: 'percentage',
type: FieldMetadataType.Number,
settings: {
type: 'percentage',
},
} as FieldMetadataItem,
],
} as ObjectMetadataItem;
const mockData = {
percentage: {
[AGGREGATE_OPERATIONS.avg]: 0.3,
},
};
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadataWithPercentageField,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.avg,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
});
expect(result).toEqual({
value: '30%',
label: 'Average',
labelWithFieldName: 'Average of percentage',
});
});
it('should handle number field with decimals', () => {
const mockObjectMetadataWithDecimalsField: ObjectMetadataItem = {
id: '123',
fields: [
{
id: MOCK_FIELD_ID,
name: 'decimals',
type: FieldMetadataType.Number,
settings: {
decimals: 2,
},
} as FieldMetadataItem,
],
} as ObjectMetadataItem;
const mockData = {
decimals: {
[AGGREGATE_OPERATIONS.sum]: 0.009,
},
};
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadataWithDecimalsField,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
});
expect(result).toEqual({
value: '0.01',
label: 'Sum',
labelWithFieldName: 'Sum of decimals',
});
});
@ -86,8 +159,9 @@ describe('computeAggregateValueAndLabel', () => {
});
expect(result).toEqual({
value: undefined,
label: 'Sum of amount',
value: '-',
label: 'Sum',
labelWithFieldName: 'Sum of amount',
});
});
});

View File

@ -4,6 +4,8 @@ import { getAggregateOperationLabel } from '@/object-record/record-board/record-
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import isEmpty from 'lodash.isempty';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { formatAmount } from '~/utils/format/formatAmount';
import { formatNumber } from '~/utils/format/number';
import { isDefined } from '~/utils/isDefined';
export const computeAggregateValueAndLabel = ({
@ -42,12 +44,33 @@ export const computeAggregateValueAndLabel = ({
const aggregateValue = data[field.name]?.[aggregateOperation];
const value =
isDefined(aggregateValue) && field.type === FieldMetadataType.Currency
? Number(aggregateValue) / 1_000_000
: aggregateValue;
let value;
const label =
if (aggregateOperation === AGGREGATE_OPERATIONS.count) {
value = aggregateValue;
} else if (!isDefined(aggregateValue)) {
value = '-';
} else {
value = Number(aggregateValue);
switch (field.type) {
case FieldMetadataType.Currency: {
value = formatAmount(value / 1_000_000);
break;
}
case FieldMetadataType.Number: {
const { decimals, type } = field.settings ?? {};
value =
type === 'percentage'
? `${formatNumber(value * 100, decimals)}%`
: formatNumber(value, decimals);
break;
}
}
}
const label = getAggregateOperationLabel(aggregateOperation);
const labelWithFieldName =
aggregateOperation === AGGREGATE_OPERATIONS.count
? `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}`
: `${getAggregateOperationLabel(aggregateOperation)} of ${field.name}`;
@ -55,5 +78,6 @@ export const computeAggregateValueAndLabel = ({
return {
value,
label,
labelWithFieldName,
};
};

View File

@ -39,7 +39,8 @@ const mocks: MockedResponse[] = [
input: {
phones: {
primaryPhoneNumber: '123 456',
primaryPhoneCountryCode: '+1',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
additionalPhones: [],
},
},
@ -134,7 +135,8 @@ describe('usePersistField', () => {
act(() => {
result.current.persistField({
primaryPhoneNumber: '123 456',
primaryPhoneCountryCode: '+1',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
additionalPhones: [],
});
});

View File

@ -208,6 +208,7 @@ const mocks: MockedResponse[] = [
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
position
@ -215,6 +216,7 @@ const mocks: MockedResponse[] = [
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
workPreference

View File

@ -9,12 +9,11 @@ import { TEXT_INPUT_STYLE } from 'twenty-ui';
import { MultiItemFieldInput } from './MultiItemFieldInput';
import { createPhonesFromFieldValue } from '@/object-record/record-field/meta-types/input/utils/phonesUtils';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
export const DEFAULT_PHONE_COUNTRY_CODE = '1';
export const DEFAULT_PHONE_CALLING_CODE = '1';
const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)`
font-family: ${({ theme }) => theme.font.family};
@ -60,22 +59,22 @@ export const PhonesFieldInput = ({
const phones = createPhonesFromFieldValue(fieldValue);
const defaultCallingCode =
stripSimpleQuotesFromString(
fieldDefinition?.defaultValue?.primaryPhoneCountryCode,
) ?? DEFAULT_PHONE_COUNTRY_CODE;
// TODO : improve once we store the real country code
const defaultCountry = useCountries().find(
(obj) => `+${obj.callingCode}` === defaultCallingCode,
)?.countryCode;
const defaultCountry = stripSimpleQuotesFromString(
fieldDefinition?.defaultValue?.primaryPhoneCountryCode,
);
const handlePersistPhones = (
updatedPhones: { number: string; callingCode: string }[],
updatedPhones: {
number: string;
countryCode: string;
callingCode: string;
}[],
) => {
const [nextPrimaryPhone, ...nextAdditionalPhones] = updatedPhones;
persistPhonesField({
primaryPhoneNumber: nextPrimaryPhone?.number ?? '',
primaryPhoneCountryCode: nextPrimaryPhone?.callingCode ?? '',
primaryPhoneCountryCode: nextPrimaryPhone?.countryCode ?? '',
primaryPhoneCallingCode: nextPrimaryPhone?.callingCode ?? '',
additionalPhones: nextAdditionalPhones,
});
};
@ -96,11 +95,13 @@ export const PhonesFieldInput = ({
return {
number: phone.nationalNumber,
callingCode: `+${phone.countryCallingCode}`,
countryCode: phone.country as string,
};
}
return {
number: '',
callingCode: '',
countryCode: '',
};
}}
renderItem={({

View File

@ -19,7 +19,8 @@ describe('createPhonesFromFieldValue test suite', () => {
it('should return an array with primary phone number if it is defined', () => {
const fieldValue: FieldPhonesValue = {
primaryPhoneNumber: '123456789',
primaryPhoneCountryCode: '+1',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
additionalPhones: [],
};
const result = createPhonesFromFieldValue(fieldValue);
@ -27,6 +28,24 @@ describe('createPhonesFromFieldValue test suite', () => {
{
number: '123456789',
callingCode: '+1',
countryCode: 'US',
},
]);
});
it('should return an array with primary phone number if it is defined, even with incorrect callingCode', () => {
const fieldValue: FieldPhonesValue = {
primaryPhoneNumber: '123456789',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+33',
additionalPhones: [],
};
const result = createPhonesFromFieldValue(fieldValue);
expect(result).toEqual([
{
number: '123456789',
callingCode: '+33',
countryCode: 'US',
},
]);
});
@ -34,10 +53,11 @@ describe('createPhonesFromFieldValue test suite', () => {
it('should return an array with both primary and additional phones if they are defined', () => {
const fieldValue: FieldPhonesValue = {
primaryPhoneNumber: '123456789',
primaryPhoneCountryCode: '+1',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
additionalPhones: [
{ number: '987654321', callingCode: '+44' },
{ number: '555555555', callingCode: '+33' },
{ number: '987654321', callingCode: '+44', countryCode: 'GB' },
{ number: '555555555', callingCode: '+33', countryCode: 'FR' },
],
};
const result = createPhonesFromFieldValue(fieldValue);
@ -45,9 +65,10 @@ describe('createPhonesFromFieldValue test suite', () => {
{
number: '123456789',
callingCode: '+1',
countryCode: 'US',
},
{ number: '987654321', callingCode: '+44' },
{ number: '555555555', callingCode: '+33' },
{ number: '987654321', callingCode: '+44', countryCode: 'GB' },
{ number: '555555555', callingCode: '+33', countryCode: 'FR' },
]);
});
@ -56,14 +77,14 @@ describe('createPhonesFromFieldValue test suite', () => {
primaryPhoneNumber: '',
primaryPhoneCountryCode: '',
additionalPhones: [
{ number: '987654321', callingCode: '+44' },
{ number: '555555555', callingCode: '+33' },
{ number: '987654321', callingCode: '+44', countryCode: 'GB' },
{ number: '555555555', callingCode: '+33', countryCode: 'FR' },
],
};
const result = createPhonesFromFieldValue(fieldValue);
expect(result).toEqual([
{ number: '987654321', callingCode: '+44' },
{ number: '555555555', callingCode: '+33' },
{ number: '987654321', callingCode: '+44', countryCode: 'GB' },
{ number: '555555555', callingCode: '+33', countryCode: 'FR' },
]);
});
@ -72,22 +93,34 @@ describe('createPhonesFromFieldValue test suite', () => {
primaryPhoneNumber: ' ',
primaryPhoneCountryCode: '',
additionalPhones: [
{ number: '987654321', callingCode: '+44' },
{ number: '555555555', callingCode: '+33' },
{ number: '987654321', callingCode: '+44', countryCode: 'GB' },
{ number: '555555555', callingCode: '+33', countryCode: 'FR' },
],
};
const result = createPhonesFromFieldValue(fieldValue);
expect(result).toEqual([
{ number: ' ', callingCode: '' },
{ number: '987654321', callingCode: '+44' },
{ number: '555555555', callingCode: '+33' },
{ number: ' ', callingCode: '', countryCode: '' },
{ number: '987654321', callingCode: '+44', countryCode: 'GB' },
{ number: '555555555', callingCode: '+33', countryCode: 'FR' },
]);
});
it('should return an empty array if only country code is defined', () => {
it('should return an empty array if only country and calling code are defined', () => {
const fieldValue: FieldPhonesValue = {
primaryPhoneNumber: '',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
additionalPhones: [],
};
const result = createPhonesFromFieldValue(fieldValue);
expect(result).toEqual([]);
});
it('should return an empty array if only calling code is defined', () => {
const fieldValue: FieldPhonesValue = {
primaryPhoneNumber: '',
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: '',
additionalPhones: [],
};
const result = createPhonesFromFieldValue(fieldValue);

View File

@ -8,7 +8,10 @@ export const createPhonesFromFieldValue = (fieldValue: FieldPhonesValue) => {
fieldValue.primaryPhoneNumber
? {
number: fieldValue.primaryPhoneNumber,
callingCode: fieldValue.primaryPhoneCountryCode,
callingCode: fieldValue.primaryPhoneCallingCode
? fieldValue.primaryPhoneCallingCode
: fieldValue.primaryPhoneCountryCode,
countryCode: fieldValue.primaryPhoneCountryCode,
}
: null,
...(fieldValue.additionalPhones ?? []),

View File

@ -27,6 +27,7 @@ export type FieldDateTimeDraftValue = string;
export type FieldPhonesDraftValue = {
primaryPhoneNumber: string;
primaryPhoneCountryCode: string;
primaryPhoneCallingCode: string;
additionalPhones?: PhoneRecord[] | null;
};
export type FieldEmailsDraftValue = {

View File

@ -268,10 +268,15 @@ export type FieldActorValue = {
export type FieldArrayValue = string[];
export type PhoneRecord = { number: string; callingCode: string };
export type PhoneRecord = {
number: string;
callingCode: string;
countryCode: string;
};
export type FieldPhonesValue = {
primaryPhoneNumber: string;
primaryPhoneCountryCode: string;
primaryPhoneCallingCode?: string;
additionalPhones?: PhoneRecord[] | null;
};

View File

@ -5,8 +5,15 @@ import { FieldPhonesValue } from '../FieldMetadata';
export const phonesSchema = z.object({
primaryPhoneNumber: z.string(),
primaryPhoneCountryCode: z.string(),
primaryPhoneCallingCode: z.string(),
additionalPhones: z
.array(z.object({ number: z.string(), callingCode: z.string() }))
.array(
z.object({
number: z.string(),
callingCode: z.string(),
countryCode: z.string(),
}),
)
.nullable(),
}) satisfies z.ZodType<FieldPhonesValue>;

View File

@ -71,6 +71,9 @@ export const computeDraftValueFromFieldValue = <FieldValue>({
primaryPhoneCountryCode: stripSimpleQuotesFromString(
fieldDefinition?.defaultValue?.primaryPhoneCountryCode,
),
primaryPhoneCallingCode: stripSimpleQuotesFromString(
fieldDefinition?.defaultValue?.primaryPhoneCallingCode,
),
} as unknown as FieldInputDraftValue<FieldValue>;
}

View File

@ -230,6 +230,7 @@ export const RecordIndexContainer = () => {
objectNamePlural={objectNamePlural}
viewBarId={recordIndexId}
/>
<RecordIndexTableContainerEffect />
</SpreadsheetImportProvider>
<RecordIndexFiltersToContextStoreEffect />
{recordIndexViewType === ViewType.Table && (
@ -255,7 +256,9 @@ export const RecordIndexContainer = () => {
<RecordIndexBoardDataLoaderEffect recordBoardId={recordIndexId} />
</StyledContainerWithPadding>
)}
{!isPageHeaderV2Enabled && <RecordIndexActionMenu />}
{!isPageHeaderV2Enabled && (
<RecordIndexActionMenu indexId={recordIndexId} />
)}
</RecordFieldValueSelectorContextProvider>
</StyledContainer>
</>

View File

@ -29,6 +29,8 @@ export const RecordIndexPageHeader = () => {
const recordIndexViewType = useRecoilValue(recordIndexViewTypeState);
const { recordIndexId } = useRecordIndexContextOrThrow();
const numberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
);
@ -64,7 +66,7 @@ export const RecordIndexPageHeader = () => {
{isPageHeaderV2Enabled && (
<>
<RecordIndexActionMenu />
<RecordIndexActionMenu indexId={recordIndexId} />
<PageHeaderOpenCommandMenuButton />
</>
)}

View File

@ -21,6 +21,7 @@ const mockPerson = {
whatsapp: {
primaryPhoneNumber: '+1',
primaryPhoneCountryCode: '234-567-890',
primaryPhoneCallingCode: '+33',
additionalPhones: [],
},
linkedinLink: {

View File

@ -0,0 +1,9 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
export const isRecordIndexLoadMoreLockedComponentState =
createComponentStateV2<boolean>({
key: 'isRecordIndexLoadMoreLockedComponentState',
componentInstanceContext: ViewComponentInstanceContext,
defaultValue: false,
});

View File

@ -4,6 +4,6 @@ import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewCompon
export const recordIndexRecordGroupHideComponentState =
createComponentStateV2<boolean>({
key: 'recordIndexRecordGroupHideComponentState',
defaultValue: true,
defaultValue: false,
componentInstanceContext: ViewComponentInstanceContext,
});

View File

@ -13,7 +13,7 @@ import { RecordTableNoRecordGroupBody } from '@/object-record/record-table/recor
import { RecordTableNoRecordGroupBodyEffect } from '@/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBodyEffect';
import { RecordTableRecordGroupBodyEffects } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffects';
import { RecordTableRecordGroupsBody } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody';
import { RecordTableFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableFooter';
import { RecordTableAggregateFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooter';
import { RecordTableHeader } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState';
import { recordTablePendingRecordIdComponentState } from '@/object-record/record-table/states/recordTablePendingRecordIdComponentState';
@ -88,7 +88,7 @@ export const RecordTable = () => {
<RecordTableEmptyState />
) : (
<>
<StyledTable className="entity-table-cell" ref={tableBodyRef}>
<StyledTable ref={tableBodyRef}>
<RecordTableHeader />
{!hasRecordGroups ? (
<RecordTableNoRecordGroupBody />
@ -97,7 +97,7 @@ export const RecordTable = () => {
)}
<RecordTableStickyEffect />
{isAggregateQueryEnabled && !hasRecordGroups && (
<RecordTableFooter />
<RecordTableAggregateFooter endOfTableSticky />
)}
</StyledTable>
<DragSelect

View File

@ -1,7 +1,6 @@
import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId';
import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector';
import { RecordTableFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableFooter';
import { RecordTablePendingRecordGroupRow } from '@/object-record/record-table/record-table-row/components/RecordTablePendingRecordGroupRow';
import { RecordTableRow } from '@/object-record/record-table/record-table-row/components/RecordTableRow';
import { RecordTableRecordGroupSectionAddNew } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionAddNew';
@ -9,14 +8,10 @@ import { RecordTableRecordGroupSectionLoadMore } from '@/object-record/record-ta
import { isRecordGroupTableSectionToggledComponentState } from '@/object-record/record-table/record-table-section/states/isRecordGroupTableSectionToggledComponentState';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useMemo } from 'react';
import { isDefined } from '~/utils/isDefined';
export const RecordTableRecordGroupRows = () => {
const isAggregateQueryEnabled = useIsFeatureEnabled(
'IS_AGGREGATE_QUERY_ENABLED',
);
const currentRecordGroupId = useCurrentRecordGroupId();
const allRecordIds = useRecoilComponentValueV2(
@ -63,12 +58,6 @@ export const RecordTableRecordGroupRows = () => {
})}
<RecordTablePendingRecordGroupRow />
<RecordTableRecordGroupSectionAddNew />
{isAggregateQueryEnabled && (
<RecordTableFooter
key={currentRecordGroupId}
currentRecordGroupId={currentRecordGroupId}
/>
)}
<RecordTableRecordGroupSectionLoadMore />
</>
);

View File

@ -663,7 +663,8 @@ export const mockPerformance = {
id: '20202020-2d40-4e49-8df4-9c6a049191df',
email: 'lorie.vladim@google.com',
phones: {
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
primaryPhoneNumber: '788901235',
},
linkedinLink: {

View File

@ -15,6 +15,7 @@ export const RecordTableBodyDroppable = ({
isDropDisabled,
}: RecordTableBodyDroppableProps) => {
const [v4Persistable] = useState(v4());
const recordTableBodyId = `record-table-body${recordGroupId ? '-' + recordGroupId : ''}`;
return (
<Droppable
@ -23,7 +24,7 @@ export const RecordTableBodyDroppable = ({
>
{(provided) => (
<RecordTableBody
id="record-table-body"
id={recordTableBodyId}
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}

View File

@ -4,6 +4,7 @@ import { useInView } from 'react-intersection-observer';
import { useRecoilCallback } from 'recoil';
import { GRAY_SCALE } from 'twenty-ui';
import { isRecordIndexLoadMoreLockedComponentState } from '@/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { hasRecordTableFetchedAllRecordsComponentStateV2 } from '@/object-record/record-table/states/hasRecordTableFetchedAllRecordsComponentStateV2';
import { RecordTableWithWrappersScrollWrapperContext } from '@/ui/utilities/scroll/contexts/ScrollWrapperContexts';
@ -22,11 +23,19 @@ const StyledText = styled.div`
export const RecordTableBodyFetchMoreLoader = () => {
const { setRecordTableLastRowVisible } = useRecordTable();
const isRecordTableLoadMoreLocked = useRecoilComponentValueV2(
isRecordIndexLoadMoreLockedComponentState,
);
const onLastRowVisible = useRecoilCallback(
() => async (inView: boolean) => {
if (isRecordTableLoadMoreLocked) {
return;
}
setRecordTableLastRowVisible(inView);
},
[setRecordTableLastRowVisible],
[setRecordTableLastRowVisible, isRecordTableLoadMoreLocked],
);
const scrollWrapperRef = useContext(
@ -37,7 +46,8 @@ export const RecordTableBodyFetchMoreLoader = () => {
hasRecordTableFetchedAllRecordsComponentStateV2,
);
const showLoadingMoreRow = !hasRecordTableFetchedAllRecordsComponents;
const showLoadingMoreRow =
!hasRecordTableFetchedAllRecordsComponents && !isRecordTableLoadMoreLocked;
const { ref: tbodyRef } = useInView({
onChange: onLastRowVisible,

View File

@ -6,12 +6,17 @@ import { RecordTableRecordGroupRows } from '@/object-record/record-table/compone
import { RecordTableBodyDroppable } from '@/object-record/record-table/record-table-body/components/RecordTableBodyDroppable';
import { RecordTableBodyLoading } from '@/object-record/record-table/record-table-body/components/RecordTableBodyLoading';
import { RecordTableBodyRecordGroupDragDropContextProvider } from '@/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider';
import { RecordTableAggregateFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooter';
import { RecordTableRecordGroupEmptyRow } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupEmptyRow';
import { RecordTableRecordGroupSection } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection';
import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const RecordTableRecordGroupsBody = () => {
const isAggregateQueryEnabled = useIsFeatureEnabled(
'IS_AGGREGATE_QUERY_ENABLED',
);
const allRecordIds = useRecoilComponentValueV2(
recordIndexAllRecordIdsComponentSelector,
);
@ -29,21 +34,29 @@ export const RecordTableRecordGroupsBody = () => {
}
return (
<RecordTableBodyRecordGroupDragDropContextProvider>
{visibleRecordGroupIds.map((recordGroupId, index) => (
<RecordTableRecordGroupBodyContextProvider
key={recordGroupId}
recordGroupId={recordGroupId}
>
<RecordGroupContext.Provider value={{ recordGroupId }}>
<RecordTableBodyDroppable recordGroupId={recordGroupId}>
{index > 0 && <RecordTableRecordGroupEmptyRow />}
<RecordTableRecordGroupSection />
<RecordTableRecordGroupRows />
</RecordTableBodyDroppable>
</RecordGroupContext.Provider>
</RecordTableRecordGroupBodyContextProvider>
))}
</RecordTableBodyRecordGroupDragDropContextProvider>
<>
<RecordTableBodyRecordGroupDragDropContextProvider>
{visibleRecordGroupIds.map((recordGroupId, index) => (
<RecordTableRecordGroupBodyContextProvider
key={recordGroupId}
recordGroupId={recordGroupId}
>
<RecordGroupContext.Provider value={{ recordGroupId }}>
<RecordTableBodyDroppable recordGroupId={recordGroupId}>
{index > 0 && <RecordTableRecordGroupEmptyRow />}
<RecordTableRecordGroupSection />
<RecordTableRecordGroupRows />
</RecordTableBodyDroppable>
{isAggregateQueryEnabled && (
<RecordTableAggregateFooter
key={recordGroupId}
currentRecordGroupId={recordGroupId}
/>
)}
</RecordGroupContext.Provider>
</RecordTableRecordGroupBodyContextProvider>
))}
</RecordTableBodyRecordGroupDragDropContextProvider>
</>
);
};

View File

@ -1,17 +1,16 @@
import styled from '@emotion/styled';
import { MOBILE_VIEWPORT } from 'twenty-ui';
import { TABLE_CELL_CHECKBOX_MIN_WIDTH } from '@/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox';
import { TABLE_CELL_GRIP_WIDTH } from '@/object-record/record-table/record-table-cell/components/RecordTableCellGrip';
import { RecordTableFooterCell } from '@/object-record/record-table/record-table-footer/components/RecordTableFooterCell';
import { RecordTableAggregateFooterCell } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooterCell';
import { FIRST_TH_WIDTH } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
const StyledTableFoot = styled.thead`
const StyledTableFoot = styled.thead<{ endOfTableSticky?: boolean }>`
cursor: pointer;
th:nth-of-type(1) {
width: 9px;
width: ${FIRST_TH_WIDTH};
left: 0;
border-right-color: ${({ theme }) => theme.background.primary};
}
@ -59,31 +58,23 @@ const StyledTableFoot = styled.thead`
}
}
&.header-sticky {
th {
position: sticky;
top: 0;
z-index: 5;
}
}
&.header-sticky.first-columns-sticky {
th:nth-of-type(1),
th:nth-of-type(2),
th:nth-of-type(3) {
z-index: 10;
}
tr {
position: sticky;
z-index: 5;
${({ endOfTableSticky }) => endOfTableSticky && `bottom: 0;`}
}
`;
const StyledDiv = styled.div`
width: calc(${TABLE_CELL_GRIP_WIDTH} + ${TABLE_CELL_CHECKBOX_MIN_WIDTH});
const StyledTh = styled.th`
background-color: ${({ theme }) => theme.background.primary};
`;
export const RecordTableFooter = ({
export const RecordTableAggregateFooter = ({
currentRecordGroupId,
endOfTableSticky,
}: {
currentRecordGroupId?: string;
endOfTableSticky?: boolean;
}) => {
const visibleTableColumns = useRecoilComponentValueV2(
visibleTableColumnsComponentSelector,
@ -93,12 +84,13 @@ export const RecordTableFooter = ({
<StyledTableFoot
id={`record-table-footer${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`}
data-select-disable
endOfTableSticky={endOfTableSticky}
>
<tr>
<th />
<StyledDiv />
<StyledTh />
<StyledTh />
{visibleTableColumns.map((column, index) => (
<RecordTableFooterCell
<RecordTableAggregateFooterCell
key={`${column.fieldMetadataId}${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`}
column={column}
currentRecordGroupId={currentRecordGroupId}

View File

@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import { useMemo } from 'react';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordTableColumnFooterWithDropdown } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnFooterWithDropdown';
import { RecordTableColumnFooterWithDropdown } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterWithDropdown';
import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -61,7 +61,7 @@ const StyledColumnFootContainer = styled.div`
width: 100%;
`;
export const RecordTableFooterCell = ({
export const RecordTableAggregateFooterCell = ({
column,
isFirstCell = false,
currentRecordGroupId,

View File

@ -14,7 +14,7 @@ import { useMemo } from 'react';
import { Key } from 'ts-key-enum';
import { MenuItem } from 'twenty-ui';
export const RecordTableColumnFooterDropdown = ({
export const RecordTableColumnAggregateFooterDropdown = ({
column,
dropdownId,
}: {
@ -30,10 +30,6 @@ export const RecordTableColumnFooterDropdown = ({
(viewField) => viewField.fieldMetadataId === column.fieldMetadataId,
);
if (!currentViewField) {
throw new Error('ViewField not found');
}
useScopedHotkeys(
[Key.Escape],
() => {
@ -56,6 +52,9 @@ export const RecordTableColumnFooterDropdown = ({
const handleAggregationChange = (
aggregateOperation: AGGREGATE_OPERATIONS,
) => {
if (!currentViewField) {
throw new Error('ViewField not found');
}
updateViewFieldRecords([
{ ...currentViewField, aggregateOperation: aggregateOperation },
]);

View File

@ -1,12 +1,7 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import {
AppTooltip,
IconChevronDown,
isDefined,
TooltipDelay,
} from 'twenty-ui';
import { IconChevronDown, isDefined } from 'twenty-ui';
const StyledCell = styled.div`
align-items: center;
@ -37,6 +32,27 @@ const StyledText = styled.span`
z-index: 1;
`;
const StyledValueContainer = styled.div`
align-items: center;
display: flex;
flex: 1 0 0;
gap: 4px;
height: 32px;
justify-content: flex-end;
padding: 8px;
`;
const StyledLabel = styled.div`
align-items: center;
display: flex;
gap: 4px;
`;
const StyledValue = styled.div`
color: ${({ theme }) => theme.color.gray60};
flex: 1 0 0;
`;
const StyledIcon = styled(IconChevronDown)`
align-items: center;
display: flex;
@ -46,7 +62,7 @@ const StyledIcon = styled(IconChevronDown)`
padding-right: ${({ theme }) => theme.spacing(2)};
`;
export const RecordTableColumnFooterAggregateValue = ({
export const RecordTableColumnAggregateFooterValue = ({
dropdownId,
aggregateValue,
aggregateLabel,
@ -70,20 +86,15 @@ export const RecordTableColumnFooterAggregateValue = ({
<StyledCell>
{isHovered || isDefined(aggregateValue) || isFirstCell ? (
<>
<StyledText id={sanitizedId}>
{aggregateValue ?? 'Calculate'}
</StyledText>
<StyledIcon fontWeight={'light'} size={theme.icon.size.sm} />
{isDefined(aggregateValue) && isDefined(aggregateLabel) && (
<AppTooltip
anchorSelect={`#${sanitizedId}`}
content={aggregateLabel}
noArrow
place="top-start"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
{isDefined(aggregateValue) ? (
<StyledValueContainer>
<StyledLabel>{aggregateLabel}</StyledLabel>
<StyledValue>{aggregateValue}</StyledValue>
</StyledValueContainer>
) : (
<StyledText id={sanitizedId}>Calculate</StyledText>
)}
<StyledIcon fontWeight={'light'} size={theme.icon.size.sm} />
</>
) : (
<></>

View File

@ -1,6 +1,6 @@
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordTableColumnFooterAggregateValue } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnFooterAggregateValue';
import { RecordTableColumnFooterDropdown } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnFooterDropdown';
import { RecordTableColumnAggregateFooterDropdown } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdown';
import { RecordTableColumnAggregateFooterValue } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValue';
import { useAggregateRecordsForRecordTableColumnFooter } from '@/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
@ -44,7 +44,7 @@ export const RecordTableColumnFooterWithDropdown = ({
onClose={handleDropdownClose}
dropdownId={dropdownId}
clickableComponent={
<RecordTableColumnFooterAggregateValue
<RecordTableColumnAggregateFooterValue
aggregateLabel={aggregateLabel}
aggregateValue={aggregateValue}
dropdownId={dropdownId}
@ -52,7 +52,7 @@ export const RecordTableColumnFooterWithDropdown = ({
/>
}
dropdownComponents={
<RecordTableColumnFooterDropdown
<RecordTableColumnAggregateFooterDropdown
column={column}
dropdownId={dropdownId}
/>

View File

@ -32,13 +32,10 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
recordIndexViewFilterGroups,
);
const viewFieldId = currentViewWithSavedFiltersAndSorts?.viewFields?.find(
(viewField) => viewField.fieldMetadataId === fieldMetadataId,
)?.id;
if (!viewFieldId) {
throw new Error('ViewField not found');
}
const viewFieldId =
currentViewWithSavedFiltersAndSorts?.viewFields?.find(
(viewField) => viewField.fieldMetadataId === fieldMetadataId,
)?.id ?? '';
const aggregateOperationForViewField = useRecoilValue(
aggregateOperationForViewFieldState({ viewFieldId: viewFieldId }),

View File

@ -7,11 +7,13 @@ import { RecordTableHeaderCheckboxColumn } from '@/object-record/record-table/re
import { RecordTableHeaderDragDropColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderDragDropColumn';
import { RecordTableHeaderLastColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderLastColumn';
export const FIRST_TH_WIDTH = '9px';
const StyledTableHead = styled.thead`
cursor: pointer;
th:nth-of-type(1) {
width: 9px;
width: ${FIRST_TH_WIDTH};
left: 0;
border-right-color: ${({ theme }) => theme.background.primary};
}

View File

@ -53,7 +53,7 @@ export const RecordTableHeaderPlusButtonContent = () => {
))}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer withoutScrollWrapper>
<DropdownMenuItemsContainer scrollable={false}>
<UndecoratedLink
fullWidth
to={`/settings/objects/${getObjectSlug(objectMetadataItem)}`}

View File

@ -9,6 +9,7 @@ import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/s
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
import { RecordTableRecordGroupStickyEffect } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupStickyEffect';
import { isRecordGroupTableSectionToggledComponentState } from '@/object-record/record-table/record-table-section/states/isRecordGroupTableSectionToggledComponentState';
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2';
@ -107,6 +108,7 @@ export const RecordTableRecordGroupSection = () => {
weight="medium"
/>
<StyledTotalRow>{recordIdsByGroup.length}</StyledTotalRow>
<RecordTableRecordGroupStickyEffect />
</StyledRecordGroupSection>
<StyledEmptyTd colSpan={visibleColumns.length - 1} />
<StyledEmptyTd />

View File

@ -1,5 +1,6 @@
import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId';
import { useLazyLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLazyLoadRecordIndexTable';
import { isRecordIndexLoadMoreLockedComponentState } from '@/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState';
import { recordIndexHasFetchedAllRecordsByGroupComponentState } from '@/object-record/record-index/states/recordIndexHasFetchedAllRecordsByGroupComponentState';
import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
@ -20,6 +21,10 @@ export const RecordTableRecordGroupSectionLoadMore = () => {
currentRecordGroupId,
);
const isLoadMoreLocked = useRecoilComponentValueV2(
isRecordIndexLoadMoreLockedComponentState,
);
const recordIds = useRecoilComponentValueV2(
recordIndexAllRecordIdsComponentSelector,
);
@ -28,7 +33,7 @@ export const RecordTableRecordGroupSectionLoadMore = () => {
fetchMoreRecords();
};
if (hasFetchedAllRecords) {
if (hasFetchedAllRecords || isLoadMoreLocked) {
return null;
}

View File

@ -0,0 +1,48 @@
import { useEffect } from 'react';
import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId';
import { isRecordTableScrolledLeftComponentState } from '@/object-record/record-table/states/isRecordTableScrolledLeftComponentState';
import { scrollWrapperScrollLeftComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollLeftComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
export const RecordTableRecordGroupStickyEffect = () => {
const scrollLeft = useRecoilComponentValueV2(
scrollWrapperScrollLeftComponentState,
);
const setIsRecordTableScrolledLeft = useSetRecoilComponentStateV2(
isRecordTableScrolledLeftComponentState,
);
const currentRecordGroupId = useCurrentRecordGroupId();
useEffect(() => {
setIsRecordTableScrolledLeft(scrollLeft === 0);
if (scrollLeft > 0) {
document
.getElementById(
`record-table-footer${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`,
)
?.classList.add('first-columns-sticky');
document
.getElementById(
`record-table-body${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`,
)
?.classList.add('first-columns-sticky');
} else {
document
.getElementById(
`record-table-footer${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`,
)
?.classList.remove('first-columns-sticky');
document
.getElementById(
`record-table-body${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`,
)
?.classList.remove('first-columns-sticky');
}
}, [currentRecordGroupId, scrollLeft, setIsRecordTableScrolledLeft]);
return <></>;
};

View File

@ -144,7 +144,7 @@ export const MultiRecordSelect = ({
{dropdownPlacement?.includes('end') && (
<>
{isDefined(onCreate) && (
<DropdownMenuItemsContainer>
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
)}
@ -181,7 +181,7 @@ export const MultiRecordSelect = ({
<DropdownMenuSeparator />
)}
{isDefined(onCreate) && (
<DropdownMenuItemsContainer withoutScrollWrapper>
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
)}

View File

@ -69,7 +69,7 @@ export const SingleRecordSelectMenuItemsWithSearch = ({
<>
{dropdownPlacement?.includes('end') && (
<>
<DropdownMenuItemsContainer>
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
{records.recordsToSelect.length > 0 && <DropdownMenuSeparator />}
@ -117,7 +117,7 @@ export const SingleRecordSelectMenuItemsWithSearch = ({
<DropdownMenuSeparator />
)}
{isDefined(onCreate) && (
<DropdownMenuItemsContainer>
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
)}

View File

@ -207,6 +207,7 @@ const companyMocks = [
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
position
@ -214,6 +215,7 @@ const companyMocks = [
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
workPreference

View File

@ -95,6 +95,7 @@ export const generateEmptyFieldValue = (
return {
primaryPhoneNumber: '',
primaryPhoneCountryCode: '',
primaryPhoneCallingCode: '',
additionalPhones: null,
};
}

View File

@ -91,7 +91,9 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
exampleValue: {
primaryPhoneNumber: '234-567-890',
primaryPhoneCountryCode: '+1',
additionalPhones: [{ number: '234-567-890', callingCode: '+1' }],
additionalPhones: [
{ number: '234-567-890', callingCode: '+1', countryCode: 'US' },
],
},
subFields: [
'primaryPhoneNumber',
@ -102,6 +104,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
labelBySubField: {
primaryPhoneNumber: 'Primary Phone Number',
primaryPhoneCountryCode: 'Primary Phone Country Code',
primaryPhoneCallingCode: 'Primary Phone Calling Code',
additionalPhones: 'Additional Phones',
},
category: 'Basic',

View File

@ -3,8 +3,10 @@ import { Controller, useFormContext } from 'react-hook-form';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { phonesSchema as phonesFieldDefaultValueSchema } from '@/object-record/record-field/types/guards/isFieldPhonesValue';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { countryCodeToCallingCode } from '@/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
import { Select } from '@/ui/input/components/Select';
import { CountryCode } from 'libphonenumber-js';
import { IconMap } from 'twenty-ui';
import { z } from 'zod';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
@ -27,22 +29,27 @@ export type SettingsDataModelFieldTextFormValues = z.infer<
typeof settingsDataModelFieldPhonesFormSchema
>;
export type CountryCodeOrEmpty = CountryCode | '';
export const SettingsDataModelFieldPhonesForm = ({
disabled,
fieldMetadataItem,
}: SettingsDataModelFieldPhonesFormProps) => {
const { control } = useFormContext<SettingsDataModelFieldTextFormValues>();
const countries = useCountries()
.sort((a, b) => a.countryName.localeCompare(b.countryName))
.map((country) => ({
label: `${country.countryName} (+${country.callingCode})`,
value: `+${country.callingCode}`,
}));
countries.unshift({ label: 'No country', value: '' });
const countries = [
{ label: 'No country', value: '' },
...useCountries()
.sort((a, b) => a.countryName.localeCompare(b.countryName))
.map((country) => ({
label: `${country.countryName} (+${country.callingCode})`,
value: country.countryCode as CountryCodeOrEmpty,
})),
];
const defaultDefaultValue = {
primaryPhoneNumber: "''",
primaryPhoneCountryCode: "''",
primaryPhoneCallingCode: "''",
additionalPhones: null,
};
const fieldMetadataItemDefaultValue = fieldMetadataItem?.defaultValue;
@ -73,6 +80,9 @@ export const SettingsDataModelFieldPhonesForm = ({
...value,
primaryPhoneCountryCode:
applySimpleQuotesToString(newPhoneCountryCode),
primaryPhoneCallingCode: applySimpleQuotesToString(
countryCodeToCallingCode(newPhoneCountryCode),
),
})
}
disabled={disabled}

View File

@ -8,7 +8,6 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useMemo } from 'react';
import {
ColorSample,
IconCheck,
@ -21,7 +20,6 @@ import {
MenuItem,
MenuItemSelectColor,
} from 'twenty-ui';
import { v4 } from 'uuid';
import { computeOptionValueFromLabel } from '~/pages/settings/data-model/utils/compute-option-value-from-label.utils';
type SettingsDataModelFieldSelectFormOptionRowProps = {
@ -81,17 +79,14 @@ export const SettingsDataModelFieldSelectFormOptionRow = ({
}: SettingsDataModelFieldSelectFormOptionRowProps) => {
const theme = useTheme();
const dropdownIds = useMemo(() => {
const baseScopeId = `select-field-option-row-${v4()}`;
return {
color: `${baseScopeId}-color`,
actions: `${baseScopeId}-actions`,
};
}, []);
const SELECT_COLOR_DROPDOWN_ID = 'select-color-dropdown';
const SELECT_ACTIONS_DROPDOWN_ID = 'select-actions-dropdown';
const { closeDropdown: closeColorDropdown } = useDropdown(dropdownIds.color);
const { closeDropdown: closeColorDropdown } = useDropdown(
SELECT_COLOR_DROPDOWN_ID,
);
const { closeDropdown: closeActionsDropdown } = useDropdown(
dropdownIds.actions,
SELECT_ACTIONS_DROPDOWN_ID,
);
const handleInputEnter = () => {
@ -120,28 +115,26 @@ export const SettingsDataModelFieldSelectFormOptionRow = ({
/>
</AdvancedSettingsWrapper>
<Dropdown
dropdownId={dropdownIds.color}
dropdownId={SELECT_COLOR_DROPDOWN_ID}
dropdownPlacement="bottom-start"
dropdownHotkeyScope={{
scope: dropdownIds.color,
scope: SELECT_COLOR_DROPDOWN_ID,
}}
clickableComponent={<StyledColorSample colorName={option.color} />}
dropdownComponents={
<DropdownMenu>
<DropdownMenuItemsContainer>
{MAIN_COLOR_NAMES.map((colorName) => (
<MenuItemSelectColor
key={colorName}
onClick={() => {
onChange({ ...option, color: colorName });
closeColorDropdown();
}}
color={colorName}
selected={colorName === option.color}
/>
))}
</DropdownMenuItemsContainer>
</DropdownMenu>
<DropdownMenuItemsContainer>
{MAIN_COLOR_NAMES.map((colorName) => (
<MenuItemSelectColor
key={colorName}
onClick={() => {
onChange({ ...option, color: colorName });
closeColorDropdown();
}}
color={colorName}
selected={colorName === option.color}
/>
))}
</DropdownMenuItemsContainer>
}
/>
<StyledOptionInput
@ -165,10 +158,10 @@ export const SettingsDataModelFieldSelectFormOptionRow = ({
autoSelectOnMount={isNewRow}
/>
<Dropdown
dropdownId={dropdownIds.actions}
dropdownId={SELECT_ACTIONS_DROPDOWN_ID}
dropdownPlacement="right-start"
dropdownHotkeyScope={{
scope: dropdownIds.actions,
scope: SELECT_ACTIONS_DROPDOWN_ID,
}}
clickableComponent={
<StyledLightIconButton accent="tertiary" Icon={IconDotsVertical} />

View File

@ -1,9 +1,29 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { DEFAULT_PHONE_CALLING_CODE } from '@/object-record/record-field/meta-types/input/components/PhonesFieldInput';
import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata';
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
import {
CountryCode,
getCountries,
getCountryCallingCode,
} from 'libphonenumber-js';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
const isStrCountryCodeGuard = (str: string): str is CountryCode => {
return getCountries().includes(str as CountryCode);
};
export const countryCodeToCallingCode = (countryCode: string): string => {
if (!countryCode || !isStrCountryCodeGuard(countryCode)) {
return `+${DEFAULT_PHONE_CALLING_CODE}`;
}
const callingCode = getCountryCallingCode(countryCode);
return callingCode ? `+${callingCode}` : `+${DEFAULT_PHONE_CALLING_CODE}`;
};
export const getPhonesFieldPreviewValue = ({
fieldMetadataItem,
}: {
@ -26,8 +46,16 @@ export const getPhonesFieldPreviewValue = ({
fieldMetadataItem.defaultValue?.primaryPhoneCountryCode,
)
: null;
const primaryPhoneCallingCode =
fieldMetadataItem.defaultValue?.primaryPhoneCallingCode &&
fieldMetadataItem.defaultValue.primaryPhoneCallingCode !== ''
? stripSimpleQuotesFromString(
fieldMetadataItem.defaultValue?.primaryPhoneCallingCode,
)
: null;
return {
...placeholderDefaultValue,
primaryPhoneCountryCode,
primaryPhoneCallingCode,
};
};

View File

@ -16,7 +16,6 @@ import { SettingsSummaryCard } from '@/settings/components/SettingsSummaryCard';
import { SettingsDataModelObjectTypeTag } from '@/settings/data-model/objects/components/SettingsDataModelObjectTypeTag';
import { getObjectTypeLabel } from '@/settings/data-model/utils/getObjectTypeLabel';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
@ -86,21 +85,20 @@ export const SettingsObjectSummaryCard = ({
accent="tertiary"
/>
}
dropdownMenuWidth={160}
dropdownComponents={
<DropdownMenu width="160px">
<DropdownMenuItemsContainer>
<MenuItem
text="Edit"
LeftIcon={IconPencil}
onClick={handleEdit}
/>
<MenuItem
text="Deactivate"
LeftIcon={IconArchive}
onClick={handleDeactivate}
/>
</DropdownMenuItemsContainer>
</DropdownMenu>
<DropdownMenuItemsContainer>
<MenuItem
text="Edit"
LeftIcon={IconPencil}
onClick={handleEdit}
/>
<MenuItem
text="Deactivate"
LeftIcon={IconArchive}
onClick={handleDeactivate}
/>
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{
scope: dropdownId,

View File

@ -1,7 +1,6 @@
import { SettingsSummaryCard } from '@/settings/components/SettingsSummaryCard';
import { SettingsIntegrationDatabaseConnectionSyncStatus } from '@/settings/integrations/database-connection/components/SettingsIntegrationDatabaseConnectionSyncStatus';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import styled from '@emotion/styled';
import {
@ -64,18 +63,16 @@ export const SettingsIntegrationDatabaseConnectionSummaryCard = ({
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownComponents={
<DropdownMenu>
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconTrash}
text="Remove"
onClick={onRemove}
/>
<UndecoratedLink to="./edit">
<MenuItem LeftIcon={IconPencil} text="Edit" />
</UndecoratedLink>
</DropdownMenuItemsContainer>
</DropdownMenu>
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconTrash}
text="Remove"
onClick={onRemove}
/>
<UndecoratedLink to="./edit">
<MenuItem LeftIcon={IconPencil} text="Edit" />
</UndecoratedLink>
</DropdownMenuItemsContainer>
}
/>
</>

View File

@ -5,6 +5,7 @@ import { RoundedLink, THEME_COMMON } from 'twenty-ui';
import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { DEFAULT_PHONE_CALLING_CODE } from '@/object-record/record-field/meta-types/input/components/PhonesFieldInput';
import { parsePhoneNumber } from 'libphonenumber-js';
import { isDefined } from '~/utils/isDefined';
import { logError } from '~/utils/logError';
@ -36,7 +37,10 @@ export const PhonesDisplay = ({ value, isFocused }: PhonesDisplayProps) => {
value?.primaryPhoneNumber
? {
number: value.primaryPhoneNumber,
callingCode: value.primaryPhoneCountryCode,
callingCode:
value.primaryPhoneCallingCode ||
value.primaryPhoneCountryCode ||
`+${DEFAULT_PHONE_CALLING_CODE}`,
}
: null,
...parseAdditionalPhones(value?.additionalPhones),
@ -50,11 +54,11 @@ export const PhonesDisplay = ({ value, isFocused }: PhonesDisplayProps) => {
}),
[
value?.primaryPhoneNumber,
value?.primaryPhoneCallingCode,
value?.primaryPhoneCountryCode,
value?.additionalPhones,
],
);
const parsePhoneNumberOrReturnInvalidValue = (number: string) => {
try {
return { parsedPhone: parsePhoneNumber(number) };

View File

@ -165,7 +165,7 @@ export const Select = <Value extends SelectValue>({
<DropdownMenuSeparator />
)}
{!!callToActionButton && (
<DropdownMenuItemsContainer hasMaxHeight withoutScrollWrapper>
<DropdownMenuItemsContainer hasMaxHeight scrollable={false}>
<MenuItem
onClick={callToActionButton.onClick}
LeftIcon={callToActionButton.Icon}

View File

@ -38,16 +38,16 @@ export const DropdownMenuItemsContainer = ({
children,
hasMaxHeight,
className,
withoutScrollWrapper,
scrollable = true,
}: {
children: React.ReactNode;
hasMaxHeight?: boolean;
className?: string;
withoutScrollWrapper?: boolean;
scrollable?: boolean;
}) => {
const id = useId();
return withoutScrollWrapper === true ? (
return scrollable !== true ? (
<StyledDropdownMenuItemsExternalContainer
hasMaxHeight={hasMaxHeight}
className={className}

View File

@ -1,9 +1,11 @@
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { RecordAgnosticActionsSetterEffect } from '@/action-menu/actions/record-agnostic-actions/components/RecordAgnosticActionsSetterEffect';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { AuthModal } from '@/auth/components/AuthModal';
import { CommandMenu } from '@/command-menu/components/CommandMenu';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenu';
@ -75,6 +77,7 @@ export const DefaultLayout = () => {
const theme = useTheme();
const windowsWidth = useScreenSize().width;
const showAuthModal = useShowAuthModal();
const { toggleCommandMenu } = useCommandMenu();
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
@ -96,10 +99,17 @@ export const DefaultLayout = () => {
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: 'command-menu' }}
>
<RecordActionMenuEntriesSetter />
{isWorkflowEnabled && <RecordAgnosticActionsSetterEffect />}
<ActionMenuConfirmationModals />
<CommandMenu />
<ActionMenuContext.Provider
value={{
isInRightDrawer: false,
onActionExecutedCallback: toggleCommandMenu,
}}
>
<RecordActionMenuEntriesSetter />
{isWorkflowEnabled && <RecordAgnosticActionsSetterEffect />}
<ActionMenuConfirmationModals />
<CommandMenu />
</ActionMenuContext.Provider>
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
<KeyboardShortcutMenu />

View File

@ -19,7 +19,6 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { isWorkflowSubObjectMetadata } from '@/object-metadata/utils/isWorkflowSubObjectMetadata';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { Dropdown } from '../../dropdown/components/Dropdown';
import { DropdownMenu } from '../../dropdown/components/DropdownMenu';
const StyledContainer = styled.div`
z-index: 1;
@ -93,22 +92,20 @@ export const ShowPageAddButton = ({
)
}
dropdownComponents={
<DropdownMenu>
<DropdownMenuItemsContainer>
<MenuItem
onClick={() => handleSelect(CoreObjectNameSingular.Note)}
accent="default"
LeftIcon={IconNotes}
text="Note"
/>
<MenuItem
onClick={() => handleSelect(CoreObjectNameSingular.Task)}
accent="default"
LeftIcon={IconCheckbox}
text="Task"
/>
</DropdownMenuItemsContainer>
</DropdownMenu>
<DropdownMenuItemsContainer>
<MenuItem
onClick={() => handleSelect(CoreObjectNameSingular.Note)}
accent="default"
LeftIcon={IconNotes}
text="Note"
/>
<MenuItem
onClick={() => handleSelect(CoreObjectNameSingular.Task)}
accent="default"
LeftIcon={IconCheckbox}
text="Task"
/>
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{
scope: PageHotkeyScope.ShowPage,

View File

@ -7,13 +7,13 @@ import { SummaryCard } from '@/object-record/record-show/components/SummaryCard'
import { RecordLayout } from '@/object-record/record-show/types/RecordLayout';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer';
import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
const StyledShowPageRightContainer = styled.div<{ isMobile: boolean }>`
display: flex;

View File

@ -19,6 +19,7 @@ export type ContextProviderName =
| 'test'
| 'showPageActivityContainer'
| 'navigationDrawer'
| 'aggregateFooterCell'
| 'modalContent';
const createScrollWrapperContext = (id: string) =>
@ -52,6 +53,8 @@ export const ShowPageActivityContainerScrollWrapperContext =
export const NavigationDrawerScrollWrapperContext =
createScrollWrapperContext('navigationDrawer');
export const TestScrollWrapperContext = createScrollWrapperContext('test');
export const AggregateFooterCellScrollWrapperContext =
createScrollWrapperContext('aggregateFooterCell');
export const ModalContentScrollWrapperContext =
createScrollWrapperContext('modalContent');
@ -85,6 +88,8 @@ export const getContextByProviderName = (
return ShowPageActivityContainerScrollWrapperContext;
case 'navigationDrawer':
return NavigationDrawerScrollWrapperContext;
case 'aggregateFooterCell':
return AggregateFooterCellScrollWrapperContext;
case 'modalContent':
return ModalContentScrollWrapperContext;
default:

View File

@ -112,15 +112,13 @@ export const UpdateViewButtonGroup = ({
/>
}
dropdownComponents={
<>
<DropdownMenuItemsContainer>
<MenuItem
onClick={handleCreateViewClick}
LeftIcon={IconPlus}
text="Create view"
/>
</DropdownMenuItemsContainer>
</>
<DropdownMenuItemsContainer>
<MenuItem
onClick={handleCreateViewClick}
LeftIcon={IconPlus}
text="Create view"
/>
</DropdownMenuItemsContainer>
}
/>
</ButtonGroup>

View File

@ -190,7 +190,7 @@ export const ViewPickerContentCreateMode = () => {
)}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<DropdownMenuItemsContainer scrollable={false}>
<ViewPickerSaveButtonContainer>
<ViewPickerCreateButton />
</ViewPickerSaveButtonContainer>

View File

@ -89,7 +89,7 @@ export const ViewPickerContentEditMode = () => {
</ViewPickerIconAndNameContainer>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<DropdownMenuItemsContainer scrollable={false}>
<ViewPickerSaveButtonContainer>
<ViewPickerEditButton />
</ViewPickerSaveButtonContainer>

View File

@ -97,7 +97,7 @@ export const ViewPickerListContent = () => {
/>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<StyledBoldDropdownMenuItemsContainer>
<StyledBoldDropdownMenuItemsContainer scrollable={false}>
<MenuItem
onClick={handleAddViewButtonClick}
LeftIcon={IconPlus}

View File

@ -19461,7 +19461,8 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"defaultValue": {
"additionalPhones": null,
"primaryPhoneNumber": "''",
"primaryPhoneCountryCode": "''"
"primaryPhoneCountryCode": "''",
"primaryPhoneCallingCode": "''"
},
"options": null,
"isLabelSyncedWithName": false,
@ -19740,7 +19741,8 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
{
"additionalPhones": {},
"primaryPhoneNumber": "",
"primaryPhoneCountryCode": ""
"primaryPhoneCountryCode": "",
"primaryPhoneCallingCode": ""
}
],
"options": null,

View File

@ -22,6 +22,7 @@ export const mockedEmptyPersonData = {
xUrl: null,
_activityCount: null,
company: null,
deletedAt: null,
__typename: 'Person',
};
@ -43,11 +44,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzAsICJkYTNjMmM0Yi1kYTAxLTRiODEtOTczNC0yMjYwNjllYjRjZDAiXQ==',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:52:46.814Z',
city: 'ASd',
phones: {
primaryPhoneNumber: '781234562',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: 'da3c2c4b-da01-4b81-9734-226069eb4cd0',
jobTitle: '',
@ -173,11 +176,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzEsICIyMDIwMjAyMC0xYzBlLTQ5NGMtYTFiNi04NWIxYzZmZWZhYTUiXQ==',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-01T09:50:00.000Z',
city: 'Seattle',
phones: {
primaryPhoneNumber: '781234562',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-1c0e-494c-a1b6-85b1c6fefaa5',
jobTitle: '',
@ -303,11 +308,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzIsICIyMDIwMjAyMC1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'Los Angeles',
phones: {
primaryPhoneNumber: '781234576',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-ac73-4797-824e-87a1f5aea9e0',
jobTitle: '',
@ -402,11 +409,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzMsICIyMDIwMjAyMC1mNTE3LTQyZmQtODBhZS0xNDE3M2IzYjcwYWUiXQ==',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'Seattle',
phones: {
primaryPhoneNumber: '781234545',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-f517-42fd-80ae-14173b3b70ae',
jobTitle: '',
@ -501,11 +510,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzQsICIyMDIwMjAyMC1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'Los Angeles',
phones: {
primaryPhoneNumber: '781234587',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-eee1-4690-ad2c-8619e5b56a2e',
jobTitle: '',
@ -600,11 +611,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzUsICIyMDIwMjAyMC02Nzg0LTQ0NDktYWZkZi1kYzYyY2I4NzAyZjIiXQ==',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'Seattle',
phones: {
primaryPhoneNumber: '781234599',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-6784-4449-afdf-dc62cb8702f2',
jobTitle: '',
@ -699,11 +712,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzYsICIyMDIwMjAyMC00OTBmLTQ0NjYtODM5MS03MzNjZmQ2NmEwYzgiXQ==',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'New York',
phones: {
primaryPhoneNumber: '781234572',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-490f-4466-8391-733cfd66a0c8',
jobTitle: '',
@ -798,11 +813,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzcsICIyMDIwMjAyMC04MGYxLTRkZmYtYjU3MC1hNzQ5NDI1MjhkZTMiXQ==',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'Seattle',
phones: {
primaryPhoneNumber: '781234582',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-80f1-4dff-b570-a74942528de3',
jobTitle: '',
@ -897,11 +914,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzgsICIyMDIwMjAyMC0zMzhiLTQ2ZGYtODgxMS1mYTA4YzdkMTlkMzUiXQ==',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'New York',
phones: {
primaryPhoneNumber: '781234569',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-338b-46df-8811-fa08c7d19d35',
jobTitle: '',
@ -996,11 +1015,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzksICIyMDIwMjAyMC02NGFkLTRiMGUtYmJmZC1lOWZkNzk1YjcwMTYiXQ==',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'San Francisco',
phones: {
primaryPhoneNumber: '781234962',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-64ad-4b0e-bbfd-e9fd795b7016',
jobTitle: '',
@ -1095,11 +1116,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzEwLCAiMjAyMDIwMjAtNWQ1NC00MWI3LWJhMzYtZjBkMjBlMTQxN2FlIl0=',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'New York',
phones: {
primaryPhoneNumber: '781234502',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-5d54-41b7-ba36-f0d20e1417ae',
jobTitle: '',
@ -1194,11 +1217,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzExLCAiMjAyMDIwMjAtNjIzZC00MWZlLTkyZTctZGQ0NWI3YzU2OGUxIl0=',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'Los Angeles',
phones: {
primaryPhoneNumber: '781234563',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-623d-41fe-92e7-dd45b7c568e1',
jobTitle: '',
@ -1293,11 +1318,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzEyLCAiMjAyMDIwMjAtMmQ0MC00ZTQ5LThkZjQtOWM2YTA0OTE5MGVmIl0=',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'Seattle',
phones: {
primaryPhoneNumber: '781234542',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-2d40-4e49-8df4-9c6a049190ef',
jobTitle: '',
@ -1392,11 +1419,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzEzLCAiMjAyMDIwMjAtMmQ0MC00ZTQ5LThkZjQtOWM2YTA0OTE5MGRmIl0=',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'Seattle',
phones: {
primaryPhoneNumber: '782234562',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-2d40-4e49-8df4-9c6a049190df',
jobTitle: '',
@ -1491,11 +1520,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzE0LCAiMjAyMDIwMjAtMmQ0MC00ZTQ5LThkZjQtOWM2YTA0OTE5MWRlIl0=',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'Seattle',
phones: {
primaryPhoneNumber: '781274562',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-2d40-4e49-8df4-9c6a049191de',
jobTitle: '',
@ -1590,11 +1621,13 @@ export const peopleQueryResult: { people: RecordGqlConnection } = {
cursor: 'WzE1LCAiMjAyMDIwMjAtMmQ0MC00ZTQ5LThkZjQtOWM2YTA0OTE5MWRmIl0=',
node: {
__typename: 'Person',
deletedAt: null,
createdAt: '2024-08-02T09:48:36.193Z',
city: 'Seattle',
phones: {
primaryPhoneNumber: '781239562',
primaryPhoneCountryCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
id: '20202020-2d40-4e49-8df4-9c6a049191df',
jobTitle: '',

View File

@ -1,5 +1,3 @@
import { Logger } from '@nestjs/common';
import chalk from 'chalk';
import { Option } from 'nest-commander';
import { Repository } from 'typeorm';
@ -20,11 +18,8 @@ export type ActiveWorkspacesCommandOptions = BaseCommandOptions & {
export abstract class ActiveWorkspacesCommandRunner extends BaseCommandRunner {
private workspaceIds: string[] = [];
protected readonly logger: Logger;
constructor(protected readonly workspaceRepository: Repository<Workspace>) {
super();
this.logger = new Logger(this.constructor.name);
}
@Option({

View File

@ -3,6 +3,7 @@ import { Logger } from '@nestjs/common';
import chalk from 'chalk';
import { CommandRunner, Option } from 'nest-commander';
import { CommandLogger } from './logger';
export type BaseCommandOptions = {
workspaceId?: string;
dryRun?: boolean;
@ -10,11 +11,13 @@ export type BaseCommandOptions = {
};
export abstract class BaseCommandRunner extends CommandRunner {
protected readonly logger: Logger;
protected logger: CommandLogger | Logger;
constructor() {
super();
this.logger = new Logger(this.constructor.name);
this.logger = new CommandLogger({
verbose: false,
constructorName: this.constructor.name,
});
}
@Option({
@ -27,10 +30,11 @@ export abstract class BaseCommandRunner extends CommandRunner {
}
@Option({
flags: '--verbose',
flags: '-v, --verbose',
description: 'Verbose output',
required: false,
})
parseVerbose() {
parseVerbose(): boolean {
return true;
}
@ -38,6 +42,13 @@ export abstract class BaseCommandRunner extends CommandRunner {
passedParams: string[],
options: BaseCommandOptions,
): Promise<void> {
if (options.verbose) {
this.logger = new CommandLogger({
verbose: true,
constructorName: this.constructor.name,
});
}
try {
await this.executeBaseCommand(passedParams, options);
} catch (error) {

View File

@ -52,8 +52,8 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
UpgradeTo0_32CommandModule,
UpgradeTo0_33CommandModule,
UpgradeTo0_34CommandModule,
FeatureFlagModule,
UpgradeTo0_40CommandModule,
FeatureFlagModule,
],
providers: [
DataSeedWorkspaceCommand,

View File

@ -0,0 +1,46 @@
import { Logger } from '@nestjs/common';
interface CommandLoggerOptions {
verbose?: boolean;
constructorName: string;
}
export class CommandLogger {
private logger: Logger;
private verbose: boolean;
constructor(options: CommandLoggerOptions) {
this.logger = new Logger(options.constructorName);
this.verbose = options.verbose ?? true;
}
log(message: string, context?: string) {
if (this.verbose) {
this.logger.log(message, context);
}
}
error(message: string, stack?: string, context?: string) {
if (this.verbose) {
this.logger.error(message, stack, context);
}
}
warn(message: string, context?: string) {
if (this.verbose) {
this.logger.warn(message, context);
}
}
debug(message: string, context?: string) {
if (this.verbose) {
this.logger.debug(message, context);
}
}
verboseLog(message: string, context?: string) {
if (this.verbose) {
this.logger.verbose(message, context);
}
}
}

View File

@ -0,0 +1,144 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationTableAction,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { isDefined } from 'src/utils/is-defined';
@Command({
name: 'upgrade-0.40:phone-calling-code-create-column',
description: 'Create the callingCode column',
})
export class PhoneCallingCodeCreateColumnCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log(
'Running command to add calling code and change country code with default one',
);
this.logger.log(`Part 1 - Workspace`);
let workspaceIterator = 1;
for (const workspaceId of workspaceIds) {
this.logger.log(
`Running command for workspace ${workspaceId} ${workspaceIterator}/${workspaceIds.length}`,
);
this.logger.log(
`P1 Step 1 - let's find all the fieldsMetadata that have the PHONES type, and extract the objectMetadataId`,
);
try {
const phonesFieldMetadata = await this.fieldMetadataRepository.find({
where: {
workspaceId,
type: FieldMetadataType.PHONES,
},
relations: ['object'],
});
for (const phoneFieldMetadata of phonesFieldMetadata) {
if (
isDefined(phoneFieldMetadata?.name && phoneFieldMetadata.object)
) {
this.logger.log(
`P1 Step 1 - Let's find the "nameSingular" of this objectMetadata: ${phoneFieldMetadata.object.nameSingular || 'not found'}`,
);
if (!phoneFieldMetadata.object?.nameSingular) continue;
this.logger.log(
`P1 Step 1 - Create migration for field ${phoneFieldMetadata.name}`,
);
if (options.dryRun === true) {
continue;
}
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`create-${phoneFieldMetadata.object.nameSingular}PrimaryPhoneCallingCode-for-field-${phoneFieldMetadata.name}`,
),
workspaceId,
[
{
name: computeObjectTargetTable(phoneFieldMetadata.object),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
{
id: v4(),
type: FieldMetadataType.TEXT,
name: `${phoneFieldMetadata.name}PrimaryPhoneCallingCode`,
label: `${phoneFieldMetadata.name}PrimaryPhoneCallingCode`,
objectMetadataId: phoneFieldMetadata.object.id,
workspaceId: workspaceId,
isNullable: true,
defaultValue: "''",
} satisfies Partial<FieldMetadataEntity>,
),
} satisfies WorkspaceMigrationTableAction,
],
);
}
}
this.logger.log(
`P1 Step 1 - RUN migration to create callingCodes for ${workspaceId.slice(0, 5)}`,
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
} catch (error) {
console.log(`Error in workspace ${workspaceId} : ${error}`);
}
workspaceIterator++;
}
this.logger.log(chalk.green(`Command completed!`));
}
}

View File

@ -0,0 +1,302 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { getCountries, getCountryCallingCode } from 'libphonenumber-js';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationTableAction,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { isDefined } from 'src/utils/is-defined';
const callingCodeToCountryCode = (callingCode: string): string => {
if (!callingCode) {
return '';
}
let callingCodeSanitized = callingCode;
if (callingCode.startsWith('+')) {
callingCodeSanitized = callingCode.slice(1);
}
return (
getCountries().find(
(countryCode) =>
getCountryCallingCode(countryCode) === callingCodeSanitized,
) || ''
);
};
const isCallingCode = (callingCode: string): boolean => {
return callingCodeToCountryCode(callingCode) !== '';
};
@Command({
name: 'upgrade-0.40:phone-calling-code-migrate-data',
description: 'Add calling code and change country code with default one',
})
export class PhoneCallingCodeMigrateDataCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log(
'Running command to add calling code and change country code with default one',
);
this.logger.log(`Part 1 - Workspace`);
let workspaceIterator = 1;
for (const workspaceId of workspaceIds) {
this.logger.log(
`Running command for workspace ${workspaceId} ${workspaceIterator}/${workspaceIds.length}`,
);
this.logger.log(
`P1 Step 1 - let's find all the fieldsMetadata that have the PHONES type, and extract the objectMetadataId`,
);
try {
const phonesFieldMetadata = await this.fieldMetadataRepository.find({
where: {
workspaceId,
type: FieldMetadataType.PHONES,
},
relations: ['object'],
});
for (const phoneFieldMetadata of phonesFieldMetadata) {
if (
isDefined(phoneFieldMetadata?.name) &&
isDefined(phoneFieldMetadata.object)
) {
this.logger.log(
`P1 Step 1 - Let's find the "nameSingular" of this objectMetadata: ${phoneFieldMetadata.object.nameSingular || 'not found'}`,
);
if (!phoneFieldMetadata.object?.nameSingular) continue;
this.logger.log(
`P1 Step 1 - Create migration for field ${phoneFieldMetadata.name}`,
);
if (options.dryRun === false) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`create-${phoneFieldMetadata.object.nameSingular}PrimaryPhoneCallingCode-for-field-${phoneFieldMetadata.name}`,
),
workspaceId,
[
{
name: computeObjectTargetTable(phoneFieldMetadata.object),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
{
id: v4(),
type: FieldMetadataType.TEXT,
name: `${phoneFieldMetadata.name}PrimaryPhoneCallingCode`,
label: `${phoneFieldMetadata.name}PrimaryPhoneCallingCode`,
objectMetadataId: phoneFieldMetadata.object.id,
workspaceId: workspaceId,
isNullable: true,
defaultValue: "''",
},
),
} satisfies WorkspaceMigrationTableAction,
],
);
}
}
}
this.logger.log(
`P1 Step 1 - RUN migration to create callingCodes for ${workspaceId.slice(0, 5)}`,
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
this.logger.log(
`P1 Step 2 - Migrations for callingCode must be first. Now can use twentyORMGlobalManager to update countryCode`,
);
this.logger.log(
`P1 Step 3 (same time) - update CountryCode to letters: +33 => FR || +1 => US (if mulitple, first one)`,
);
this.logger.log(
`P1 Step 4 (same time) - update all additioanl phones to add a country code following the same logic`,
);
for (const phoneFieldMetadata of phonesFieldMetadata) {
this.logger.log(`P1 Step 2 - for ${phoneFieldMetadata.name}`);
if (
isDefined(phoneFieldMetadata) &&
isDefined(phoneFieldMetadata.name)
) {
const [objectMetadata] = await this.objectMetadataRepository.find({
where: {
id: phoneFieldMetadata?.objectMetadataId,
},
});
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectMetadata.nameSingular,
);
const records = await repository.find();
for (const record of records) {
if (
record?.[phoneFieldMetadata.name]?.primaryPhoneCountryCode &&
isCallingCode(
record[phoneFieldMetadata.name].primaryPhoneCountryCode,
)
) {
let additionalPhones = null;
if (record[phoneFieldMetadata.name].additionalPhones) {
additionalPhones = record[
phoneFieldMetadata.name
].additionalPhones.map((phone) => {
return {
...phone,
countryCode: callingCodeToCountryCode(phone.callingCode),
};
});
}
if (options.dryRun === false) {
await repository.update(record.id, {
[`${phoneFieldMetadata.name}PrimaryPhoneCallingCode`]:
record[phoneFieldMetadata.name].primaryPhoneCountryCode,
[`${phoneFieldMetadata.name}PrimaryPhoneCountryCode`]:
callingCodeToCountryCode(
record[phoneFieldMetadata.name].primaryPhoneCountryCode,
),
[`${phoneFieldMetadata.name}AdditionalPhones`]:
additionalPhones,
});
}
}
}
}
}
} catch (error) {
console.log(`Error in workspace ${workspaceId} : ${error}`);
}
workspaceIterator++;
}
this.logger.log(`
Part 2 - FieldMetadata`);
workspaceIterator = 1;
for (const workspaceId of workspaceIds) {
this.logger.log(
`Running command for workspace ${workspaceId} ${workspaceIterator}/${workspaceIds.length}`,
);
this.logger.log(
`P2 Step 1 - let's find all the fieldsMetadata that have the PHONES type, and extract the objectMetadataId`,
);
try {
const phonesFieldMetadata = await this.fieldMetadataRepository.find({
where: {
workspaceId,
type: FieldMetadataType.PHONES,
},
});
for (const phoneFieldMetadata of phonesFieldMetadata) {
if (
!isDefined(phoneFieldMetadata) ||
!isDefined(phoneFieldMetadata.defaultValue)
)
continue;
let defaultValue = phoneFieldMetadata.defaultValue;
// some cases look like it's an array. let's flatten it (not sure the case is supposed to happen but I saw it in my local db)
if (Array.isArray(defaultValue) && isDefined(defaultValue[0]))
defaultValue = phoneFieldMetadata.defaultValue[0];
if (!isDefined(defaultValue)) continue;
if (typeof defaultValue !== 'object') continue;
if (!('primaryPhoneCountryCode' in defaultValue)) continue;
if (!defaultValue.primaryPhoneCountryCode) continue;
const primaryPhoneCountryCode = defaultValue.primaryPhoneCountryCode;
const countryCode = callingCodeToCountryCode(
primaryPhoneCountryCode.replace(/["']/g, ''),
);
if (options.dryRun === false) {
await this.fieldMetadataRepository.update(phoneFieldMetadata.id, {
defaultValue: {
...defaultValue,
primaryPhoneCountryCode: countryCode
? `'${countryCode}'`
: "''",
primaryPhoneCallingCode: isCallingCode(
primaryPhoneCountryCode.replace(/["']/g, ''),
)
? primaryPhoneCountryCode
: "''",
},
});
}
}
} catch (error) {
console.log(`Error in workspace ${workspaceId} : ${error}`);
}
workspaceIterator++;
}
this.logger.log(chalk.green(`Command completed!`));
}
}

Some files were not shown because too many files have changed in this diff Show More