Merge branch 'main' into introduce-free-pass

This commit is contained in:
Félix Malfait 2024-12-20 16:01:27 +01:00
commit 55b692d7a8
156 changed files with 3202 additions and 1413 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

@ -23,6 +23,14 @@ const pagesCoverage = {
exclude: ['src/generated/**/*', 'src/modules/**/*', 'src/**/*.ts'],
};
const performanceCoverage = {
branches: 35,
statements: 60,
lines: 60,
functions: 45,
exclude: ['src/generated/**/*', 'src/modules/**/*', 'src/**/*.ts'],
};
const storybookStoriesFolders = process.env.STORYBOOK_SCOPE;
module.exports =
@ -30,4 +38,6 @@ module.exports =
? pagesCoverage
: storybookStoriesFolders === 'modules'
? modulesCoverage
: storybookStoriesFolders === 'performance'
? performanceCoverage
: globalCoverage;

View File

@ -1,3 +1,4 @@
import { MultipleRecordsActionKeys } from '@/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
@ -8,19 +9,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 +39,6 @@ export const useDeleteMultipleRecordsAction = ({
objectNameSingular: objectMetadataItem.nameSingular,
});
const { sortedFavorites: favorites } = useFavorites();
const { deleteFavorite } = useDeleteFavorite();
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
);
@ -76,26 +72,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 +83,7 @@ export const useDeleteMultipleRecordsAction = ({
contextStoreNumberOfSelectedRecords < DELETE_MAX_COUNT &&
contextStoreNumberOfSelectedRecords > 0;
const { isInRightDrawer, onActionExecutedCallback } =
const { isInRightDrawer, onActionStartedCallback, onActionExecutedCallback } =
useContext(ActionMenuContext);
const registerDeleteMultipleRecordsAction = ({
@ -117,8 +95,9 @@ export const useDeleteMultipleRecordsAction = ({
addActionMenuEntry({
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'delete-multiple-records',
label: 'Delete',
key: MultipleRecordsActionKeys.DELETE,
label: 'Delete records',
shortLabel: 'Delete',
position,
Icon: IconTrash,
accent: 'danger',
@ -132,9 +111,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

@ -2,6 +2,8 @@ import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { IconDatabaseExport } from 'twenty-ui';
import { MultipleRecordsActionKeys } from '@/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
@ -10,6 +12,7 @@ import {
displayedExportProgress,
useExportRecords,
} from '@/object-record/record-index/export/hooks/useExportRecords';
import { useContext } from 'react';
export const useExportMultipleRecordsAction = ({
objectMetadataItem,
@ -25,6 +28,9 @@ export const useExportMultipleRecordsAction = ({
filename: `${objectMetadataItem.nameSingular}.csv`,
});
const { onActionStartedCallback, onActionExecutedCallback } =
useContext(ActionMenuContext);
const registerExportMultipleRecordsAction = ({
position,
}: {
@ -33,12 +39,21 @@ export const useExportMultipleRecordsAction = ({
addActionMenuEntry({
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'export-multiple-records',
key: MultipleRecordsActionKeys.EXPORT,
position,
label: displayedExportProgress(progress),
shortLabel: 'Export',
Icon: IconDatabaseExport,
accent: 'default',
onClick: () => download(),
onClick: async () => {
await onActionStartedCallback?.({
key: MultipleRecordsActionKeys.EXPORT,
});
await download();
await onActionExecutedCallback?.({
key: MultipleRecordsActionKeys.EXPORT,
});
},
});
};

View File

@ -0,0 +1,4 @@
export enum MultipleRecordsActionKeys {
DELETE = 'delete-multiple-records',
EXPORT = 'export-multiple-records',
}

View File

@ -2,6 +2,7 @@ import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { IconDatabaseExport } from 'twenty-ui';
import { NoSelectionRecordActionKeys } from '@/action-menu/actions/record-actions/no-selection/types/NoSelectionRecordActionsKey';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
@ -33,7 +34,7 @@ export const useExportViewNoSelectionRecordAction = ({
addActionMenuEntry({
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.Global,
key: 'export-view-no-selection',
key: NoSelectionRecordActionKeys.EXPORT_VIEW,
position,
label: displayedExportProgress(progress),
Icon: IconDatabaseExport,

View File

@ -0,0 +1,3 @@
export enum NoSelectionRecordActionKeys {
EXPORT_VIEW = 'export-view-no-selection',
}

View File

@ -1,11 +1,13 @@
import { getActionConfig } from '@/action-menu/actions/record-actions/single-record/utils/getActionConfig';
import { ActionAvailableOn } from '@/action-menu/actions/types/actionAvailableOn';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { wrapActionInCallbacks } from '@/action-menu/actions/utils/wrapActionInCallbacks';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useEffect } from 'react';
import { useContext, useEffect } from 'react';
import { isDefined } from 'twenty-ui';
export const ShowPageSingleRecordActionMenuEntrySetterEffect = ({
@ -36,6 +38,8 @@ export const ShowPageSingleRecordActionMenuEntrySetterEffect = ({
if (!isDefined(selectedRecordId)) {
throw new Error('Selected record ID is required');
}
const { onActionStartedCallback, onActionExecutedCallback } =
useContext(ActionMenuContext);
const actionMenuEntries = Object.values(actionConfig ?? {})
.filter((action) =>
@ -48,15 +52,21 @@ export const ShowPageSingleRecordActionMenuEntrySetterEffect = ({
objectMetadataItem,
});
if (shouldBeRegistered) {
return {
if (!shouldBeRegistered) {
return undefined;
}
const wrappedAction = wrapActionInCallbacks({
action: {
...action,
onClick,
ConfirmationModal,
};
}
},
onActionStartedCallback,
onActionExecutedCallback,
});
return undefined;
return wrappedAction;
})
.filter(isDefined);

View File

@ -1,11 +1,13 @@
import { getActionConfig } from '@/action-menu/actions/record-actions/single-record/utils/getActionConfig';
import { ActionAvailableOn } from '@/action-menu/actions/types/actionAvailableOn';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { wrapActionInCallbacks } from '@/action-menu/actions/utils/wrapActionInCallbacks';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useEffect } from 'react';
import { useContext, useEffect } from 'react';
import { isDefined } from 'twenty-ui';
export const SingleRecordActionMenuEntrySetterEffect = ({
@ -37,6 +39,9 @@ export const SingleRecordActionMenuEntrySetterEffect = ({
throw new Error('Selected record ID is required');
}
const { onActionStartedCallback, onActionExecutedCallback } =
useContext(ActionMenuContext);
const actionMenuEntries = Object.values(actionConfig ?? {})
.filter((action) =>
action.availableOn?.includes(
@ -50,15 +55,21 @@ export const SingleRecordActionMenuEntrySetterEffect = ({
objectMetadataItem,
});
if (shouldBeRegistered) {
return {
if (!shouldBeRegistered) {
return undefined;
}
const wrappedAction = wrapActionInCallbacks({
action: {
...action,
onClick,
ConfirmationModal,
};
}
},
onActionStartedCallback,
onActionExecutedCallback,
});
return undefined;
return wrappedAction;
})
.filter(isDefined);

View File

@ -1,8 +1,9 @@
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 { useRemoveFromFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction';
import { ActionAvailableOn } from '@/action-menu/actions/types/actionAvailableOn';
import { SingleRecordActionHook } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { SingleRecordActionHook } from '@/action-menu/actions/types/SingleRecordActionHook';
import {
ActionMenuEntry,
ActionMenuEntryScope,
@ -19,7 +20,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V1: Record<
addToFavoritesSingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'add-to-favorites-single-record',
key: SingleRecordActionKeys.ADD_TO_FAVORITES,
label: 'Add to favorites',
position: 0,
Icon: IconHeart,
@ -32,7 +33,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V1: Record<
removeFromFavoritesSingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'remove-from-favorites-single-record',
key: SingleRecordActionKeys.REMOVE_FROM_FAVORITES,
label: 'Remove from favorites',
position: 1,
Icon: IconHeartOff,
@ -45,7 +46,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V1: Record<
deleteSingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'delete-single-record',
key: SingleRecordActionKeys.DELETE,
label: 'Delete',
position: 2,
Icon: IconTrash,

View File

@ -1,10 +1,12 @@
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';
import { ActionAvailableOn } from '@/action-menu/actions/types/actionAvailableOn';
import { SingleRecordActionHook } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { SingleRecordActionHook } from '@/action-menu/actions/types/SingleRecordActionHook';
import {
ActionMenuEntry,
ActionMenuEntryScope,
@ -16,6 +18,7 @@ import {
IconHeart,
IconHeartOff,
IconTrash,
IconTrashX,
} from 'twenty-ui';
export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
@ -27,7 +30,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
addToFavoritesSingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'add-to-favorites-single-record',
key: SingleRecordActionKeys.ADD_TO_FAVORITES,
label: 'Add to favorites',
shortLabel: 'Add to favorites',
position: 0,
@ -42,7 +45,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
removeFromFavoritesSingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'remove-from-favorites-single-record',
key: SingleRecordActionKeys.REMOVE_FROM_FAVORITES,
label: 'Remove from favorites',
shortLabel: 'Remove from favorites',
isPinned: true,
@ -57,7 +60,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
deleteSingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'delete-single-record',
key: SingleRecordActionKeys.DELETE,
label: 'Delete record',
shortLabel: 'Delete',
position: 2,
@ -70,13 +73,29 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
],
actionHook: useDeleteSingleRecordAction,
},
destroySingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: SingleRecordActionKeys.DESTROY,
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',
key: SingleRecordActionKeys.NAVIGATE_TO_PREVIOUS_RECORD,
label: 'Navigate to previous record',
shortLabel: '',
position: 3,
position: 4,
isPinned: true,
Icon: IconChevronUp,
availableOn: [ActionAvailableOn.SHOW_PAGE],
@ -85,10 +104,10 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
navigateToNextRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'navigate-to-next-record',
key: SingleRecordActionKeys.NAVIGATE_TO_NEXT_RECORD,
label: 'Navigate to next record',
shortLabel: '',
position: 4,
position: 5,
isPinned: true,
Icon: IconChevronDown,
availableOn: [ActionAvailableOn.SHOW_PAGE],

View File

@ -1,7 +1,8 @@
import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
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

@ -1,12 +1,15 @@
import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
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,71 @@
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 } = 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();
if (isInRightDrawer) {
closeRightDrawer();
}
}}
deleteButtonText={'Permanently Destroy Record'}
/>
),
};
};

View File

@ -1,4 +1,4 @@
import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination';
export const useNavigateToNextRecordSingleRecordAction: SingleRecordActionHookWithObjectMetadataItem =

View File

@ -1,4 +1,4 @@
import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination';
export const useNavigateToPreviousRecordSingleRecordAction: SingleRecordActionHookWithObjectMetadataItem =

View File

@ -1,4 +1,4 @@
import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { isDefined } from 'twenty-ui';

View File

@ -0,0 +1,8 @@
export enum SingleRecordActionKeys {
DELETE = 'delete-single-record',
DESTROY = 'destroy-single-record',
ADD_TO_FAVORITES = 'add-to-favorites-single-record',
REMOVE_FROM_FAVORITES = 'remove-from-favorites-single-record',
NAVIGATE_TO_NEXT_RECORD = 'navigate-to-next-record-single-record',
NAVIGATE_TO_PREVIOUS_RECORD = 'navigate-to-previous-record-single-record',
}

View File

@ -1,5 +1,10 @@
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';
import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
import { useActivateDraftWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateDraftWorkflowSingleRecordAction';
import { useActivateLastPublishedVersionWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateLastPublishedVersionWorkflowSingleRecordAction';
import { useDeactivateWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useDeactivateWorkflowSingleRecordAction';
@ -8,8 +13,9 @@ import { useSeeActiveVersionWorkflowSingleRecordAction } from '@/action-menu/act
import { useSeeRunsWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeRunsWorkflowSingleRecordAction';
import { useSeeVersionsWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeVersionsWorkflowSingleRecordAction';
import { useTestWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useTestWorkflowSingleRecordAction';
import { ActionAvailableOn } from '@/action-menu/actions/types/actionAvailableOn';
import { SingleRecordActionHook } from '@/action-menu/actions/types/singleRecordActionHook';
import { WorkflowSingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/workflow-actions/types/WorkflowSingleRecordActionsKeys';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { SingleRecordActionHook } from '@/action-menu/actions/types/SingleRecordActionHook';
import {
ActionMenuEntry,
ActionMenuEntryScope,
@ -18,12 +24,15 @@ import {
import {
IconChevronDown,
IconChevronUp,
IconHeart,
IconHeartOff,
IconHistory,
IconHistoryToggle,
IconPlayerPause,
IconPlayerPlay,
IconPower,
IconTrash,
IconTrashX,
} from 'twenty-ui';
export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
@ -33,7 +42,7 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
}
> = {
activateWorkflowDraftSingleRecord: {
key: 'activate-workflow-draft-single-record',
key: WorkflowSingleRecordActionKeys.ACTIVATE_DRAFT,
label: 'Activate Draft',
shortLabel: 'Activate Draft',
isPinned: true,
@ -48,7 +57,7 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
actionHook: useActivateDraftWorkflowSingleRecordAction,
},
activateWorkflowLastPublishedVersionSingleRecord: {
key: 'activate-workflow-last-published-version-single-record',
key: WorkflowSingleRecordActionKeys.ACTIVATE_LAST_PUBLISHED,
label: 'Activate last published version',
shortLabel: 'Activate last version',
isPinned: true,
@ -63,7 +72,7 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
actionHook: useActivateLastPublishedVersionWorkflowSingleRecordAction,
},
deactivateWorkflowSingleRecord: {
key: 'deactivate-workflow-single-record',
key: WorkflowSingleRecordActionKeys.DEACTIVATE,
label: 'Deactivate Workflow',
shortLabel: 'Deactivate',
isPinned: true,
@ -78,7 +87,7 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
actionHook: useDeactivateWorkflowSingleRecordAction,
},
discardWorkflowDraftSingleRecord: {
key: 'discard-workflow-draft-single-record',
key: WorkflowSingleRecordActionKeys.DISCARD_DRAFT,
label: 'Discard Draft',
shortLabel: 'Discard Draft',
isPinned: true,
@ -93,7 +102,7 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
actionHook: useDiscardDraftWorkflowSingleRecordAction,
},
seeWorkflowActiveVersionSingleRecord: {
key: 'see-workflow-active-version-single-record',
key: WorkflowSingleRecordActionKeys.SEE_ACTIVE_VERSION,
label: 'See active version',
shortLabel: 'See active version',
isPinned: false,
@ -108,7 +117,7 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
actionHook: useSeeActiveVersionWorkflowSingleRecordAction,
},
seeWorkflowRunsSingleRecord: {
key: 'see-workflow-runs-single-record',
key: WorkflowSingleRecordActionKeys.SEE_RUNS,
label: 'See runs',
shortLabel: 'See runs',
isPinned: false,
@ -123,7 +132,7 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
actionHook: useSeeRunsWorkflowSingleRecordAction,
},
seeWorkflowVersionsHistorySingleRecord: {
key: 'see-workflow-versions-history-single-record',
key: WorkflowSingleRecordActionKeys.SEE_VERSIONS,
label: 'See versions history',
shortLabel: 'See versions',
isPinned: false,
@ -138,7 +147,7 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
actionHook: useSeeVersionsWorkflowSingleRecordAction,
},
testWorkflowSingleRecord: {
key: 'test-workflow-single-record',
key: WorkflowSingleRecordActionKeys.TEST,
label: 'Test Workflow',
shortLabel: 'Test',
isPinned: true,
@ -155,7 +164,7 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
navigateToPreviousRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'navigate-to-previous-record',
key: SingleRecordActionKeys.NAVIGATE_TO_PREVIOUS_RECORD,
label: 'Navigate to previous workflow',
shortLabel: '',
position: 9,
@ -166,7 +175,7 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
navigateToNextRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'navigate-to-next-record',
key: SingleRecordActionKeys.NAVIGATE_TO_NEXT_RECORD,
label: 'Navigate to next workflow',
shortLabel: '',
position: 10,
@ -174,4 +183,66 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
availableOn: [ActionAvailableOn.SHOW_PAGE],
actionHook: useNavigateToNextRecordSingleRecordAction,
},
addToFavoritesSingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: SingleRecordActionKeys.ADD_TO_FAVORITES,
label: 'Add to favorites',
shortLabel: 'Add to favorites',
position: 11,
isPinned: false,
Icon: IconHeart,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
],
actionHook: useAddToFavoritesSingleRecordAction,
},
removeFromFavoritesSingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: SingleRecordActionKeys.REMOVE_FROM_FAVORITES,
label: 'Remove from favorites',
shortLabel: 'Remove from favorites',
isPinned: false,
position: 12,
Icon: IconHeartOff,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
],
actionHook: useRemoveFromFavoritesSingleRecordAction,
},
deleteSingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: SingleRecordActionKeys.DELETE,
label: 'Delete record',
shortLabel: 'Delete',
position: 13,
Icon: IconTrash,
accent: 'danger',
isPinned: false,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
],
actionHook: useDeleteSingleRecordAction,
},
destroySingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: SingleRecordActionKeys.DESTROY,
label: 'Permanently destroy record',
shortLabel: 'Destroy',
position: 14,
Icon: IconTrashX,
accent: 'danger',
isPinned: false,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
],
actionHook: useDestroySingleRecordAction,
},
};

View File

@ -1,4 +1,4 @@
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { useActivateWorkflowVersion } from '@/workflow/hooks/useActivateWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { isDefined } from 'twenty-ui';

View File

@ -1,4 +1,4 @@
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { useActivateWorkflowVersion } from '@/workflow/hooks/useActivateWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { isDefined } from 'twenty-ui';

View File

@ -1,4 +1,4 @@
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { useDeactivateWorkflowVersion } from '@/workflow/hooks/useDeactivateWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { isDefined } from 'twenty-ui';

View File

@ -1,4 +1,4 @@
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { useDeleteOneWorkflowVersion } from '@/workflow/hooks/useDeleteOneWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { isDefined } from 'twenty-ui';

View File

@ -1,4 +1,4 @@
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useActiveWorkflowVersion } from '@/workflow/hooks/useActiveWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';

View File

@ -1,4 +1,4 @@
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { FilterQueryParams } from '@/views/hooks/internal/useViewFromQueryParams';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';

View File

@ -1,4 +1,4 @@
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { FilterQueryParams } from '@/views/hooks/internal/useViewFromQueryParams';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';

View File

@ -1,4 +1,4 @@
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { isDefined } from 'twenty-ui';

View File

@ -0,0 +1,10 @@
export enum WorkflowSingleRecordActionKeys {
ACTIVATE_DRAFT = 'activate-draft-workflow-single-record',
ACTIVATE_LAST_PUBLISHED = 'activate-last-published-workflow-single-record',
DEACTIVATE = 'deactivate-workflow-single-record',
DISCARD_DRAFT = 'discard-draft-workflow-single-record',
SEE_ACTIVE_VERSION = 'see-active-version-workflow-single-record',
SEE_RUNS = 'see-runs-workflow-single-record',
SEE_VERSIONS = 'see-versions-workflow-single-record',
TEST = 'test-workflow-single-record',
}

View File

@ -1,10 +1,16 @@
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 { useSeeExecutionsWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeExecutionsWorkflowVersionSingleRecordAction';
import { useRemoveFromFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction';
import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
import { useSeeRunsWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeRunsWorkflowVersionSingleRecordAction';
import { useSeeVersionsWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeVersionsWorkflowVersionSingleRecordAction';
import { useUseAsDraftWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useUseAsDraftWorkflowVersionSingleRecordAction';
import { ActionAvailableOn } from '@/action-menu/actions/types/actionAvailableOn';
import { SingleRecordActionHook } from '@/action-menu/actions/types/singleRecordActionHook';
import { WorkflowVersionSingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/types/WorkflowVersionSingleRecordActionsKeys';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { SingleRecordActionHook } from '@/action-menu/actions/types/SingleRecordActionHook';
import {
ActionMenuEntry,
ActionMenuEntryScope,
@ -13,9 +19,13 @@ import {
import {
IconChevronDown,
IconChevronUp,
IconHeart,
IconHeartOff,
IconHistory,
IconHistoryToggle,
IconPencil,
IconTrash,
IconTrashX,
} from 'twenty-ui';
export const WORKFLOW_VERSIONS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
@ -25,7 +35,7 @@ export const WORKFLOW_VERSIONS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
}
> = {
useAsDraftWorkflowVersionSingleRecord: {
key: 'use-as-draft-workflow-version-single-record',
key: WorkflowVersionSingleRecordActionKeys.USE_AS_DRAFT,
label: 'Use as draft',
position: 1,
isPinned: true,
@ -38,9 +48,9 @@ export const WORKFLOW_VERSIONS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
],
actionHook: useUseAsDraftWorkflowVersionSingleRecordAction,
},
seeWorkflowExecutionsSingleRecord: {
key: 'see-workflow-executions-single-record',
label: 'See executions',
seeWorkflowRunsSingleRecord: {
key: WorkflowVersionSingleRecordActionKeys.SEE_RUNS,
label: 'See runs',
position: 2,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
@ -49,10 +59,10 @@ export const WORKFLOW_VERSIONS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useSeeExecutionsWorkflowVersionSingleRecordAction,
actionHook: useSeeRunsWorkflowVersionSingleRecordAction,
},
seeWorkflowVersionsHistorySingleRecord: {
key: 'see-workflow-versions-history-single-record',
key: WorkflowVersionSingleRecordActionKeys.SEE_VERSIONS,
label: 'See versions history',
position: 3,
type: ActionMenuEntryType.Standard,
@ -67,10 +77,10 @@ export const WORKFLOW_VERSIONS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
navigateToPreviousRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'navigate-to-previous-record',
key: SingleRecordActionKeys.NAVIGATE_TO_PREVIOUS_RECORD,
label: 'Navigate to previous version',
shortLabel: '',
position: 9,
position: 4,
Icon: IconChevronUp,
availableOn: [ActionAvailableOn.SHOW_PAGE],
actionHook: useNavigateToPreviousRecordSingleRecordAction,
@ -78,12 +88,74 @@ export const WORKFLOW_VERSIONS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
navigateToNextRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'navigate-to-next-record',
key: SingleRecordActionKeys.NAVIGATE_TO_NEXT_RECORD,
label: 'Navigate to next version',
shortLabel: '',
position: 10,
position: 5,
Icon: IconChevronDown,
availableOn: [ActionAvailableOn.SHOW_PAGE],
actionHook: useNavigateToNextRecordSingleRecordAction,
},
addToFavoritesSingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: SingleRecordActionKeys.ADD_TO_FAVORITES,
label: 'Add to favorites',
shortLabel: 'Add to favorites',
position: 6,
isPinned: false,
Icon: IconHeart,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
],
actionHook: useAddToFavoritesSingleRecordAction,
},
removeFromFavoritesSingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: SingleRecordActionKeys.REMOVE_FROM_FAVORITES,
label: 'Remove from favorites',
shortLabel: 'Remove from favorites',
isPinned: false,
position: 7,
Icon: IconHeartOff,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
],
actionHook: useRemoveFromFavoritesSingleRecordAction,
},
deleteSingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: SingleRecordActionKeys.DELETE,
label: 'Delete record',
shortLabel: 'Delete',
position: 8,
Icon: IconTrash,
accent: 'danger',
isPinned: false,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
],
actionHook: useDeleteSingleRecordAction,
},
destroySingleRecord: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: SingleRecordActionKeys.DESTROY,
label: 'Permanently destroy record',
shortLabel: 'Destroy',
position: 9,
Icon: IconTrashX,
accent: 'danger',
isPinned: false,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
],
actionHook: useDestroySingleRecordAction,
},
};

View File

@ -1,4 +1,4 @@
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { FilterQueryParams } from '@/views/hooks/internal/useViewFromQueryParams';
@ -9,7 +9,7 @@ import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const useSeeExecutionsWorkflowVersionSingleRecordAction: SingleRecordActionHookWithoutObjectMetadataItem =
export const useSeeRunsWorkflowVersionSingleRecordAction: SingleRecordActionHookWithoutObjectMetadataItem =
({ recordId }) => {
const workflowVersion = useRecoilValue(recordStoreFamilyState(recordId));

View File

@ -1,5 +1,5 @@
import { useSeeVersionsWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeVersionsWorkflowSingleRecordAction';
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';

View File

@ -1,4 +1,4 @@
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook';
import { SingleRecordActionHookWithoutObjectMetadataItem } from '@/action-menu/actions/types/SingleRecordActionHook';
import { OverrideWorkflowDraftConfirmationModal } from '@/workflow/components/OverrideWorkflowDraftConfirmationModal';
import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion';
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';

View File

@ -0,0 +1,5 @@
export enum WorkflowVersionSingleRecordActionKeys {
SEE_RUNS = 'see-runs-workflow-version-single-record',
SEE_VERSIONS = 'see-versions-workflow-version-single-record',
USE_AS_DRAFT = 'use-as-draft-workflow-version-single-record',
}

View File

@ -0,0 +1,7 @@
import { ConfirmationModalProps } from '@/ui/layout/modal/components/ConfirmationModal';
export type ActionHookResult = {
shouldBeRegistered: boolean;
onClick: () => Promise<void> | void;
ConfirmationModal?: React.ReactElement<ConfirmationModalProps>;
};

View File

@ -1,4 +1,4 @@
import { ActionHookResult } from '@/action-menu/actions/types/actionHookResult';
import { ActionHookResult } from '@/action-menu/actions/types/ActionHookResult';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export type SingleRecordActionHook =

View File

@ -1,5 +0,0 @@
export type ActionHookResult = {
shouldBeRegistered: boolean;
onClick: () => void;
ConfirmationModal?: React.ReactElement;
};

View File

@ -0,0 +1,40 @@
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
import { isDefined } from 'twenty-ui';
export const wrapActionInCallbacks = ({
action,
onActionStartedCallback,
onActionExecutedCallback,
}: {
action: ActionMenuEntry;
onActionStartedCallback?: (action: { key: string }) => Promise<void> | void;
onActionExecutedCallback?: (action: { key: string }) => Promise<void> | void;
}) => {
const onClickWithCallbacks = isDefined(action.ConfirmationModal)
? action.onClick
: async () => {
await onActionStartedCallback?.({ key: action.key });
await action.onClick?.();
await onActionExecutedCallback?.({ key: action.key });
};
const ConfirmationModalWithCallbacks = isDefined(action.ConfirmationModal)
? {
...action.ConfirmationModal,
props: {
...action.ConfirmationModal.props,
onConfirmClick: async () => {
await onActionStartedCallback?.({ key: action.key });
await action.ConfirmationModal?.props.onConfirmClick?.();
await onActionExecutedCallback?.({ key: action.key });
},
},
}
: undefined;
return {
...action,
onClick: onClickWithCallbacks,
ConfirmationModal: ConfirmationModalWithCallbacks,
};
};

View File

@ -1,4 +1,5 @@
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { MultipleRecordsActionKeys } from '@/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys';
import { RecordAgnosticActionsSetterEffect } from '@/action-menu/actions/record-agnostic-actions/components/RecordAgnosticActionsSetterEffect';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { RecordIndexActionMenuBar } from '@/action-menu/components/RecordIndexActionMenuBar';
@ -8,11 +9,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 +28,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 === MultipleRecordsActionKeys.DELETE) {
setIsLoadMoreLocked(true);
}
},
onActionExecutedCallback: (action) => {
if (action.key === MultipleRecordsActionKeys.DELETE) {
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 }) => Promise<void> | void;
onActionExecutedCallback?: (action: { key: string }) => Promise<void> | void;
};
export const ActionMenuContext = createContext<ActionMenuContextType>({
isInRightDrawer: false,
onActionStartedCallback: () => {},
onActionExecutedCallback: () => {},
});

View File

@ -1,4 +1,5 @@
import { ActionAvailableOn } from '@/action-menu/actions/types/actionAvailableOn';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { ConfirmationModalProps } from '@/ui/layout/modal/components/ConfirmationModal';
import { MouseEvent, ReactElement } from 'react';
import { IconComponent, MenuItemAccent } from 'twenty-ui';
@ -24,5 +25,5 @@ export type ActionMenuEntry = {
accent?: MenuItemAccent;
availableOn?: ActionAvailableOn[];
onClick?: (event?: MouseEvent<HTMLElement>) => void;
ConfirmationModal?: ReactElement;
ConfirmationModal?: ReactElement<ConfirmationModalProps>;
};

View File

@ -8,14 +8,11 @@ import { useDebouncedCallback } from 'use-debounce';
import { v4 } from 'uuid';
import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
import { activityBodyFamilyState } from '@/activities/states/activityBodyFamilyState';
import { activityTitleHasBeenSetFamilyState } from '@/activities/states/activityTitleHasBeenSetFamilyState';
import { canCreateActivityState } from '@/activities/states/canCreateActivityState';
import { ActivityEditorHotkeyScope } from '@/activities/types/ActivityEditorHotkeyScope';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
@ -23,44 +20,32 @@ import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritin
import { isDefined } from '~/utils/isDefined';
import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { ActivityRichTextEditorChangeOnActivityIdEffect } from '@/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect';
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
import '@blocknote/core/fonts/inter.css';
import '@blocknote/mantine/style.css';
import '@blocknote/react/style.css';
type RichTextEditorProps = {
type ActivityRichTextEditorProps = {
activityId: string;
fillTitleFromBody: boolean;
activityObjectNameSingular:
| CoreObjectNameSingular.Task
| CoreObjectNameSingular.Note;
};
export const RichTextEditor = ({
export const ActivityRichTextEditor = ({
activityId,
fillTitleFromBody,
activityObjectNameSingular,
}: RichTextEditorProps) => {
}: ActivityRichTextEditorProps) => {
const [activityInStore] = useRecoilState(recordStoreFamilyState(activityId));
const cache = useApolloClient().cache;
const activity = activityInStore as Task | Note | null;
const [activityTitleHasBeenSet, setActivityTitleHasBeenSet] = useRecoilState(
activityTitleHasBeenSetFamilyState({
activityId: activityId,
}),
);
const [activityBody, setActivityBody] = useRecoilState(
activityBodyFamilyState({
activityId: activityId,
}),
);
const { objectMetadataItem: objectMetadataItemActivity } =
useObjectMetadataItem({
objectNameSingular: activityObjectNameSingular,
@ -86,33 +71,6 @@ export const RichTextEditor = ({
}
}, 300);
const persistTitleAndBodyDebounced = useDebouncedCallback(
(newTitle: string, newBody: string) => {
if (isDefined(activity)) {
upsertActivity({
activity,
input: {
title: newTitle,
body: newBody,
},
});
setActivityTitleHasBeenSet(true);
}
},
200,
);
const updateTitleAndBody = useCallback(
(newStringifiedBody: string) => {
const blockBody = JSON.parse(newStringifiedBody);
const newTitleFromBody = blockBody[0]?.content?.[0]?.text;
persistTitleAndBodyDebounced(newTitleFromBody, newStringifiedBody);
},
[persistTitleAndBodyDebounced],
);
const [canCreateActivity, setCanCreateActivity] = useRecoilState(
canCreateActivityState,
);
@ -156,24 +114,13 @@ export const RichTextEditor = ({
setCanCreateActivity(true);
}
if (!activityTitleHasBeenSet && fillTitleFromBody) {
updateTitleAndBody(activityBody);
} else {
persistBodyDebounced(prepareBody(activityBody));
}
persistBodyDebounced(prepareBody(activityBody));
},
[
fillTitleFromBody,
persistBodyDebounced,
activityTitleHasBeenSet,
updateTitleAndBody,
setCanCreateActivity,
canCreateActivity,
],
[persistBodyDebounced, setCanCreateActivity, canCreateActivity],
);
const handleBodyChange = useRecoilCallback(
({ snapshot, set }) =>
({ set }) =>
(newStringifiedBody: string) => {
set(recordStoreFamilyState(activityId), (oldActivity) => {
return {
@ -195,79 +142,28 @@ export const RichTextEditor = ({
objectMetadataItem: objectMetadataItemActivity,
});
const activityTitleHasBeenSet = snapshot
.getLoadable(
activityTitleHasBeenSetFamilyState({
activityId: activityId,
}),
)
.getValue();
const blockBody = JSON.parse(newStringifiedBody);
const newTitleFromBody = blockBody[0]?.content?.[0]?.text as string;
if (!activityTitleHasBeenSet && fillTitleFromBody) {
set(recordStoreFamilyState(activityId), (oldActivity) => {
return {
...oldActivity,
id: activityId,
title: newTitleFromBody,
__typename: 'Activity',
};
});
modifyRecordFromCache({
recordId: activityId,
fieldModifiers: {
title: () => {
return newTitleFromBody;
},
},
cache,
objectMetadataItem: objectMetadataItemActivity,
});
}
handlePersistBody(newStringifiedBody);
},
[
activityId,
cache,
objectMetadataItemActivity,
fillTitleFromBody,
handlePersistBody,
],
[activityId, cache, objectMetadataItemActivity, handlePersistBody],
);
const handleBodyChangeDebounced = useDebouncedCallback(handleBodyChange, 500);
// See https://github.com/twentyhq/twenty/issues/6724 for explanation
const setActivityBodyDebouncedToAvoidDragBug = useDebouncedCallback(
setActivityBody,
100,
);
const handleEditorChange = () => {
const newStringifiedBody = JSON.stringify(editor.document) ?? '';
setActivityBodyDebouncedToAvoidDragBug(newStringifiedBody);
handleBodyChangeDebounced(newStringifiedBody);
};
const initialBody = useMemo(() => {
if (isNonEmptyString(activityBody) && activityBody !== '{}') {
return JSON.parse(activityBody);
} else if (
if (
isDefined(activity) &&
isNonEmptyString(activity.body) &&
activity?.body !== '{}'
) {
return JSON.parse(activity.body);
} else {
return undefined;
}
}, [activity, activityBody]);
}, [activity]);
const handleEditorBuiltInUploadFile = async (file: File) => {
const { attachementAbsoluteURL } = await handleUploadAttachment(file);
@ -367,11 +263,17 @@ export const RichTextEditor = ({
};
return (
<BlockEditor
onFocus={handleBlockEditorFocus}
onBlur={handlerBlockEditorBlur}
onChange={handleEditorChange}
editor={editor}
/>
<>
<ActivityRichTextEditorChangeOnActivityIdEffect
editor={editor}
activityId={activityId}
/>
<BlockEditor
onFocus={handleBlockEditorFocus}
onBlur={handlerBlockEditorBlur}
onChange={handleEditorChange}
editor={editor}
/>
</>
);
};

View File

@ -0,0 +1,27 @@
import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { useReplaceActivityBlockEditorContent } from '@/activities/hooks/useReplaceActivityBlockEditorContent';
import { useEffect, useState } from 'react';
type ActivityRichTextEditorChangeOnActivityIdEffectProps = {
activityId: string;
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
};
export const ActivityRichTextEditorChangeOnActivityIdEffect = ({
activityId,
editor,
}: ActivityRichTextEditorChangeOnActivityIdEffectProps) => {
const { replaceBlockEditorContent } =
useReplaceActivityBlockEditorContent(editor);
const [currentActivityId, setCurrentActivityId] = useState(activityId);
useEffect(() => {
if (currentActivityId !== activityId) {
replaceBlockEditorContent(activityId);
setCurrentActivityId(activityId);
}
}, [activityId, currentActivityId, replaceBlockEditorContent]);
return <></>;
};

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,34 @@
import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-ui';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useReplaceActivityBlockEditorContent = (
editor: typeof BLOCK_SCHEMA.BlockNoteEditor,
) => {
const replaceBlockEditorContent = useRecoilCallback(
({ snapshot }) =>
(activityId: string) => {
if (isDefined(editor)) {
const activityInStore = snapshot
.getLoadable(recordStoreFamilyState(activityId))
.getValue();
const content = isNonEmptyString(activityInStore?.body)
? JSON.parse(activityInStore?.body)
: [{ type: 'paragraph', content: '' }];
if (!isDeeplyEqual(editor.document, content)) {
editor.replaceBlocks(editor.document, content);
}
}
},
[editor],
);
return {
replaceBlockEditorContent,
};
};

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,6 @@
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 +8,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 +81,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 +116,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,
});
}
@ -137,6 +144,9 @@ export const useDeleteManyRecords = ({
},
})
.catch((error: Error) => {
const cachedRecordsWithConnection: RecordGqlNode[] = [];
const optimisticRecordsWithConnection: RecordGqlNode[] = [];
cachedRecords.forEach((cachedRecord) => {
if (isUndefinedOrNull(cachedRecord?.id)) {
return;
@ -175,16 +185,21 @@ export const useDeleteManyRecords = ({
!optimisticRecordWithConnection ||
!cachedRecordWithConnection
) {
return null;
return;
}
triggerUpdateRecordOptimisticEffect({
cache: apolloClient.cache,
objectMetadataItem,
currentRecord: optimisticRecordWithConnection,
updatedRecord: cachedRecordWithConnection,
objectMetadataItems,
});
cachedRecordsWithConnection.push(cachedRecordWithConnection);
optimisticRecordsWithConnection.push(
optimisticRecordWithConnection,
);
});
triggerUpdateRecordOptimisticEffectByBatch({
cache: apolloClient.cache,
objectMetadataItem,
currentRecords: optimisticRecordsWithConnection,
updatedRecords: cachedRecordsWithConnection,
objectMetadataItems,
});
throw error;

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,6 +1,6 @@
import { FormAddressFieldInput } from '@/object-record/record-field/form-types/components/FormAddressFieldInput';
import { FormBooleanFieldInput } from '@/object-record/record-field/form-types/components/FormBooleanFieldInput';
import { FormDateTimeFieldInputBase } from '@/object-record/record-field/form-types/components/FormDateTimeFieldInputBase';
import { FormDateFieldInput } from '@/object-record/record-field/form-types/components/FormDateFieldInput';
import { FormEmailsFieldInput } from '@/object-record/record-field/form-types/components/FormEmailsFieldInput';
import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/components/FormFullNameFieldInput';
import { FormLinksFieldInput } from '@/object-record/record-field/form-types/components/FormLinksFieldInput';
@ -34,6 +34,7 @@ import { isFieldSelect } from '@/object-record/record-field/types/guards/isField
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
import { JsonValue } from 'type-fest';
import { FormDateTimeFieldInput } from '@/object-record/record-field/form-types/components/FormDateTimeFieldInput';
type FormFieldInputProps = {
field: FieldDefinition<FieldMetadata>;
@ -109,16 +110,14 @@ export const FormFieldInput = ({
VariablePicker={VariablePicker}
/>
) : isFieldDate(field) ? (
<FormDateTimeFieldInputBase
mode="date"
<FormDateFieldInput
label={field.label}
defaultValue={defaultValue as string | undefined}
onPersist={onPersist}
VariablePicker={VariablePicker}
/>
) : isFieldDateTime(field) ? (
<FormDateTimeFieldInputBase
mode="datetime"
<FormDateTimeFieldInput
label={field.label}
defaultValue={defaultValue as string | undefined}
onPersist={onPersist}

View File

@ -0,0 +1,26 @@
import { FormDateTimeFieldInput } from '@/object-record/record-field/form-types/components/FormDateTimeFieldInput';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
type FormDateFieldInputProps = {
label?: string;
defaultValue: string | undefined;
onPersist: (value: string | null) => void;
VariablePicker?: VariablePickerComponent;
};
export const FormDateFieldInput = ({
label,
defaultValue,
onPersist,
VariablePicker,
}: FormDateFieldInputProps) => {
return (
<FormDateTimeFieldInput
dateOnly
label={label}
defaultValue={defaultValue}
onPersist={onPersist}
VariablePicker={VariablePicker}
/>
);
};

View File

@ -69,27 +69,26 @@ type DraftValue =
value: string;
};
type FormDateTimeFieldInputBaseProps = {
mode: 'date' | 'datetime';
type FormDateTimeFieldInputProps = {
dateOnly?: boolean;
label?: string;
placeholder?: string;
defaultValue: string | undefined;
onPersist: (value: string | null) => void;
VariablePicker?: VariablePickerComponent;
};
export const FormDateTimeFieldInputBase = ({
mode,
export const FormDateTimeFieldInput = ({
dateOnly,
label,
defaultValue,
onPersist,
VariablePicker,
}: FormDateTimeFieldInputBaseProps) => {
}: FormDateTimeFieldInputProps) => {
const { timeZone } = useContext(UserContext);
const inputId = useId();
const placeholder = mode === 'date' ? 'mm/dd/yyyy' : 'mm/dd/yyyy hh:mm';
const [draftValue, setDraftValue] = useState<DraftValue>(
isStandaloneVariableString(defaultValue)
? {
@ -116,7 +115,7 @@ export const FormDateTimeFieldInputBase = ({
isDefined(draftValueAsDate) && !isStandaloneVariableString(defaultValue)
? parseDateToString({
date: draftValueAsDate,
isDateTimeInput: mode === 'datetime',
isDateTimeInput: !dateOnly,
userTimezone: timeZone,
})
: '',
@ -143,6 +142,8 @@ export const FormDateTimeFieldInputBase = ({
const displayDatePicker =
draftValue.type === 'static' && draftValue.mode === 'edit';
const placeholder = dateOnly ? 'mm/dd/yyyy' : 'mm/dd/yyyy hh:mm';
useListenClickOutside({
refs: [datePickerWrapperRef],
listenerId: 'FormDateTimeFieldInputBase',
@ -168,7 +169,7 @@ export const FormDateTimeFieldInputBase = ({
isDefined(newDate)
? parseDateToString({
date: newDate,
isDateTimeInput: mode === 'datetime',
isDateTimeInput: !dateOnly,
userTimezone: timeZone,
})
: '',
@ -226,7 +227,7 @@ export const FormDateTimeFieldInputBase = ({
isDefined(newDate)
? parseDateToString({
date: newDate,
isDateTimeInput: mode === 'datetime',
isDateTimeInput: !dateOnly,
userTimezone: timeZone,
})
: '',
@ -262,7 +263,7 @@ export const FormDateTimeFieldInputBase = ({
const parsedInputDateTime = parseStringToDate({
dateAsString: inputDateTimeTrimmed,
isDateTimeInput: mode === 'datetime',
isDateTimeInput: !dateOnly,
userTimezone: timeZone,
});
@ -288,7 +289,7 @@ export const FormDateTimeFieldInputBase = ({
setInputDateTime(
parseDateToString({
date: validatedDate,
isDateTimeInput: mode === 'datetime',
isDateTimeInput: !dateOnly,
userTimezone: timeZone,
}),
);

View File

@ -0,0 +1,370 @@
import { MAX_DATE } from '@/ui/input/components/internal/date/constants/MaxDate';
import { MIN_DATE } from '@/ui/input/components/internal/date/constants/MinDate';
import { parseDateToString } from '@/ui/input/components/internal/date/utils/parseDateToString';
import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import {
fn,
userEvent,
waitFor,
waitForElementToBeRemoved,
within,
} from '@storybook/test';
import { DateTime } from 'luxon';
import { FormDateFieldInput } from '../FormDateFieldInput';
const meta: Meta<typeof FormDateFieldInput> = {
title: 'UI/Data/Field/Form/Input/FormDateFieldInput',
component: FormDateFieldInput,
args: {},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof FormDateFieldInput>;
export const Default: Story = {
args: {
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Created At');
await canvas.findByDisplayValue('12/09/2024');
},
};
export const WithDefaultEmptyValue: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Created At');
await canvas.findByDisplayValue('');
await canvas.findByPlaceholderText('mm/dd/yyyy');
},
};
export const SetsDateWithInput: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
await userEvent.click(input);
const dialog = await canvas.findByRole('dialog');
expect(dialog).toBeVisible();
await userEvent.type(input, '12/08/2024{enter}');
await waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith('2024-12-08T00:00:00.000Z');
});
expect(dialog).toBeVisible();
},
};
export const SetsDateWithDatePicker: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
const dayToChoose = await within(datePicker).findByRole('option', {
name: 'Choose Saturday, December 7th, 2024',
});
await Promise.all([
userEvent.click(dayToChoose),
waitForElementToBeRemoved(datePicker),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(
expect.stringMatching(/^2024-12-07/),
);
}),
waitFor(() => {
expect(canvas.getByDisplayValue('12/07/2024')).toBeVisible();
}),
]);
},
};
export const ResetsDateByClickingButton: Story = {
args: {
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
const clearButton = await canvas.findByText('Clear');
await Promise.all([
userEvent.click(clearButton),
waitForElementToBeRemoved(datePicker),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(null);
}),
waitFor(() => {
expect(input).toHaveDisplayValue('');
}),
]);
},
};
export const ResetsDateByErasingInputContent: Story = {
args: {
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
expect(input).toHaveDisplayValue('12/09/2024');
await userEvent.clear(input);
await Promise.all([
userEvent.type(input, '{Enter}'),
waitForElementToBeRemoved(() => canvas.queryByRole('dialog')),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(null);
}),
waitFor(() => {
expect(input).toHaveDisplayValue('');
}),
]);
},
};
export const DefaultsToMinValueWhenTypingReallyOldDate: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
await Promise.all([
userEvent.type(input, '02/02/1500{Enter}'),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(MIN_DATE.toISOString());
}),
waitFor(() => {
expect(input).toHaveDisplayValue(
parseDateToString({
date: MIN_DATE,
isDateTimeInput: false,
userTimezone: undefined,
}),
);
}),
waitFor(() => {
const expectedDate = DateTime.fromJSDate(MIN_DATE)
.toLocal()
.set({
day: MIN_DATE.getUTCDate(),
month: MIN_DATE.getUTCMonth() + 1,
year: MIN_DATE.getUTCFullYear(),
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
});
const selectedDay = within(datePicker).getByRole('option', {
selected: true,
name: (accessibleName) => {
// The name looks like "Choose Sunday, December 31st, 1899"
return accessibleName.includes(expectedDate.toFormat('yyyy'));
},
});
expect(selectedDay).toBeVisible();
}),
]);
},
};
export const DefaultsToMaxValueWhenTypingReallyFarDate: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
await Promise.all([
userEvent.type(input, '02/02/2500{Enter}'),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(MAX_DATE.toISOString());
}),
waitFor(() => {
expect(input).toHaveDisplayValue(
parseDateToString({
date: MAX_DATE,
isDateTimeInput: false,
userTimezone: undefined,
}),
);
}),
waitFor(() => {
const expectedDate = DateTime.fromJSDate(MAX_DATE)
.toLocal()
.set({
day: MAX_DATE.getUTCDate(),
month: MAX_DATE.getUTCMonth() + 1,
year: MAX_DATE.getUTCFullYear(),
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
});
const selectedDay = within(datePicker).getByRole('option', {
selected: true,
name: (accessibleName) => {
// The name looks like "Choose Thursday, December 30th, 2100"
return accessibleName.includes(expectedDate.toFormat('yyyy'));
},
});
expect(selectedDay).toBeVisible();
}),
]);
},
};
export const SwitchesToStandaloneVariable: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
VariablePicker: ({ onVariableSelect }) => {
return (
<button
onClick={() => {
onVariableSelect('{{test}}');
}}
>
Add variable
</button>
);
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const addVariableButton = await canvas.findByText('Add variable');
await userEvent.click(addVariableButton);
const variableTag = await canvas.findByText('test');
expect(variableTag).toBeVisible();
const removeVariableButton = canvas.getByTestId(/^remove-icon/);
await Promise.all([
userEvent.click(removeVariableButton),
waitForElementToBeRemoved(variableTag),
waitFor(() => {
const input = canvas.getByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
}),
]);
},
};
export const ClickingOutsideDoesNotResetInputState: Story = {
args: {
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const defaultValueAsDisplayString = parseDateToString({
date: new Date(args.defaultValue!),
isDateTimeInput: false,
userTimezone: undefined,
});
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
expect(input).toHaveDisplayValue(defaultValueAsDisplayString);
await userEvent.type(input, '{Backspace}{Backspace}');
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
await Promise.all([
userEvent.click(canvasElement),
waitForElementToBeRemoved(datePicker),
]);
expect(args.onPersist).not.toHaveBeenCalled();
expect(input).toHaveDisplayValue(defaultValueAsDisplayString.slice(0, -2));
},
};

View File

@ -0,0 +1,397 @@
import { MAX_DATE } from '@/ui/input/components/internal/date/constants/MaxDate';
import { MIN_DATE } from '@/ui/input/components/internal/date/constants/MinDate';
import { parseDateToString } from '@/ui/input/components/internal/date/utils/parseDateToString';
import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import {
fn,
userEvent,
waitFor,
waitForElementToBeRemoved,
within,
} from '@storybook/test';
import { DateTime } from 'luxon';
import { FormDateTimeFieldInput } from '@/object-record/record-field/form-types/components/FormDateTimeFieldInput';
const meta: Meta<typeof FormDateTimeFieldInput> = {
title: 'UI/Data/Field/Form/Input/FormDateTimeFieldInput',
component: FormDateTimeFieldInput,
args: {},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof FormDateTimeFieldInput>;
export const Default: Story = {
args: {
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Created At');
await canvas.findByDisplayValue(/12\/09\/2024 \d{2}:20/);
},
};
export const WithDefaultEmptyValue: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Created At');
await canvas.findByDisplayValue('');
await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
},
};
export const SetsDateTimeWithInput: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
await userEvent.click(input);
const dialog = await canvas.findByRole('dialog');
expect(dialog).toBeVisible();
await userEvent.type(input, '12/08/2024 12:10{enter}');
await waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(
expect.stringMatching(/2024-12-08T\d{2}:10:00.000Z/),
);
});
expect(dialog).toBeVisible();
},
};
export const DoesNotSetDateWithoutTime: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
await userEvent.click(input);
const dialog = await canvas.findByRole('dialog');
expect(dialog).toBeVisible();
await userEvent.type(input, '12/08/2024{enter}');
expect(args.onPersist).not.toHaveBeenCalled();
expect(dialog).toBeVisible();
},
};
export const SetsDateTimeWithDatePicker: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
const dayToChoose = await within(datePicker).findByRole('option', {
name: 'Choose Saturday, December 7th, 2024',
});
await Promise.all([
userEvent.click(dayToChoose),
waitForElementToBeRemoved(datePicker),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(
expect.stringMatching(/^2024-12-07/),
);
}),
waitFor(() => {
expect(
canvas.getByDisplayValue(/12\/07\/2024 \d{2}:\d{2}/),
).toBeVisible();
}),
]);
},
};
export const ResetsDateByClickingButton: Story = {
args: {
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
const clearButton = await canvas.findByText('Clear');
await Promise.all([
userEvent.click(clearButton),
waitForElementToBeRemoved(datePicker),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(null);
}),
waitFor(() => {
expect(input).toHaveDisplayValue('');
}),
]);
},
};
export const ResetsDateByErasingInputContent: Story = {
args: {
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
expect(input).toHaveDisplayValue(/12\/09\/2024 \d{2}:\d{2}/);
await userEvent.clear(input);
await Promise.all([
userEvent.type(input, '{Enter}'),
waitForElementToBeRemoved(() => canvas.queryByRole('dialog')),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(null);
}),
waitFor(() => {
expect(input).toHaveDisplayValue('');
}),
]);
},
};
export const DefaultsToMinValueWhenTypingReallyOldDate: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
await Promise.all([
userEvent.type(input, '02/02/1500 10:10{Enter}'),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(MIN_DATE.toISOString());
}),
waitFor(() => {
expect(input).toHaveDisplayValue(
parseDateToString({
date: MIN_DATE,
isDateTimeInput: true,
userTimezone: undefined,
}),
);
}),
waitFor(() => {
const expectedDate = DateTime.fromJSDate(MIN_DATE)
.toLocal()
.set({
day: MIN_DATE.getUTCDate(),
month: MIN_DATE.getUTCMonth() + 1,
year: MIN_DATE.getUTCFullYear(),
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
});
const selectedDay = within(datePicker).getByRole('option', {
selected: true,
name: (accessibleName) => {
// The name looks like "Choose Sunday, December 31st, 1899"
return accessibleName.includes(expectedDate.toFormat('yyyy'));
},
});
expect(selectedDay).toBeVisible();
}),
]);
},
};
export const DefaultsToMaxValueWhenTypingReallyFarDate: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
await Promise.all([
userEvent.type(input, '02/02/2500 10:10{Enter}'),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(MAX_DATE.toISOString());
}),
waitFor(() => {
expect(input).toHaveDisplayValue(
parseDateToString({
date: MAX_DATE,
isDateTimeInput: true,
userTimezone: undefined,
}),
);
}),
waitFor(() => {
const expectedDate = DateTime.fromJSDate(MAX_DATE)
.toLocal()
.set({
day: MAX_DATE.getUTCDate(),
month: MAX_DATE.getUTCMonth() + 1,
year: MAX_DATE.getUTCFullYear(),
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
});
const selectedDay = within(datePicker).getByRole('option', {
selected: true,
name: (accessibleName) => {
// The name looks like "Choose Thursday, December 30th, 2100"
return accessibleName.includes(expectedDate.toFormat('yyyy'));
},
});
expect(selectedDay).toBeVisible();
}),
]);
},
};
export const SwitchesToStandaloneVariable: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
VariablePicker: ({ onVariableSelect }) => {
return (
<button
onClick={() => {
onVariableSelect('{{test}}');
}}
>
Add variable
</button>
);
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const addVariableButton = await canvas.findByText('Add variable');
await userEvent.click(addVariableButton);
const variableTag = await canvas.findByText('test');
expect(variableTag).toBeVisible();
const removeVariableButton = canvas.getByTestId(/^remove-icon/);
await Promise.all([
userEvent.click(removeVariableButton),
waitForElementToBeRemoved(variableTag),
waitFor(() => {
const input = canvas.getByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
}),
]);
},
};
export const ClickingOutsideDoesNotResetInputState: Story = {
args: {
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const defaultValueAsDisplayString = parseDateToString({
date: new Date(args.defaultValue!),
isDateTimeInput: true,
userTimezone: undefined,
});
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
expect(input).toHaveDisplayValue(defaultValueAsDisplayString);
await userEvent.type(input, '{Backspace}{Backspace}');
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
await Promise.all([
userEvent.click(canvasElement),
waitForElementToBeRemoved(datePicker),
]);
expect(args.onPersist).not.toHaveBeenCalled();
expect(input).toHaveDisplayValue(defaultValueAsDisplayString.slice(0, -2));
},
};

