diff --git a/packages/twenty-front/nyc.config.cjs b/packages/twenty-front/nyc.config.cjs index b4c04651ecb6..639a738ff9e4 100644 --- a/packages/twenty-front/nyc.config.cjs +++ b/packages/twenty-front/nyc.config.cjs @@ -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; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx index 39d3dc879ced..7c48a63a0b92 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx @@ -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,13 +9,12 @@ 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 { FilterOperand } from '@/object-record/object-filter-dropdown/types/FilterOperand'; import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; @@ -41,9 +41,6 @@ export const useDeleteMultipleRecordsAction = ({ objectNameSingular: objectMetadataItem.nameSingular, }); - const { sortedFavorites: favorites } = useFavorites(); - const { deleteFavorite } = useDeleteFavorite(); - const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2( contextStoreNumberOfSelectedRecordsComponentState, ); @@ -65,6 +62,16 @@ export const useDeleteMultipleRecordsAction = ({ filterValueDependencies, ); + const deletedAtFieldMetadata = objectMetadataItem.fields.find( + (field) => field.name === 'deletedAt', + ); + + const isDeletedFilterActive = contextStoreFilters.some( + (filter) => + filter.fieldMetadataId === deletedAtFieldMetadata?.id && + filter.operand === FilterOperand.IsNotEmpty, + ); + const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({ objectNameSingular: objectMetadataItem.nameSingular, filter: graphqlFilter, @@ -80,36 +87,19 @@ 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; const canDelete = !isRemoteObject && + !isDeletedFilterActive && isDefined(contextStoreNumberOfSelectedRecords) && contextStoreNumberOfSelectedRecords < DELETE_MAX_COUNT && contextStoreNumberOfSelectedRecords > 0; - const { isInRightDrawer, onActionExecutedCallback } = + const { isInRightDrawer, onActionStartedCallback, onActionExecutedCallback } = useContext(ActionMenuContext); const registerDeleteMultipleRecordsAction = ({ @@ -121,7 +111,7 @@ export const useDeleteMultipleRecordsAction = ({ addActionMenuEntry({ type: ActionMenuEntryType.Standard, scope: ActionMenuEntryScope.RecordSelection, - key: 'delete-multiple-records', + key: MultipleRecordsActionKeys.DELETE, label: 'Delete records', shortLabel: 'Delete', position, @@ -137,9 +127,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(); } diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useExportMultipleRecordsAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useExportMultipleRecordsAction.tsx index 935c34db1c8a..3b4d10bec890 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useExportMultipleRecordsAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useExportMultipleRecordsAction.tsx @@ -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,13 +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, + }); + }, }); }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys.ts new file mode 100644 index 000000000000..9ecbb5b23c31 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys.ts @@ -0,0 +1,4 @@ +export enum MultipleRecordsActionKeys { + DELETE = 'delete-multiple-records', + EXPORT = 'export-multiple-records', +} diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/no-selection/hooks/useExportViewNoSelectionRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/no-selection/hooks/useExportViewNoSelectionRecordAction.tsx index 3bb7cebbbe7f..e3ace2ee667b 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/no-selection/hooks/useExportViewNoSelectionRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/no-selection/hooks/useExportViewNoSelectionRecordAction.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/no-selection/types/NoSelectionRecordActionsKey.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/no-selection/types/NoSelectionRecordActionsKey.ts new file mode 100644 index 000000000000..1b56de13e6fe --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/no-selection/types/NoSelectionRecordActionsKey.ts @@ -0,0 +1,3 @@ +export enum NoSelectionRecordActionKeys { + EXPORT_VIEW = 'export-view-no-selection', +} diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/ShowPageSingleRecordActionMenuEntrySetterEffect.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/ShowPageSingleRecordActionMenuEntrySetterEffect.tsx index 65ad1000cb5c..c97b1d1df112 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/ShowPageSingleRecordActionMenuEntrySetterEffect.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/ShowPageSingleRecordActionMenuEntrySetterEffect.tsx @@ -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); diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/SingleRecordActionMenuEntrySetterEffect.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/SingleRecordActionMenuEntrySetterEffect.tsx index 9238772a669c..296459dc5751 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/SingleRecordActionMenuEntrySetterEffect.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/SingleRecordActionMenuEntrySetterEffect.tsx @@ -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); diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/constants/DefaultSingleRecordActionsConfigV1.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/constants/DefaultSingleRecordActionsConfigV1.ts index db6512154885..778fdea06a73 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/constants/DefaultSingleRecordActionsConfigV1.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/constants/DefaultSingleRecordActionsConfigV1.ts @@ -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, diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/constants/DefaultSingleRecordActionsConfigV2.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/constants/DefaultSingleRecordActionsConfigV2.ts index b22f7e14f1ac..1cf13d116321 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/constants/DefaultSingleRecordActionsConfigV2.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/constants/DefaultSingleRecordActionsConfigV2.ts @@ -4,8 +4,9 @@ import { useDestroySingleRecordAction } from '@/action-menu/actions/record-actio 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, @@ -29,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, @@ -44,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, @@ -59,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, @@ -75,7 +76,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record< destroySingleRecord: { type: ActionMenuEntryType.Standard, scope: ActionMenuEntryScope.RecordSelection, - key: 'destroy-single-record', + key: SingleRecordActionKeys.DESTROY, label: 'Permanently destroy record', shortLabel: 'Destroy', position: 3, @@ -91,7 +92,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record< 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: 4, @@ -103,7 +104,7 @@ 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: 5, diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useAddToFavoritesSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useAddToFavoritesSingleRecordAction.ts index 88c8f4c1928e..ec3f6ddf042d 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useAddToFavoritesSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useAddToFavoritesSingleRecordAction.ts @@ -1,4 +1,4 @@ -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'; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx index cb58be678a32..e499c72d062a 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx @@ -1,4 +1,4 @@ -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'; @@ -54,8 +54,7 @@ export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetada const isRemoteObject = objectMetadataItem.isRemote; - const { isInRightDrawer, onActionExecutedCallback } = - useContext(ActionMenuContext); + const { isInRightDrawer } = useContext(ActionMenuContext); const shouldBeRegistered = !isRemoteObject && isNull(selectedRecord?.deletedAt); @@ -81,7 +80,6 @@ export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetada } onConfirmClick={() => { handleDeleteClick(); - onActionExecutedCallback?.(); if (isInRightDrawer) { closeRightDrawer(); } diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx index c024df812f5e..85bcf89e861f 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx @@ -1,4 +1,4 @@ -import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook'; +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'; @@ -34,8 +34,7 @@ export const useDestroySingleRecordAction: SingleRecordActionHookWithObjectMetad const isRemoteObject = objectMetadataItem.isRemote; - const { isInRightDrawer, onActionExecutedCallback } = - useContext(ActionMenuContext); + const { isInRightDrawer } = useContext(ActionMenuContext); const shouldBeRegistered = !isRemoteObject && isDefined(selectedRecord?.deletedAt); @@ -61,7 +60,6 @@ export const useDestroySingleRecordAction: SingleRecordActionHookWithObjectMetad } onConfirmClick={async () => { await handleDeleteClick(); - onActionExecutedCallback?.(); if (isInRightDrawer) { closeRightDrawer(); } diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useNavigateToNextRecordSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useNavigateToNextRecordSingleRecordAction.ts index a775d5275b79..77a4f9acff95 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useNavigateToNextRecordSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useNavigateToNextRecordSingleRecordAction.ts @@ -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 = diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useNavigateToPreviousRecordSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useNavigateToPreviousRecordSingleRecordAction.ts index f1287a68fee4..25000420c928 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useNavigateToPreviousRecordSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useNavigateToPreviousRecordSingleRecordAction.ts @@ -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 = diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction.ts index 28382e42528e..57802d53957d 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey.ts new file mode 100644 index 000000000000..536480576f03 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey.ts @@ -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', +} diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/constants/WorkflowSingleRecordActionsConfig.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/constants/WorkflowSingleRecordActionsConfig.ts index c168ffe543a0..b672ec5e82c8 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/constants/WorkflowSingleRecordActionsConfig.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/constants/WorkflowSingleRecordActionsConfig.ts @@ -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, + }, }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateDraftWorkflowSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateDraftWorkflowSingleRecordAction.ts index 79bf61a3c07a..280aa8019c04 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateDraftWorkflowSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateDraftWorkflowSingleRecordAction.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateLastPublishedVersionWorkflowSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateLastPublishedVersionWorkflowSingleRecordAction.ts index c107d3d51fd1..32f84f6736cb 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateLastPublishedVersionWorkflowSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateLastPublishedVersionWorkflowSingleRecordAction.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useDeactivateWorkflowSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useDeactivateWorkflowSingleRecordAction.ts index 731bd6a4f4d6..dd61f50de5eb 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useDeactivateWorkflowSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useDeactivateWorkflowSingleRecordAction.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useDiscardDraftWorkflowSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useDiscardDraftWorkflowSingleRecordAction.ts index d91532c3bac6..a1fe61b51eca 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useDiscardDraftWorkflowSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useDiscardDraftWorkflowSingleRecordAction.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeActiveVersionWorkflowSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeActiveVersionWorkflowSingleRecordAction.ts index 95264cf29d27..678a197f8917 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeActiveVersionWorkflowSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeActiveVersionWorkflowSingleRecordAction.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeRunsWorkflowSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeRunsWorkflowSingleRecordAction.ts index 4c7da08932d8..6eb4bda1e707 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeRunsWorkflowSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeRunsWorkflowSingleRecordAction.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeVersionsWorkflowSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeVersionsWorkflowSingleRecordAction.ts index 4ff30bc4351f..f1170edcb04a 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeVersionsWorkflowSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeVersionsWorkflowSingleRecordAction.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useTestWorkflowSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useTestWorkflowSingleRecordAction.ts index 4df8e533bbdc..dd1daeba24f4 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useTestWorkflowSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useTestWorkflowSingleRecordAction.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/types/WorkflowSingleRecordActionsKeys.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/types/WorkflowSingleRecordActionsKeys.ts new file mode 100644 index 000000000000..f40dc9f96897 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/types/WorkflowSingleRecordActionsKeys.ts @@ -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', +} diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/constants/WorkflowVersionsSingleRecordActionsConfig.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/constants/WorkflowVersionsSingleRecordActionsConfig.ts index 659d0e166a84..fa6174ed3877 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/constants/WorkflowVersionsSingleRecordActionsConfig.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/constants/WorkflowVersionsSingleRecordActionsConfig.ts @@ -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, + }, }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeExecutionsWorkflowVersionSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeRunsWorkflowVersionSingleRecordAction.ts similarity index 90% rename from packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeExecutionsWorkflowVersionSingleRecordAction.ts rename to packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeRunsWorkflowVersionSingleRecordAction.ts index 241a1e9fe13c..290317ea4081 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeExecutionsWorkflowVersionSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeRunsWorkflowVersionSingleRecordAction.ts @@ -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)); diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeVersionsWorkflowVersionSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeVersionsWorkflowVersionSingleRecordAction.ts index 8b90991c80a6..738b11fb7d9d 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeVersionsWorkflowVersionSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeVersionsWorkflowVersionSingleRecordAction.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useUseAsDraftWorkflowVersionSingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useUseAsDraftWorkflowVersionSingleRecordAction.tsx index c4c09530be2e..f4ba205de1a2 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useUseAsDraftWorkflowVersionSingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useUseAsDraftWorkflowVersionSingleRecordAction.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/types/WorkflowVersionSingleRecordActionsKeys.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/types/WorkflowVersionSingleRecordActionsKeys.ts new file mode 100644 index 000000000000..d6515b28e4fb --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-version-actions/types/WorkflowVersionSingleRecordActionsKeys.ts @@ -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', +} diff --git a/packages/twenty-front/src/modules/action-menu/actions/types/actionAvailableOn.ts b/packages/twenty-front/src/modules/action-menu/actions/types/ActionAvailableOn.ts similarity index 100% rename from packages/twenty-front/src/modules/action-menu/actions/types/actionAvailableOn.ts rename to packages/twenty-front/src/modules/action-menu/actions/types/ActionAvailableOn.ts diff --git a/packages/twenty-front/src/modules/action-menu/actions/types/ActionHookResult.ts b/packages/twenty-front/src/modules/action-menu/actions/types/ActionHookResult.ts new file mode 100644 index 000000000000..7fa7c3a689b7 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/types/ActionHookResult.ts @@ -0,0 +1,7 @@ +import { ConfirmationModalProps } from '@/ui/layout/modal/components/ConfirmationModal'; + +export type ActionHookResult = { + shouldBeRegistered: boolean; + onClick: () => Promise | void; + ConfirmationModal?: React.ReactElement; +}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/types/singleRecordActionHook.ts b/packages/twenty-front/src/modules/action-menu/actions/types/SingleRecordActionHook.ts similarity index 89% rename from packages/twenty-front/src/modules/action-menu/actions/types/singleRecordActionHook.ts rename to packages/twenty-front/src/modules/action-menu/actions/types/SingleRecordActionHook.ts index 9446939883c8..4bf981332905 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/types/singleRecordActionHook.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/types/SingleRecordActionHook.ts @@ -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 = diff --git a/packages/twenty-front/src/modules/action-menu/actions/types/actionHookResult.ts b/packages/twenty-front/src/modules/action-menu/actions/types/actionHookResult.ts deleted file mode 100644 index aa72e8879151..000000000000 --- a/packages/twenty-front/src/modules/action-menu/actions/types/actionHookResult.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type ActionHookResult = { - shouldBeRegistered: boolean; - onClick: () => void; - ConfirmationModal?: React.ReactElement; -}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/utils/wrapActionInCallbacks.ts b/packages/twenty-front/src/modules/action-menu/actions/utils/wrapActionInCallbacks.ts new file mode 100644 index 000000000000..be82a371c437 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/utils/wrapActionInCallbacks.ts @@ -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; + onActionExecutedCallback?: (action: { key: string }) => Promise | 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, + }; +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx index 8eafd9040de5..603bc34bd2c4 100644 --- a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx @@ -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 && ( {}, + onActionStartedCallback: (action) => { + if (action.key === MultipleRecordsActionKeys.DELETE) { + setIsLoadMoreLocked(true); + } + }, + onActionExecutedCallback: (action) => { + if (action.key === MultipleRecordsActionKeys.DELETE) { + setIsLoadMoreLocked(false); + } + }, }} > {isPageHeaderV2Enabled ? ( diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx index fdafd08d2c9b..80c1080e0d58 100644 --- a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx @@ -28,7 +28,7 @@ export const RecordIndexActionMenuButtons = () => { variant="secondary" accent="default" title={entry.shortLabel} - onClick={() => entry.onClick?.()} + onClick={entry.onClick} ariaLabel={entry.label} /> ))} diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuDropdown.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuDropdown.tsx index ed7fa1d7d5fb..1b91826c20c9 100644 --- a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuDropdown.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuDropdown.tsx @@ -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; diff --git a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts index 0c1482f40b93..2ddf67be666a 100644 --- a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts +++ b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts @@ -2,10 +2,12 @@ import { createContext } from 'react'; type ActionMenuContextType = { isInRightDrawer: boolean; - onActionExecutedCallback: () => void; + onActionStartedCallback?: (action: { key: string }) => Promise | void; + onActionExecutedCallback?: (action: { key: string }) => Promise | void; }; export const ActionMenuContext = createContext({ isInRightDrawer: false, + onActionStartedCallback: () => {}, onActionExecutedCallback: () => {}, }); diff --git a/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts b/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts index cec68dd396c1..2b955d0ca475 100644 --- a/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts +++ b/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts @@ -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) => void; - ConfirmationModal?: ReactElement; + ConfirmationModal?: ReactElement; }; diff --git a/packages/twenty-front/src/modules/activities/components/RichTextEditor.tsx b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx similarity index 68% rename from packages/twenty-front/src/modules/activities/components/RichTextEditor.tsx rename to packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx index bff2bef3cb1b..f871566fff4b 100644 --- a/packages/twenty-front/src/modules/activities/components/RichTextEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx @@ -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 ( - + <> + + + ); }; diff --git a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect.tsx b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect.tsx new file mode 100644 index 000000000000..5ca77b0cd178 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect.tsx @@ -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 <>; +}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useReplaceActivityBlockEditorContent.ts b/packages/twenty-front/src/modules/activities/hooks/useReplaceActivityBlockEditorContent.ts new file mode 100644 index 000000000000..7fbda840c369 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/useReplaceActivityBlockEditorContent.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts new file mode 100644 index 000000000000..0b115f958ecd --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts @@ -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; + 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({ + fields: { + [objectMetadataItem.namePlural]: ( + rootQueryCachedResponse, + { readField, storeFieldName, toReference }, + ) => { + const shouldSkip = !isObjectRecordConnectionWithRefs( + objectMetadataItem.nameSingular, + rootQueryCachedResponse, + ); + + if (shouldSkip) { + return rootQueryCachedResponse; + } + + const rootQueryConnection = rootQueryCachedResponse; + + const { fieldVariables: rootQueryVariables } = + parseApolloStoreFieldName( + storeFieldName, + ); + + const rootQueryCurrentEdges = + readField('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, + }; + }, + }, + }); +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx index bb2c949ff2c0..2d06ba743fea 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx @@ -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 + } /> ), @@ -765,6 +792,7 @@ export const CommandMenu = () => { key, firstHotKey, secondHotKey, + shouldCloseCommandMenuOnClick, } = renderItem(item); return ( @@ -777,6 +805,9 @@ export const CommandMenu = () => { onClick={onClick} firstHotKey={firstHotKey} secondHotKey={secondHotKey} + shouldCloseCommandMenuOnClick={ + shouldCloseCommandMenuOnClick + } /> ); diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx index 0895015e222c..c772da280070 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx @@ -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} /> ); diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuNavigateCommands.ts b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuNavigateCommands.ts index c997ff304795..6089cdcb75f2 100644 --- a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuNavigateCommands.ts +++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuNavigateCommands.ts @@ -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, }, }; diff --git a/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx b/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx index aac286a6e6e9..f9549c7372e9 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx +++ b/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx @@ -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); diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts index 46eea6f6cb6d..f8d178cd3920 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts @@ -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(); diff --git a/packages/twenty-front/src/modules/command-menu/types/Command.ts b/packages/twenty-front/src/modules/command-menu/types/Command.ts index 2f14cc6a10ba..1c23c100c184 100644 --- a/packages/twenty-front/src/modules/command-menu/types/Command.ts +++ b/packages/twenty-front/src/modules/command-menu/types/Command.ts @@ -21,4 +21,5 @@ export type Command = { firstHotKey?: string; secondHotKey?: string; onCommandClick?: () => void; + shouldCloseCommandMenuOnClick?: boolean; }; diff --git a/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPicker.tsx b/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPicker.tsx index a8b58b34f4a7..d64b04cbf163 100644 --- a/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPicker.tsx +++ b/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPicker.tsx @@ -99,6 +99,7 @@ export const FavoriteFolderPicker = ({ toggleFolderSelection={toggleFolderSelection} /> + ); diff --git a/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPickerFooter.tsx b/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPickerFooter.tsx index b43b045a2049..1f60d986c69e 100644 --- a/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPickerFooter.tsx +++ b/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPickerFooter.tsx @@ -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 ( - - - { - setIsNavigationDrawerExpanded(true); - openNavigationSection(); - setIsFavoriteFolderCreating(true); - closeDropdown(); - }} - text="Add folder" - LeftIcon={() => } - /> - - + + { + setIsNavigationDrawerExpanded(true); + openNavigationSection(); + setIsFavoriteFolderCreating(true); + closeDropdown(); + }} + text="Add folder" + LeftIcon={() => } + /> + ); }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts index 404320943eff..f9b5ee597c94 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts @@ -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; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent.tsx index 69eda6e55a00..efb1c84668e7 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent.tsx @@ -62,7 +62,7 @@ export const ObjectOptionsDropdownFieldsContent = () => { showDragGrip={true} /> - + onContentChange('hiddenFields')} LeftIcon={IconEyeOff} diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent.tsx index 15a87ed2fa21..05aa96d8833a 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent.tsx @@ -87,7 +87,7 @@ export const ObjectOptionsDropdownHiddenFieldsContent = () => { closeDropdown(); }} > - + diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx index 99e21cd9d0c7..0cfa046491dc 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx @@ -98,7 +98,7 @@ export const ObjectOptionsDropdownMenuContent = () => { {/** TODO: Should be removed when view settings contains more options */} {viewType === ViewType.Kanban && ( <> - + onContentChange('viewSettings')} LeftIcon={IconLayout} @@ -109,7 +109,7 @@ export const ObjectOptionsDropdownMenuContent = () => { )} - + onContentChange('fields')} LeftIcon={IconTag} diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx index 1143aaf5e953..809237272e03 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx @@ -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 (
diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx index a3fa74cc5aa4..bfa0a901c703 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx @@ -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; @@ -109,16 +110,14 @@ export const FormFieldInput = ({ VariablePicker={VariablePicker} /> ) : isFieldDate(field) ? ( - ) : isFieldDateTime(field) ? ( - void; + VariablePicker?: VariablePickerComponent; +}; + +export const FormDateFieldInput = ({ + label, + defaultValue, + onPersist, + VariablePicker, +}: FormDateFieldInputProps) => { + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInputBase.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInput.tsx similarity index 95% rename from packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInputBase.tsx rename to packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInput.tsx index fb45ea4f9c98..62b61ebe7c7b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInputBase.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInput.tsx @@ -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( 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, }), ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateFieldInput.stories.tsx new file mode 100644 index 000000000000..39a313fb809c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateFieldInput.stories.tsx @@ -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 = { + title: 'UI/Data/Field/Form/Input/FormDateFieldInput', + component: FormDateFieldInput, + args: {}, + argTypes: {}, +}; + +export default meta; + +type Story = StoryObj; + +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 ( + + ); + }, + }, + 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)); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateTimeFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateTimeFieldInput.stories.tsx new file mode 100644 index 000000000000..d3dffde5ddbe --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateTimeFieldInput.stories.tsx @@ -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 = { + title: 'UI/Data/Field/Form/Input/FormDateTimeFieldInput', + component: FormDateTimeFieldInput, + args: {}, + argTypes: {}, +}; + +export default meta; + +type Story = StoryObj; + +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 ( + + ); + }, + }, + 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)); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateTimeFieldInputBase.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateTimeFieldInputBase.stories.tsx deleted file mode 100644 index cad93289e0c6..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateTimeFieldInputBase.stories.tsx +++ /dev/null @@ -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 = { - title: 'UI/Data/Field/Form/Input/FormDateTimeFieldInputBase', - component: FormDateTimeFieldInputBase, - args: {}, - argTypes: {}, -}; - -export default meta; - -type Story = StoryObj; - -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 ( - - ); - }, - }, - 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 ( - - ); - }, - }, - 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)); - }, -}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 8b239017f8e9..532c9a9f213b 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -255,7 +255,9 @@ export const RecordIndexContainer = () => { )} - {!isPageHeaderV2Enabled && } + {!isPageHeaderV2Enabled && ( + + )} diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx index c2d6584ff391..757eb4715c74 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx @@ -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 && ( <> - + )} diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState.ts new file mode 100644 index 000000000000..dbdb3a0f3b42 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState.ts @@ -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({ + key: 'isRecordIndexLoadMoreLockedComponentState', + componentInstanceContext: ViewComponentInstanceContext, + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx index 3ef7983c0f5c..3a73c095c908 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderPlusButtonContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderPlusButtonContent.tsx index 95db8d71f28b..a6c628bcfe15 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderPlusButtonContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderPlusButtonContent.tsx @@ -53,7 +53,7 @@ export const RecordTableHeaderPlusButtonContent = () => { ))} - + { currentRecordGroupId, ); + const isLoadMoreLocked = useRecoilComponentValueV2( + isRecordIndexLoadMoreLockedComponentState, + ); + const recordIds = useRecoilComponentValueV2( recordIndexAllRecordIdsComponentSelector, ); @@ -28,7 +33,7 @@ export const RecordTableRecordGroupSectionLoadMore = () => { fetchMoreRecords(); }; - if (hasFetchedAllRecords) { + if (hasFetchedAllRecords || isLoadMoreLocked) { return null; } diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultiRecordSelect.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultiRecordSelect.tsx index f9ea16c11330..f5348821cd01 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultiRecordSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultiRecordSelect.tsx @@ -144,7 +144,7 @@ export const MultiRecordSelect = ({ {dropdownPlacement?.includes('end') && ( <> {isDefined(onCreate) && ( - + {createNewButton} )} @@ -181,7 +181,7 @@ export const MultiRecordSelect = ({ )} {isDefined(onCreate) && ( - + {createNewButton} )} diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleRecordSelectMenuItemsWithSearch.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleRecordSelectMenuItemsWithSearch.tsx index a9331c4c7184..a971eecff288 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleRecordSelectMenuItemsWithSearch.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleRecordSelectMenuItemsWithSearch.tsx @@ -69,7 +69,7 @@ export const SingleRecordSelectMenuItemsWithSearch = ({ <> {dropdownPlacement?.includes('end') && ( <> - + {createNewButton} {records.recordsToSelect.length > 0 && } @@ -117,7 +117,7 @@ export const SingleRecordSelectMenuItemsWithSearch = ({ )} {isDefined(onCreate) && ( - + {createNewButton} )} diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx index ffe61d6c4f5c..c55fb4e0acdb 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx @@ -8,7 +8,6 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { useMemo } from 'react'; import { ColorSample, IconCheck, @@ -21,7 +20,6 @@ import { MenuItem, MenuItemSelectColor, } from 'twenty-ui'; -import { v4 } from 'uuid'; import { computeOptionValueFromLabel } from '~/pages/settings/data-model/utils/compute-option-value-from-label.utils'; type SettingsDataModelFieldSelectFormOptionRowProps = { @@ -81,17 +79,14 @@ export const SettingsDataModelFieldSelectFormOptionRow = ({ }: SettingsDataModelFieldSelectFormOptionRowProps) => { const theme = useTheme(); - const dropdownIds = useMemo(() => { - const baseScopeId = `select-field-option-row-${v4()}`; - return { - color: `${baseScopeId}-color`, - actions: `${baseScopeId}-actions`, - }; - }, []); + const SELECT_COLOR_DROPDOWN_ID = `select-color-dropdown-${option.id}`; + const SELECT_ACTIONS_DROPDOWN_ID = `select-actions-dropdown-${option.id}`; - const { closeDropdown: closeColorDropdown } = useDropdown(dropdownIds.color); + const { closeDropdown: closeColorDropdown } = useDropdown( + SELECT_COLOR_DROPDOWN_ID, + ); const { closeDropdown: closeActionsDropdown } = useDropdown( - dropdownIds.actions, + SELECT_ACTIONS_DROPDOWN_ID, ); const handleInputEnter = () => { @@ -120,28 +115,26 @@ export const SettingsDataModelFieldSelectFormOptionRow = ({ /> } dropdownComponents={ - - - {MAIN_COLOR_NAMES.map((colorName) => ( - { - onChange({ ...option, color: colorName }); - closeColorDropdown(); - }} - color={colorName} - selected={colorName === option.color} - /> - ))} - - + + {MAIN_COLOR_NAMES.map((colorName) => ( + { + onChange({ ...option, color: colorName }); + closeColorDropdown(); + }} + color={colorName} + selected={colorName === option.color} + /> + ))} + } /> diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectSummaryCard.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectSummaryCard.tsx index f818cded810f..5567a3bf9ac2 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectSummaryCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectSummaryCard.tsx @@ -16,7 +16,6 @@ import { SettingsSummaryCard } from '@/settings/components/SettingsSummaryCard'; import { SettingsDataModelObjectTypeTag } from '@/settings/data-model/objects/components/SettingsDataModelObjectTypeTag'; import { getObjectTypeLabel } from '@/settings/data-model/utils/getObjectTypeLabel'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; @@ -86,21 +85,20 @@ export const SettingsObjectSummaryCard = ({ accent="tertiary" /> } + dropdownMenuWidth={160} dropdownComponents={ - - - - - - + + + + } dropdownHotkeyScope={{ scope: dropdownId, diff --git a/packages/twenty-front/src/modules/settings/integrations/database-connection/components/SettingsIntegrationDatabaseConnectionSummaryCard.tsx b/packages/twenty-front/src/modules/settings/integrations/database-connection/components/SettingsIntegrationDatabaseConnectionSummaryCard.tsx index 8d0b1bbc1f97..26a56c983a55 100644 --- a/packages/twenty-front/src/modules/settings/integrations/database-connection/components/SettingsIntegrationDatabaseConnectionSummaryCard.tsx +++ b/packages/twenty-front/src/modules/settings/integrations/database-connection/components/SettingsIntegrationDatabaseConnectionSummaryCard.tsx @@ -1,7 +1,6 @@ import { SettingsSummaryCard } from '@/settings/components/SettingsSummaryCard'; import { SettingsIntegrationDatabaseConnectionSyncStatus } from '@/settings/integrations/database-connection/components/SettingsIntegrationDatabaseConnectionSyncStatus'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import styled from '@emotion/styled'; import { @@ -64,18 +63,16 @@ export const SettingsIntegrationDatabaseConnectionSummaryCard = ({ } dropdownComponents={ - - - - - - - - + + + + + + } /> diff --git a/packages/twenty-front/src/modules/ui/input/components/Select.tsx b/packages/twenty-front/src/modules/ui/input/components/Select.tsx index 9c60672106cb..929eed1c02b1 100644 --- a/packages/twenty-front/src/modules/ui/input/components/Select.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/Select.tsx @@ -165,7 +165,7 @@ export const Select = ({ )} {!!callToActionButton && ( - + { const id = useId(); - return withoutScrollWrapper === true ? ( + return scrollable !== true ? ( { const theme = useTheme(); const windowsWidth = useScreenSize().width; const showAuthModal = useShowAuthModal(); + const { toggleCommandMenu } = useCommandMenu(); const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED'); @@ -96,10 +99,17 @@ export const DefaultLayout = () => { - - {isWorkflowEnabled && } - - + + + {isWorkflowEnabled && } + + + diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageActivityContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageActivityContainer.tsx index 93825d42525e..2b17eebac232 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageActivityContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageActivityContainer.tsx @@ -1,4 +1,4 @@ -import { RichTextEditor } from '@/activities/components/RichTextEditor'; +import { ActivityRichTextEditor } from '@/activities/components/ActivityRichTextEditor'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading'; @@ -28,9 +28,8 @@ export const ShowPageActivityContainer = ({ componentInstanceId={`scroll-wrapper-tab-list-${targetableObject.id}`} > - - - handleSelect(CoreObjectNameSingular.Note)} - accent="default" - LeftIcon={IconNotes} - text="Note" - /> - handleSelect(CoreObjectNameSingular.Task)} - accent="default" - LeftIcon={IconCheckbox} - text="Task" - /> - - + + handleSelect(CoreObjectNameSingular.Note)} + accent="default" + LeftIcon={IconNotes} + text="Note" + /> + handleSelect(CoreObjectNameSingular.Task)} + accent="default" + LeftIcon={IconCheckbox} + text="Task" + /> + } dropdownHotkeyScope={{ scope: PageHotkeyScope.ShowPage, diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx index 80c69b86a8b6..ed706f726a93 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx @@ -7,13 +7,13 @@ import { SummaryCard } from '@/object-record/record-show/components/SummaryCard' import { RecordLayout } from '@/object-record/record-show/types/RecordLayout'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter'; import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer'; import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList'; import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import styled from '@emotion/styled'; import { useRecoilState, useRecoilValue } from 'recoil'; -import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter'; const StyledShowPageRightContainer = styled.div<{ isMobile: boolean }>` display: flex; @@ -65,7 +65,7 @@ export const ShowPageSubContainer = ({ isNewRightDrawerItemLoading = false, }: ShowPageSubContainerProps) => { const { activeTabId } = useTabList( - `${TAB_LIST_COMPONENT_ID}-${isInRightDrawer}`, + `${TAB_LIST_COMPONENT_ID}-${isInRightDrawer}-${targetableObject.id}`, ); const isMobile = useIsMobile(); @@ -128,7 +128,7 @@ export const ShowPageSubContainer = ({ diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx index eed58ea01fda..75c645db2245 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx @@ -111,15 +111,15 @@ export const NavigationDrawer = ({ onMouseEnter={handleHover} onMouseLeave={handleMouseLeave} > - {isSettingsDrawer && title - ? !isMobile && - : logo && ( - - )} + {isSettingsDrawer && title ? ( + !isMobile && + ) : ( + + )} {children} diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx index 1d924f55c413..d3916a1ba0c7 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx @@ -183,6 +183,9 @@ const StyledSpacer = styled.span` const StyledIcon = styled.div` flex-shrink: 0; flex-grow: 0; + display: flex; + align-items: center; + justify-content: center; margin-right: ${({ theme }) => theme.spacing(2)}; `; @@ -208,7 +211,6 @@ const visibleStateStyles = css` const StyledRightOptionsVisbility = styled.div<{ isMobile: boolean; - active: boolean; }>` display: block; opacity: 0; @@ -221,7 +223,7 @@ const StyledRightOptionsVisbility = styled.div<{ height: 1px; width: 1px; - ${({ isMobile, active }) => (isMobile || active) && visibleStateStyles} + ${({ isMobile }) => isMobile && visibleStateStyles} .navigation-drawer-item:hover & { ${visibleStateStyles} @@ -343,10 +345,7 @@ export const NavigationDrawerItem = ({ e.preventDefault(); }} > - + {rightOptions} diff --git a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx index e9001faf7508..4d0e22b108b5 100644 --- a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx +++ b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx @@ -112,15 +112,13 @@ export const UpdateViewButtonGroup = ({ /> } dropdownComponents={ - <> - - - - + + + } /> diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx index 84f655a28b30..27618cea7a53 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx @@ -190,7 +190,7 @@ export const ViewPickerContentCreateMode = () => { )} - + diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentEditMode.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentEditMode.tsx index 1e3b588f96e2..41dc8b6da6f4 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentEditMode.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentEditMode.tsx @@ -89,7 +89,7 @@ export const ViewPickerContentEditMode = () => { - + diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx index 85e0619876dc..aae3dc2d7113 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx @@ -97,7 +97,7 @@ export const ViewPickerListContent = () => { /> - + { + const apolloClient = useApolloClient(); const { deleteOneRecord } = useDeleteOneRecord({ objectNameSingular: CoreObjectNameSingular.WorkflowVersion, }); + const getWorkflowVersionFromCache = useGetRecordFromCache({ + objectNameSingular: CoreObjectNameSingular.WorkflowVersion, + }); + const getWorkflowFromCache = useGetRecordFromCache({ + objectNameSingular: CoreObjectNameSingular.Workflow, + }); const deleteOneWorkflowVersion = async ({ workflowVersionId, @@ -12,6 +22,32 @@ export const useDeleteOneWorkflowVersion = () => { workflowVersionId: string; }) => { await deleteOneRecord(workflowVersionId); + + const cachedWorkflowVersion = + getWorkflowVersionFromCache(workflowVersionId); + + if (!cachedWorkflowVersion) { + return; + } + + const cachedWorkflow = getWorkflowFromCache( + cachedWorkflowVersion.workflowId, + ); + + if (!cachedWorkflow) { + return; + } + + apolloClient.cache.modify({ + id: apolloClient.cache.identify(cachedWorkflow), + fields: { + versions: () => { + return cachedWorkflow.versions.filter( + (version) => version.id !== workflowVersionId, + ); + }, + }, + }); }; return { deleteOneWorkflowVersion }; diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts index 1ab5b849f46b..e25e01cdb03c 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -10,7 +10,7 @@ import { ConfirmationQuestion } from 'src/database/commands/questions/confirmati import { UpgradeTo0_32CommandModule } from 'src/database/commands/upgrade-version/0-32/0-32-upgrade-version.module'; import { UpgradeTo0_33CommandModule } from 'src/database/commands/upgrade-version/0-33/0-33-upgrade-version.module'; import { UpgradeTo0_34CommandModule } from 'src/database/commands/upgrade-version/0-34/0-34-upgrade-version.module'; -import { UpgradeTo0_40CommandModule } from 'src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module'; +import { UpgradeTo0_35CommandModule } from 'src/database/commands/upgrade-version/0-35/0-35-upgrade-version.module'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; @@ -52,7 +52,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp UpgradeTo0_32CommandModule, UpgradeTo0_33CommandModule, UpgradeTo0_34CommandModule, - UpgradeTo0_40CommandModule, + UpgradeTo0_35CommandModule, FeatureFlagModule, ], providers: [ diff --git a/packages/twenty-server/src/database/commands/logger.ts b/packages/twenty-server/src/database/commands/logger.ts index 9bd2ebb02011..722cdb224461 100644 --- a/packages/twenty-server/src/database/commands/logger.ts +++ b/packages/twenty-server/src/database/commands/logger.ts @@ -5,42 +5,44 @@ interface CommandLoggerOptions { constructorName: string; } +export const isCommandLogger = ( + logger: Logger | CommandLogger, +): logger is CommandLogger => { + return typeof logger['setVerbose'] === 'function'; +}; + export class CommandLogger { private logger: Logger; - private verbose: boolean; + private verboseFlag: boolean; constructor(options: CommandLoggerOptions) { this.logger = new Logger(options.constructorName); - this.verbose = options.verbose ?? true; + this.verboseFlag = options.verbose ?? false; } - log(message: string, context?: string) { - if (this.verbose) { - this.logger.log(message, context); - } + log(message: string, ...optionalParams: [...any, string?]) { + this.logger.log(message, ...optionalParams); } error(message: string, stack?: string, context?: string) { - if (this.verbose) { - this.logger.error(message, stack, context); - } + this.logger.error(message, stack, context); } warn(message: string, context?: string) { - if (this.verbose) { - this.logger.warn(message, context); - } + this.logger.warn(message, context); } debug(message: string, context?: string) { - if (this.verbose) { - this.logger.debug(message, context); - } + this.logger.debug(message, context); } - verboseLog(message: string, context?: string) { - if (this.verbose) { - this.logger.verbose(message, context); + verbose(message: string, ...optionalParams: [...any, string?]) { + if (this.verboseFlag) { + this.logger.log(message, ...optionalParams); } } + + setVerbose(flag: boolean) { + this.verboseFlag = flag; + } } diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-create-column.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-create-column.command.ts similarity index 88% rename from packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-create-column.command.ts rename to packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-create-column.command.ts index ea3750ab32e8..21fe77a6de86 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-create-column.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-create-column.command.ts @@ -9,12 +9,12 @@ import { ActiveWorkspacesCommandOptions, ActiveWorkspacesCommandRunner, } from 'src/database/commands/active-workspaces.command'; +import { isCommandLogger } from 'src/database/commands/logger'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { FieldMetadataEntity, FieldMetadataType, } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service'; import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; import { @@ -29,7 +29,7 @@ import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/wo import { isDefined } from 'src/utils/is-defined'; @Command({ - name: 'upgrade-0.40:phone-calling-code-create-column', + name: 'upgrade-0.35:phone-calling-code-create-column', description: 'Create the callingCode column', }) export class PhoneCallingCodeCreateColumnCommand extends ActiveWorkspacesCommandRunner { @@ -38,8 +38,6 @@ export class PhoneCallingCodeCreateColumnCommand extends ActiveWorkspacesCommand protected readonly workspaceRepository: Repository, @InjectRepository(FieldMetadataEntity, 'metadata') private readonly fieldMetadataRepository: Repository, - @InjectRepository(ObjectMetadataEntity, 'metadata') - private readonly objectMetadataRepository: Repository, private readonly workspaceMigrationService: WorkspaceMigrationService, private readonly workspaceMigrationFactory: WorkspaceMigrationFactory, private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, @@ -56,16 +54,19 @@ export class PhoneCallingCodeCreateColumnCommand extends ActiveWorkspacesCommand this.logger.log( 'Running command to add calling code and change country code with default one', ); + if (isCommandLogger(this.logger)) { + this.logger.setVerbose(options.verbose ?? false); + } - this.logger.log(`Part 1 - Workspace`); + this.logger.verbose(`Part 1 - Workspace`); let workspaceIterator = 1; for (const workspaceId of workspaceIds) { - this.logger.log( + this.logger.verbose( `Running command for workspace ${workspaceId} ${workspaceIterator}/${workspaceIds.length}`, ); - this.logger.log( + this.logger.verbose( `P1 Step 1 - let's find all the fieldsMetadata that have the PHONES type, and extract the objectMetadataId`, ); @@ -80,19 +81,20 @@ export class PhoneCallingCodeCreateColumnCommand extends ActiveWorkspacesCommand for (const phoneFieldMetadata of phonesFieldMetadata) { if ( - isDefined(phoneFieldMetadata?.name && phoneFieldMetadata.object) + isDefined(phoneFieldMetadata?.name) && + isDefined(phoneFieldMetadata.object) ) { - this.logger.log( + this.logger.verbose( `P1 Step 1 - Let's find the "nameSingular" of this objectMetadata: ${phoneFieldMetadata.object.nameSingular || 'not found'}`, ); if (!phoneFieldMetadata.object?.nameSingular) continue; - this.logger.log( + this.logger.verbose( `P1 Step 1 - Create migration for field ${phoneFieldMetadata.name}`, ); - if (options.dryRun === true) { + if (options.dryRun) { continue; } await this.workspaceMigrationService.createCustomMigration( @@ -123,7 +125,7 @@ export class PhoneCallingCodeCreateColumnCommand extends ActiveWorkspacesCommand } } - this.logger.log( + this.logger.verbose( `P1 Step 1 - RUN migration to create callingCodes for ${workspaceId.slice(0, 5)}`, ); await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( @@ -134,7 +136,7 @@ export class PhoneCallingCodeCreateColumnCommand extends ActiveWorkspacesCommand workspaceId, ); } catch (error) { - console.log(`Error in workspace ${workspaceId} : ${error}`); + this.logger.log(`Error in workspace ${workspaceId} : ${error}`); } workspaceIterator++; } diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-migrate-data.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-migrate-data.command.ts similarity index 87% rename from packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-migrate-data.command.ts rename to packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-migrate-data.command.ts index ac60af59bc90..67bf658918d0 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-migrate-data.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-migrate-data.command.ts @@ -10,6 +10,7 @@ import { ActiveWorkspacesCommandOptions, ActiveWorkspacesCommandRunner, } from 'src/database/commands/active-workspaces.command'; +import { isCommandLogger } from 'src/database/commands/logger'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { FieldMetadataEntity, @@ -53,7 +54,7 @@ const isCallingCode = (callingCode: string): boolean => { }; @Command({ - name: 'upgrade-0.40:phone-calling-code-migrate-data', + name: 'upgrade-0.35:phone-calling-code-migrate-data', description: 'Add calling code and change country code with default one', }) export class PhoneCallingCodeMigrateDataCommand extends ActiveWorkspacesCommandRunner { @@ -82,7 +83,10 @@ export class PhoneCallingCodeMigrateDataCommand extends ActiveWorkspacesCommandR 'Running command to add calling code and change country code with default one', ); - this.logger.log(`Part 1 - Workspace`); + if (isCommandLogger(this.logger)) { + this.logger.setVerbose(options.verbose ?? false); + } + this.logger.verbose(`Part 1 - Workspace`); let workspaceIterator = 1; @@ -91,7 +95,7 @@ export class PhoneCallingCodeMigrateDataCommand extends ActiveWorkspacesCommandR `Running command for workspace ${workspaceId} ${workspaceIterator}/${workspaceIds.length}`, ); - this.logger.log( + this.logger.verbose( `P1 Step 1 - let's find all the fieldsMetadata that have the PHONES type, and extract the objectMetadataId`, ); @@ -109,16 +113,16 @@ export class PhoneCallingCodeMigrateDataCommand extends ActiveWorkspacesCommandR isDefined(phoneFieldMetadata?.name) && isDefined(phoneFieldMetadata.object) ) { - this.logger.log( + this.logger.verbose( `P1 Step 1 - Let's find the "nameSingular" of this objectMetadata: ${phoneFieldMetadata.object.nameSingular || 'not found'}`, ); if (!phoneFieldMetadata.object?.nameSingular) continue; - this.logger.log( + this.logger.verbose( `P1 Step 1 - Create migration for field ${phoneFieldMetadata.name}`, ); - if (options.dryRun === false) { + if (!options.dryRun) { await this.workspaceMigrationService.createCustomMigration( generateMigrationName( `create-${phoneFieldMetadata.object.nameSingular}PrimaryPhoneCallingCode-for-field-${phoneFieldMetadata.name}`, @@ -148,7 +152,7 @@ export class PhoneCallingCodeMigrateDataCommand extends ActiveWorkspacesCommandR } } - this.logger.log( + this.logger.verbose( `P1 Step 1 - RUN migration to create callingCodes for ${workspaceId.slice(0, 5)}`, ); await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( @@ -159,20 +163,20 @@ export class PhoneCallingCodeMigrateDataCommand extends ActiveWorkspacesCommandR workspaceId, ); - this.logger.log( + this.logger.verbose( `P1 Step 2 - Migrations for callingCode must be first. Now can use twentyORMGlobalManager to update countryCode`, ); - this.logger.log( + this.logger.verbose( `P1 Step 3 (same time) - update CountryCode to letters: +33 => FR || +1 => US (if mulitple, first one)`, ); - this.logger.log( + this.logger.verbose( `P1 Step 4 (same time) - update all additioanl phones to add a country code following the same logic`, ); for (const phoneFieldMetadata of phonesFieldMetadata) { - this.logger.log(`P1 Step 2 - for ${phoneFieldMetadata.name}`); + this.logger.verbose(`P1 Step 2 - for ${phoneFieldMetadata.name}`); if ( isDefined(phoneFieldMetadata) && isDefined(phoneFieldMetadata.name) @@ -209,7 +213,7 @@ export class PhoneCallingCodeMigrateDataCommand extends ActiveWorkspacesCommandR }; }); } - if (options.dryRun === false) { + if (!options.dryRun) { await repository.update(record.id, { [`${phoneFieldMetadata.name}PrimaryPhoneCallingCode`]: record[phoneFieldMetadata.name].primaryPhoneCountryCode, @@ -226,12 +230,12 @@ export class PhoneCallingCodeMigrateDataCommand extends ActiveWorkspacesCommandR } } } catch (error) { - console.log(`Error in workspace ${workspaceId} : ${error}`); + this.logger.log(`Error in workspace ${workspaceId} : ${error}`); } workspaceIterator++; } - this.logger.log(` + this.logger.verbose(` Part 2 - FieldMetadata`); @@ -241,7 +245,7 @@ export class PhoneCallingCodeMigrateDataCommand extends ActiveWorkspacesCommandR `Running command for workspace ${workspaceId} ${workspaceIterator}/${workspaceIds.length}`, ); - this.logger.log( + this.logger.verbose( `P2 Step 1 - let's find all the fieldsMetadata that have the PHONES type, and extract the objectMetadataId`, ); @@ -276,24 +280,26 @@ export class PhoneCallingCodeMigrateDataCommand extends ActiveWorkspacesCommandR primaryPhoneCountryCode.replace(/["']/g, ''), ); - if (options.dryRun === false) { - await this.fieldMetadataRepository.update(phoneFieldMetadata.id, { - defaultValue: { - ...defaultValue, - primaryPhoneCountryCode: countryCode - ? `'${countryCode}'` - : "''", - primaryPhoneCallingCode: isCallingCode( - primaryPhoneCountryCode.replace(/["']/g, ''), - ) - ? primaryPhoneCountryCode - : "''", - }, - }); + if (!options.dryRun) { + if (!defaultValue.primaryPhoneCallingCode) { + await this.fieldMetadataRepository.update(phoneFieldMetadata.id, { + defaultValue: { + ...defaultValue, + primaryPhoneCountryCode: countryCode + ? `'${countryCode}'` + : "''", + primaryPhoneCallingCode: isCallingCode( + primaryPhoneCountryCode.replace(/["']/g, ''), + ) + ? primaryPhoneCountryCode + : "''", + }, + }); + } } } } catch (error) { - console.log(`Error in workspace ${workspaceId} : ${error}`); + this.logger.log(`Error in workspace ${workspaceId} : ${error}`); } workspaceIterator++; } diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-record-position-backfill.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-record-position-backfill.command.ts similarity index 76% rename from packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-record-position-backfill.command.ts rename to packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-record-position-backfill.command.ts index b889c904cd1b..7a342b920eb4 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-record-position-backfill.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-record-position-backfill.command.ts @@ -9,7 +9,7 @@ import { RecordPositionBackfillService } from 'src/engine/api/graphql/workspace- import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Command({ - name: 'migrate-0.40:backfill-record-position', + name: 'upgrade-0.35:record-position-backfill', description: 'Backfill record position', }) export class RecordPositionBackfillCommand extends ActiveWorkspacesCommandRunner { @@ -27,10 +27,16 @@ export class RecordPositionBackfillCommand extends ActiveWorkspacesCommandRunner workspaceIds: string[], ): Promise { for (const workspaceId of workspaceIds) { - await this.recordPositionBackfillService.backfill( - workspaceId, - options.dryRun ?? false, - ); + try { + await this.recordPositionBackfillService.backfill( + workspaceId, + options.dryRun ?? false, + ); + } catch (error) { + this.logger.error( + `Error backfilling record position for workspace ${workspaceId}: ${error}`, + ); + } } } } diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-upgrade-version.command.ts similarity index 86% rename from packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command.ts rename to packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-upgrade-version.command.ts index f0063834a52d..7f0d7cdb08ba 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-upgrade-version.command.ts @@ -5,18 +5,18 @@ import { Repository } from 'typeorm'; import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command'; import { BaseCommandOptions } from 'src/database/commands/base.command'; -import { PhoneCallingCodeCreateColumnCommand } from 'src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-create-column.command'; -import { PhoneCallingCodeMigrateDataCommand } from 'src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-migrate-data.command'; -import { RecordPositionBackfillCommand } from 'src/database/commands/upgrade-version/0-40/0-40-record-position-backfill.command'; -import { ViewGroupNoValueBackfillCommand } from 'src/database/commands/upgrade-version/0-40/0-40-view-group-no-value-backfill.command'; +import { PhoneCallingCodeCreateColumnCommand } from 'src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-create-column.command'; +import { PhoneCallingCodeMigrateDataCommand } from 'src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-migrate-data.command'; +import { RecordPositionBackfillCommand } from 'src/database/commands/upgrade-version/0-35/0-35-record-position-backfill.command'; +import { ViewGroupNoValueBackfillCommand } from 'src/database/commands/upgrade-version/0-35/0-35-view-group-no-value-backfill.command'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command'; @Command({ - name: 'upgrade-0.40', - description: 'Upgrade to 0.40', + name: 'upgrade-0.35', + description: 'Upgrade to 0.35', }) -export class UpgradeTo0_40Command extends ActiveWorkspacesCommandRunner { +export class UpgradeTo0_35Command extends ActiveWorkspacesCommandRunner { constructor( @InjectRepository(Workspace, 'core') protected readonly workspaceRepository: Repository, @@ -38,19 +38,19 @@ export class UpgradeTo0_40Command extends ActiveWorkspacesCommandRunner { 'Running command to upgrade to 0.40: must start with phone calling code otherwise SyncMetadata will fail', ); - await this.phoneCallingCodeCreateColumnCommand.executeActiveWorkspacesCommand( + await this.recordPositionBackfillCommand.executeActiveWorkspacesCommand( passedParam, options, workspaceIds, ); - await this.phoneCallingCodeMigrateDataCommand.executeActiveWorkspacesCommand( + await this.phoneCallingCodeCreateColumnCommand.executeActiveWorkspacesCommand( passedParam, options, workspaceIds, ); - await this.recordPositionBackfillCommand.executeActiveWorkspacesCommand( + await this.phoneCallingCodeMigrateDataCommand.executeActiveWorkspacesCommand( passedParam, options, workspaceIds, diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-upgrade-version.module.ts similarity index 84% rename from packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module.ts rename to packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-upgrade-version.module.ts index b19780ed8a17..aba95d1bb4d2 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-upgrade-version.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { PhoneCallingCodeCreateColumnCommand } from 'src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-create-column.command'; -import { PhoneCallingCodeMigrateDataCommand } from 'src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-migrate-data.command'; -import { RecordPositionBackfillCommand } from 'src/database/commands/upgrade-version/0-40/0-40-record-position-backfill.command'; -import { UpgradeTo0_40Command } from 'src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command'; -import { ViewGroupNoValueBackfillCommand } from 'src/database/commands/upgrade-version/0-40/0-40-view-group-no-value-backfill.command'; +import { PhoneCallingCodeCreateColumnCommand } from 'src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-create-column.command'; +import { PhoneCallingCodeMigrateDataCommand } from 'src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-migrate-data.command'; +import { RecordPositionBackfillCommand } from 'src/database/commands/upgrade-version/0-35/0-35-record-position-backfill.command'; +import { UpgradeTo0_35Command } from 'src/database/commands/upgrade-version/0-35/0-35-upgrade-version.command'; +import { ViewGroupNoValueBackfillCommand } from 'src/database/commands/upgrade-version/0-35/0-35-view-group-no-value-backfill.command'; import { RecordPositionBackfillModule } from 'src/engine/api/graphql/workspace-query-runner/services/record-position-backfill-module'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; @@ -35,12 +35,12 @@ import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manage FieldMetadataModule, ], providers: [ - UpgradeTo0_40Command, - PhoneCallingCodeMigrateDataCommand, + UpgradeTo0_35Command, PhoneCallingCodeCreateColumnCommand, + PhoneCallingCodeMigrateDataCommand, WorkspaceMigrationFactory, RecordPositionBackfillCommand, ViewGroupNoValueBackfillCommand, ], }) -export class UpgradeTo0_40CommandModule {} +export class UpgradeTo0_35CommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-view-group-no-value-backfill.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-view-group-no-value-backfill.command.ts similarity index 54% rename from packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-view-group-no-value-backfill.command.ts rename to packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-view-group-no-value-backfill.command.ts index ed66287ff574..fabdf1154b7c 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-view-group-no-value-backfill.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-35/0-35-view-group-no-value-backfill.command.ts @@ -13,7 +13,7 @@ import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; @Command({ - name: 'migrate-0.40:backfill-view-group-no-value', + name: 'migrate-0.35:backfill-view-group-no-value', description: 'Backfill view group no value', }) export class ViewGroupNoValueBackfillCommand extends ActiveWorkspacesCommandRunner { @@ -34,47 +34,53 @@ export class ViewGroupNoValueBackfillCommand extends ActiveWorkspacesCommandRunn workspaceIds: string[], ): Promise { for (const workspaceId of workspaceIds) { - const viewRepository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - 'view', - ); + try { + const viewRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'view', + ); - const viewGroupRepository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - 'viewGroup', - ); + const viewGroupRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'viewGroup', + ); - const views = await viewRepository.find({ - relations: ['viewGroups'], - }); + const views = await viewRepository.find({ + relations: ['viewGroups'], + }); - for (const view of views) { - if (view.viewGroups.length === 0) { - continue; - } + for (const view of views) { + if (view.viewGroups.length === 0) { + continue; + } - // We're assuming for now that all viewGroups belonging to the same view have the same fieldMetadataId - const viewGroup = view.viewGroups?.[0]; - const fieldMetadataId = viewGroup?.fieldMetadataId; + // We're assuming for now that all viewGroups belonging to the same view have the same fieldMetadataId + const viewGroup = view.viewGroups?.[0]; + const fieldMetadataId = viewGroup?.fieldMetadataId; - if (!fieldMetadataId || !viewGroup) { - continue; - } + if (!fieldMetadataId || !viewGroup) { + continue; + } - const fieldMetadata = await this.fieldMetadataRepository.findOne({ - where: { id: viewGroup.fieldMetadataId }, - }); + const fieldMetadata = await this.fieldMetadataRepository.findOne({ + where: { id: viewGroup.fieldMetadataId }, + }); - if (!fieldMetadata) { - continue; - } + if (!fieldMetadata) { + continue; + } - await this.fieldMetadataRelatedRecordsService.syncNoValueViewGroup( - fieldMetadata, - view, - viewGroupRepository, + await this.fieldMetadataRelatedRecordsService.syncNoValueViewGroup( + fieldMetadata, + view, + viewGroupRepository, + ); + } + } catch (error) { + this.logger.error( + `Error backfilling view group no value for workspace ${workspaceId}: ${error}`, ); } } diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts index ea8edafb8f67..1707e74effff 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts @@ -22,6 +22,7 @@ import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspac import { CalendarEventParticipantManagerModule } from 'src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module'; import { CalendarModule } from 'src/modules/calendar/calendar.module'; import { AutoCompaniesAndContactsCreationJobModule } from 'src/modules/contact-creation-manager/jobs/auto-companies-and-contacts-creation-job.module'; +import { FavoriteModule } from 'src/modules/favorite/favorite.module'; import { MessagingModule } from 'src/modules/messaging/messaging.module'; import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module'; import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module'; @@ -50,6 +51,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module'; TimelineJobModule, WebhookJobModule, WorkflowModule, + FavoriteModule, ], providers: [ CleanInactiveWorkspaceJob, diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/message-queue.constants.ts b/packages/twenty-server/src/engine/core-modules/message-queue/message-queue.constants.ts index 42262c1510cb..e6f0f11a4314 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/message-queue.constants.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/message-queue.constants.ts @@ -18,4 +18,5 @@ export enum MessageQueue { testQueue = 'test-queue', workflowQueue = 'workflow-queue', serverlessFunctionQueue = 'serverless-function-queue', + deleteCascadeQueue = 'delete-cascade-queue', } diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/base-schema.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/base-schema.utils.ts index 059f5ae291c3..1cb8ff05e21e 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/base-schema.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/base-schema.utils.ts @@ -9,17 +9,18 @@ export const baseSchema = ( serverUrl: string, ): OpenAPIV3_1.Document => { return { - openapi: '3.0.3', + openapi: '3.1.1', info: { title: 'Twenty Api', - description: `This is a **Twenty REST/API** playground based on the **OpenAPI 3.0 specification**.`, - termsOfService: 'https://github.com/twentyhq/twenty?tab=coc-ov-file', + description: `This is a **Twenty REST/API** playground based on the **OpenAPI 3.1 specification**.`, + termsOfService: + 'https://github.com/twentyhq/twenty?tab=coc-ov-file#readme', contact: { email: 'felix@twenty.com', }, license: { name: 'AGPL-3.0', - url: 'https://github.com/twentyhq/twenty?tab=AGPL-3.0-1-ov-file#readme', + url: 'https://github.com/twentyhq/twenty?tab=License-1-ov-file#readme', }, version: API_Version, }, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-field.comparator.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-field.comparator.ts index edc92cd3909f..863a12496820 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-field.comparator.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-field.comparator.ts @@ -26,7 +26,6 @@ const commonFieldPropertiesToIgnore = [ 'gate', 'asExpression', 'generatedType', - 'defaultValue', 'isLabelSyncedWithName', ]; diff --git a/packages/twenty-server/src/modules/favorite/constants/favorite-deletion-batch-size.ts b/packages/twenty-server/src/modules/favorite/constants/favorite-deletion-batch-size.ts new file mode 100644 index 000000000000..14e4b40c878c --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/constants/favorite-deletion-batch-size.ts @@ -0,0 +1 @@ +export const FAVORITE_DELETION_BATCH_SIZE = 100; diff --git a/packages/twenty-server/src/modules/favorite/favorite.module.ts b/packages/twenty-server/src/modules/favorite/favorite.module.ts new file mode 100644 index 000000000000..5fac130bcfc9 --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/favorite.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { FavoriteDeletionJob } from 'src/modules/favorite/jobs/favorite-deletion.job'; +import { FavoriteDeletionListener } from 'src/modules/favorite/listeners/favorite-deletion.listener'; +import { FavoriteDeletionService } from 'src/modules/favorite/services/favorite-deletion.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature( + [ObjectMetadataEntity, FieldMetadataEntity], + 'metadata', + ), + ], + providers: [ + FavoriteDeletionService, + FavoriteDeletionListener, + FavoriteDeletionJob, + ], + exports: [], +}) +export class FavoriteModule {} diff --git a/packages/twenty-server/src/modules/favorite/jobs/favorite-deletion.job.ts b/packages/twenty-server/src/modules/favorite/jobs/favorite-deletion.job.ts new file mode 100644 index 000000000000..c3215e4fa077 --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/jobs/favorite-deletion.job.ts @@ -0,0 +1,29 @@ +import { Scope } from '@nestjs/common'; + +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { FavoriteDeletionService } from 'src/modules/favorite/services/favorite-deletion.service'; + +export type FavoriteDeletionJobData = { + workspaceId: string; + deletedRecordIds: string[]; +}; + +@Processor({ + queueName: MessageQueue.deleteCascadeQueue, + scope: Scope.REQUEST, +}) +export class FavoriteDeletionJob { + constructor( + private readonly favoriteDeletionService: FavoriteDeletionService, + ) {} + + @Process(FavoriteDeletionJob.name) + async handle(data: FavoriteDeletionJobData): Promise { + await this.favoriteDeletionService.deleteFavoritesForDeletedRecords( + data.deletedRecordIds, + data.workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/modules/favorite/listeners/favorite-deletion.listener.ts b/packages/twenty-server/src/modules/favorite/listeners/favorite-deletion.listener.ts new file mode 100644 index 000000000000..4de7fa58cd74 --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/listeners/favorite-deletion.listener.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; + +import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event'; +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; +import { + FavoriteDeletionJob, + FavoriteDeletionJobData, +} from 'src/modules/favorite/jobs/favorite-deletion.job'; + +@Injectable() +export class FavoriteDeletionListener { + constructor( + @InjectMessageQueue(MessageQueue.deleteCascadeQueue) + private readonly messageQueueService: MessageQueueService, + ) {} + + @OnDatabaseBatchEvent('*', DatabaseEventAction.DELETED) + async handleDeletedEvent( + payload: WorkspaceEventBatch, + ) { + const deletedRecordIds = payload.events.map(({ recordId }) => recordId); + + await this.messageQueueService.add( + FavoriteDeletionJob.name, + { + workspaceId: payload.workspaceId, + deletedRecordIds, + }, + ); + } +} diff --git a/packages/twenty-server/src/modules/favorite/services/favorite-deletion.service.ts b/packages/twenty-server/src/modules/favorite/services/favorite-deletion.service.ts new file mode 100644 index 000000000000..32f62a34b01a --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/services/favorite-deletion.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { In, Repository } from 'typeorm'; + +import { + FieldMetadataEntity, + FieldMetadataType, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { FAVORITE_DELETION_BATCH_SIZE } from 'src/modules/favorite/constants/favorite-deletion-batch-size'; +import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; + +@Injectable() +export class FavoriteDeletionService { + constructor( + @InjectRepository(ObjectMetadataEntity, 'metadata') + private readonly objectMetadataRepository: Repository, + + @InjectRepository(FieldMetadataEntity, 'metadata') + private readonly fieldMetadataRepository: Repository, + private readonly twentyORMManager: TwentyORMManager, + ) {} + + async deleteFavoritesForDeletedRecords( + deletedRecordIds: string[], + workspaceId: string, + ): Promise { + const favoriteRepository = + await this.twentyORMManager.getRepository( + 'favorite', + ); + + const favoriteObjectMetadata = await this.objectMetadataRepository.findOne({ + where: { + nameSingular: 'favorite', + workspaceId, + }, + }); + + if (!favoriteObjectMetadata) { + throw new Error('Favorite object metadata not found'); + } + + const favoriteFields = await this.fieldMetadataRepository.find({ + where: { + objectMetadataId: favoriteObjectMetadata.id, + type: FieldMetadataType.RELATION, + }, + }); + + const favoritesToDelete = await favoriteRepository.find({ + select: { + id: true, + }, + where: favoriteFields.map((field) => ({ + [`${field.name}Id`]: In(deletedRecordIds), + })), + withDeleted: true, + }); + + if (favoritesToDelete.length === 0) { + return; + } + + const favoriteIdsToDelete = favoritesToDelete.map( + (favorite) => favorite.id, + ); + + const batches: string[][] = []; + + for ( + let i = 0; + i < favoriteIdsToDelete.length; + i += FAVORITE_DELETION_BATCH_SIZE + ) { + batches.push( + favoriteIdsToDelete.slice(i, i + FAVORITE_DELETION_BATCH_SIZE), + ); + } + + for (const batch of batches) { + await favoriteRepository.delete(batch); + } + } +} diff --git a/packages/twenty-server/src/modules/modules.module.ts b/packages/twenty-server/src/modules/modules.module.ts index d3cc91566a63..10bde37efea2 100644 --- a/packages/twenty-server/src/modules/modules.module.ts +++ b/packages/twenty-server/src/modules/modules.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { CalendarModule } from 'src/modules/calendar/calendar.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; import { FavoriteFolderModule } from 'src/modules/favorite-folder/favorite-folder.module'; +import { FavoriteModule } from 'src/modules/favorite/favorite.module'; import { MessagingModule } from 'src/modules/messaging/messaging.module'; import { ViewModule } from 'src/modules/view/view.module'; import { WorkflowModule } from 'src/modules/workflow/workflow.module'; @@ -15,6 +16,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module'; ViewModule, WorkflowModule, FavoriteFolderModule, + FavoriteModule, ], providers: [], exports: [], diff --git a/packages/twenty-website/public/images/releases/0.35/0.35-Favorites.png b/packages/twenty-website/public/images/releases/0.35/0.35-Favorites.png new file mode 100644 index 000000000000..06a80e1f0ed5 Binary files /dev/null and b/packages/twenty-website/public/images/releases/0.35/0.35-Favorites.png differ diff --git a/packages/twenty-website/src/content/releases/0.35.0.mdx b/packages/twenty-website/src/content/releases/0.35.0.mdx new file mode 100644 index 000000000000..31ba912ca563 --- /dev/null +++ b/packages/twenty-website/src/content/releases/0.35.0.mdx @@ -0,0 +1,10 @@ +--- +release: 0.35.0 +Date: December 20th 2024 +--- + +# Favorites Views and Favorites Folders + +You can now add your views to favorites for quick access and organize your favorites into folders for better management. + +![](/images/releases/0.35/0.35-Favorites.png) \ No newline at end of file