View File

@ -1,765 +0,0 @@
import { MAX_DATE } from '@/ui/input/components/internal/date/constants/MaxDate';
import { MIN_DATE } from '@/ui/input/components/internal/date/constants/MinDate';
import { parseDateToString } from '@/ui/input/components/internal/date/utils/parseDateToString';
import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import {
fn,
userEvent,
waitFor,
waitForElementToBeRemoved,
within,
} from '@storybook/test';
import { DateTime } from 'luxon';
import { FormDateTimeFieldInputBase } from '../FormDateTimeFieldInputBase';
const meta: Meta<typeof FormDateTimeFieldInputBase> = {
title: 'UI/Data/Field/Form/Input/FormDateTimeFieldInputBase',
component: FormDateTimeFieldInputBase,
args: {},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof FormDateTimeFieldInputBase>;
export const DateDefault: Story = {
args: {
mode: 'date',
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Created At');
await canvas.findByDisplayValue('12/09/2024');
},
};
export const DateWithDefaultEmptyValue: Story = {
args: {
mode: 'date',
label: 'Created At',
defaultValue: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Created At');
await canvas.findByDisplayValue('');
await canvas.findByPlaceholderText('mm/dd/yyyy');
},
};
export const DateSetsDateWithInput: Story = {
args: {
mode: 'date',
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
await userEvent.click(input);
const dialog = await canvas.findByRole('dialog');
expect(dialog).toBeVisible();
await userEvent.type(input, '12/08/2024{enter}');
await waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith('2024-12-08T00:00:00.000Z');
});
expect(dialog).toBeVisible();
},
};
export const DateSetsDateWithDatePicker: Story = {
args: {
mode: 'date',
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
const dayToChoose = await within(datePicker).findByRole('option', {
name: 'Choose Saturday, December 7th, 2024',
});
await Promise.all([
userEvent.click(dayToChoose),
waitForElementToBeRemoved(datePicker),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(
expect.stringMatching(/^2024-12-07/),
);
}),
waitFor(() => {
expect(canvas.getByDisplayValue('12/07/2024')).toBeVisible();
}),
]);
},
};
export const DateResetsDateByClickingButton: Story = {
args: {
mode: 'date',
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
const clearButton = await canvas.findByText('Clear');
await Promise.all([
userEvent.click(clearButton),
waitForElementToBeRemoved(datePicker),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(null);
}),
waitFor(() => {
expect(input).toHaveDisplayValue('');
}),
]);
},
};
export const DateResetsDateByErasingInputContent: Story = {
args: {
mode: 'date',
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
expect(input).toHaveDisplayValue('12/09/2024');
await userEvent.clear(input);
await Promise.all([
userEvent.type(input, '{Enter}'),
waitForElementToBeRemoved(() => canvas.queryByRole('dialog')),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(null);
}),
waitFor(() => {
expect(input).toHaveDisplayValue('');
}),
]);
},
};
export const DateDefaultsToMinValueWhenTypingReallyOldDate: Story = {
args: {
mode: 'date',
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
await Promise.all([
userEvent.type(input, '02/02/1500{Enter}'),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(MIN_DATE.toISOString());
}),
waitFor(() => {
expect(input).toHaveDisplayValue(
parseDateToString({
date: MIN_DATE,
isDateTimeInput: false,
userTimezone: undefined,
}),
);
}),
waitFor(() => {
const expectedDate = DateTime.fromJSDate(MIN_DATE)
.toLocal()
.set({
day: MIN_DATE.getUTCDate(),
month: MIN_DATE.getUTCMonth() + 1,
year: MIN_DATE.getUTCFullYear(),
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
});
const selectedDay = within(datePicker).getByRole('option', {
selected: true,
name: (accessibleName) => {
// The name looks like "Choose Sunday, December 31st, 1899"
return accessibleName.includes(expectedDate.toFormat('yyyy'));
},
});
expect(selectedDay).toBeVisible();
}),
]);
},
};
export const DateDefaultsToMaxValueWhenTypingReallyFarDate: Story = {
args: {
mode: 'date',
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
await Promise.all([
userEvent.type(input, '02/02/2500{Enter}'),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(MAX_DATE.toISOString());
}),
waitFor(() => {
expect(input).toHaveDisplayValue(
parseDateToString({
date: MAX_DATE,
isDateTimeInput: false,
userTimezone: undefined,
}),
);
}),
waitFor(() => {
const expectedDate = DateTime.fromJSDate(MAX_DATE)
.toLocal()
.set({
day: MAX_DATE.getUTCDate(),
month: MAX_DATE.getUTCMonth() + 1,
year: MAX_DATE.getUTCFullYear(),
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
});
const selectedDay = within(datePicker).getByRole('option', {
selected: true,
name: (accessibleName) => {
// The name looks like "Choose Thursday, December 30th, 2100"
return accessibleName.includes(expectedDate.toFormat('yyyy'));
},
});
expect(selectedDay).toBeVisible();
}),
]);
},
};
export const DateSwitchesToStandaloneVariable: Story = {
args: {
mode: 'date',
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
VariablePicker: ({ onVariableSelect }) => {
return (
<button
onClick={() => {
onVariableSelect('{{test}}');
}}
>
Add variable
</button>
);
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const addVariableButton = await canvas.findByText('Add variable');
await userEvent.click(addVariableButton);
const variableTag = await canvas.findByText('test');
expect(variableTag).toBeVisible();
const removeVariableButton = canvas.getByTestId(/^remove-icon/);
await Promise.all([
userEvent.click(removeVariableButton),
waitForElementToBeRemoved(variableTag),
waitFor(() => {
const input = canvas.getByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
}),
]);
},
};
export const DateClickingOutsideDoesNotResetInputState: Story = {
args: {
mode: 'date',
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const defaultValueAsDisplayString = parseDateToString({
date: new Date(args.defaultValue!),
isDateTimeInput: false,
userTimezone: undefined,
});
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy');
expect(input).toBeVisible();
expect(input).toHaveDisplayValue(defaultValueAsDisplayString);
await userEvent.type(input, '{Backspace}{Backspace}');
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
await Promise.all([
userEvent.click(canvasElement),
waitForElementToBeRemoved(datePicker),
]);
expect(args.onPersist).not.toHaveBeenCalled();
expect(input).toHaveDisplayValue(defaultValueAsDisplayString.slice(0, -2));
},
};
// ----
export const DateTimeDefault: Story = {
args: {
mode: 'datetime',
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Created At');
await canvas.findByDisplayValue(/12\/09\/2024 \d{2}:20/);
},
};
export const DateTimeWithDefaultEmptyValue: Story = {
args: {
mode: 'datetime',
label: 'Created At',
defaultValue: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Created At');
await canvas.findByDisplayValue('');
await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
},
};
export const DateTimeSetsDateTimeWithInput: Story = {
args: {
mode: 'datetime',
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
await userEvent.click(input);
const dialog = await canvas.findByRole('dialog');
expect(dialog).toBeVisible();
await userEvent.type(input, '12/08/2024 12:10{enter}');
await waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(
expect.stringMatching(/2024-12-08T\d{2}:10:00.000Z/),
);
});
expect(dialog).toBeVisible();
},
};
export const DateTimeDoesNotSetDateWithoutTime: Story = {
args: {
mode: 'datetime',
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
await userEvent.click(input);
const dialog = await canvas.findByRole('dialog');
expect(dialog).toBeVisible();
await userEvent.type(input, '12/08/2024{enter}');
expect(args.onPersist).not.toHaveBeenCalled();
expect(dialog).toBeVisible();
},
};
export const DateTimeSetsDateTimeWithDatePicker: Story = {
args: {
mode: 'datetime',
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
const dayToChoose = await within(datePicker).findByRole('option', {
name: 'Choose Saturday, December 7th, 2024',
});
await Promise.all([
userEvent.click(dayToChoose),
waitForElementToBeRemoved(datePicker),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(
expect.stringMatching(/^2024-12-07/),
);
}),
waitFor(() => {
expect(
canvas.getByDisplayValue(/12\/07\/2024 \d{2}:\d{2}/),
).toBeVisible();
}),
]);
},
};
export const DateTimeResetsDateByClickingButton: Story = {
args: {
mode: 'datetime',
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
const clearButton = await canvas.findByText('Clear');
await Promise.all([
userEvent.click(clearButton),
waitForElementToBeRemoved(datePicker),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(null);
}),
waitFor(() => {
expect(input).toHaveDisplayValue('');
}),
]);
},
};
export const DateTimeResetsDateByErasingInputContent: Story = {
args: {
mode: 'datetime',
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
expect(input).toHaveDisplayValue(/12\/09\/2024 \d{2}:\d{2}/);
await userEvent.clear(input);
await Promise.all([
userEvent.type(input, '{Enter}'),
waitForElementToBeRemoved(() => canvas.queryByRole('dialog')),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(null);
}),
waitFor(() => {
expect(input).toHaveDisplayValue('');
}),
]);
},
};
export const DateTimeDefaultsToMinValueWhenTypingReallyOldDate: Story = {
args: {
mode: 'datetime',
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
await Promise.all([
userEvent.type(input, '02/02/1500 10:10{Enter}'),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(MIN_DATE.toISOString());
}),
waitFor(() => {
expect(input).toHaveDisplayValue(
parseDateToString({
date: MIN_DATE,
isDateTimeInput: true,
userTimezone: undefined,
}),
);
}),
waitFor(() => {
const expectedDate = DateTime.fromJSDate(MIN_DATE)
.toLocal()
.set({
day: MIN_DATE.getUTCDate(),
month: MIN_DATE.getUTCMonth() + 1,
year: MIN_DATE.getUTCFullYear(),
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
});
const selectedDay = within(datePicker).getByRole('option', {
selected: true,
name: (accessibleName) => {
// The name looks like "Choose Sunday, December 31st, 1899"
return accessibleName.includes(expectedDate.toFormat('yyyy'));
},
});
expect(selectedDay).toBeVisible();
}),
]);
},
};
export const DateTimeDefaultsToMaxValueWhenTypingReallyFarDate: Story = {
args: {
mode: 'datetime',
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
await userEvent.click(input);
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
await Promise.all([
userEvent.type(input, '02/02/2500 10:10{Enter}'),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(MAX_DATE.toISOString());
}),
waitFor(() => {
expect(input).toHaveDisplayValue(
parseDateToString({
date: MAX_DATE,
isDateTimeInput: true,
userTimezone: undefined,
}),
);
}),
waitFor(() => {
const expectedDate = DateTime.fromJSDate(MAX_DATE)
.toLocal()
.set({
day: MAX_DATE.getUTCDate(),
month: MAX_DATE.getUTCMonth() + 1,
year: MAX_DATE.getUTCFullYear(),
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
});
const selectedDay = within(datePicker).getByRole('option', {
selected: true,
name: (accessibleName) => {
// The name looks like "Choose Thursday, December 30th, 2100"
return accessibleName.includes(expectedDate.toFormat('yyyy'));
},
});
expect(selectedDay).toBeVisible();
}),
]);
},
};
export const DateTimeSwitchesToStandaloneVariable: Story = {
args: {
mode: 'datetime',
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
VariablePicker: ({ onVariableSelect }) => {
return (
<button
onClick={() => {
onVariableSelect('{{test}}');
}}
>
Add variable
</button>
);
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const addVariableButton = await canvas.findByText('Add variable');
await userEvent.click(addVariableButton);
const variableTag = await canvas.findByText('test');
expect(variableTag).toBeVisible();
const removeVariableButton = canvas.getByTestId(/^remove-icon/);
await Promise.all([
userEvent.click(removeVariableButton),
waitForElementToBeRemoved(variableTag),
waitFor(() => {
const input = canvas.getByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
}),
]);
},
};
export const DateTimeClickingOutsideDoesNotResetInputState: Story = {
args: {
mode: 'datetime',
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const defaultValueAsDisplayString = parseDateToString({
date: new Date(args.defaultValue!),
isDateTimeInput: true,
userTimezone: undefined,
});
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm');
expect(input).toBeVisible();
expect(input).toHaveDisplayValue(defaultValueAsDisplayString);
await userEvent.type(input, '{Backspace}{Backspace}');
const datePicker = await canvas.findByRole('dialog');
expect(datePicker).toBeVisible();
await Promise.all([
userEvent.click(canvasElement),
waitForElementToBeRemoved(datePicker),
]);
expect(args.onPersist).not.toHaveBeenCalled();
expect(input).toHaveDisplayValue(defaultValueAsDisplayString.slice(0, -2));
},
};

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

@ -265,10 +265,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

@ -255,7 +255,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

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

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

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

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

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

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