diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextChipGroupsWithRecordSelection.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextChipGroupsWithRecordSelection.tsx index 5f14fb122..56ae2b285 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextChipGroupsWithRecordSelection.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextChipGroupsWithRecordSelection.tsx @@ -47,6 +47,7 @@ export const CommandMenuContextChipGroupsWithRecordSelection = ({ ), Icons: Avatars, onClick: contextChips.length > 0 ? openRootCommandMenu : undefined, + withIconBackground: false, } : undefined; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextChipRecordSetterEffect.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextChipRecordSetterEffect.tsx new file mode 100644 index 000000000..46286a429 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextChipRecordSetterEffect.tsx @@ -0,0 +1,117 @@ +import { commandMenuNavigationMorphItemByPageState } from '@/command-menu/states/commandMenuNavigationMorphItemsState'; +import { commandMenuNavigationRecordsState } from '@/command-menu/states/commandMenuNavigationRecordsState'; +import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; +import { usePerformCombinedFindManyRecords } from '@/object-record/multiple-objects/hooks/usePerformCombinedFindManyRecords'; +import { isNonEmptyArray } from '@sniptt/guards'; +import { useCallback, useEffect } from 'react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { capitalize, isDefined } from 'twenty-shared'; + +export const CommandMenuContextChipRecordSetterEffect = () => { + const commandMenuNavigationMorphItemByPage = useRecoilValue( + commandMenuNavigationMorphItemByPageState, + ); + + const setCommandMenuNavigationRecords = useSetRecoilState( + commandMenuNavigationRecordsState, + ); + + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const { performCombinedFindManyRecords } = + usePerformCombinedFindManyRecords(); + + const objectMetadataIdsUsedInCommandMenuNavigation = Array.from( + commandMenuNavigationMorphItemByPage.values(), + ).map(({ objectMetadataId }) => objectMetadataId); + + const searchableObjectMetadataItems = objectMetadataItems.filter(({ id }) => + objectMetadataIdsUsedInCommandMenuNavigation.includes(id), + ); + + const commandMenuNavigationStack = useRecoilValue( + commandMenuNavigationStackState, + ); + + const fetchRecords = useCallback(async () => { + const filterPerMetadataItemFilteredOnRecordId = Object.fromEntries( + searchableObjectMetadataItems + .map(({ id, nameSingular }) => { + const recordIdsForMetadataItem = Array.from( + commandMenuNavigationMorphItemByPage.values(), + ) + .filter(({ objectMetadataId }) => objectMetadataId === id) + .map(({ recordId }) => recordId); + + if (!isNonEmptyArray(recordIdsForMetadataItem)) { + return null; + } + + return [ + `filter${capitalize(nameSingular)}`, + { + id: { + in: recordIdsForMetadataItem, + }, + }, + ]; + }) + .filter(isDefined), + ); + + const operationSignatures = searchableObjectMetadataItems + .filter(({ nameSingular }) => + isDefined( + filterPerMetadataItemFilteredOnRecordId[ + `filter${capitalize(nameSingular)}` + ], + ), + ) + .map((objectMetadataItem) => ({ + objectNameSingular: objectMetadataItem.nameSingular, + variables: { + filter: + filterPerMetadataItemFilteredOnRecordId[ + `filter${capitalize(objectMetadataItem.nameSingular)}` + ], + }, + })); + + if (operationSignatures.length === 0) { + setCommandMenuNavigationRecords([]); + return; + } + + const { result } = await performCombinedFindManyRecords({ + operationSignatures, + }); + + const formattedRecords = Object.entries(result).flatMap( + ([objectNamePlural, records]) => + records.map((record) => ({ + objectMetadataItem: searchableObjectMetadataItems.find( + ({ namePlural }) => namePlural === objectNamePlural, + ) as ObjectMetadataItem, + record: record as RecordGqlNode, + })), + ); + + setCommandMenuNavigationRecords(formattedRecords); + }, [ + commandMenuNavigationMorphItemByPage, + performCombinedFindManyRecords, + searchableObjectMetadataItems, + setCommandMenuNavigationRecords, + ]); + + useEffect(() => { + if (commandMenuNavigationStack.length > 1) { + fetchRecords(); + } + }, [commandMenuNavigationStack.length, fetchRecords]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChipAvatars.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChipAvatars.tsx index 4f9d55889..23bc1ea75 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChipAvatars.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChipAvatars.tsx @@ -32,7 +32,6 @@ export const CommandMenuContextRecordChipAvatars = ({ objectNameSingular: objectMetadataItem.nameSingular, record, }); - const { Icon, IconColor } = useGetStandardObjectIcon( objectMetadataItem.nameSingular, ); diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx index ae08b3956..10cbcb055 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx @@ -1,4 +1,5 @@ import { CommandMenuContainer } from '@/command-menu/components/CommandMenuContainer'; +import { CommandMenuContextChipRecordSetterEffect } from '@/command-menu/components/CommandMenuContextChipRecordSetterEffect'; import { CommandMenuTopBar } from '@/command-menu/components/CommandMenuTopBar'; import { COMMAND_MENU_PAGES_CONFIG } from '@/command-menu/constants/CommandMenuPagesConfig'; import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState'; @@ -30,6 +31,7 @@ export const CommandMenuRouter = () => { return ( + diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx index 90507e145..b7fe4883c 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx @@ -5,7 +5,7 @@ import { CommandMenuTopBarInputFocusEffect } from '@/command-menu/components/Com import { COMMAND_MENU_SEARCH_BAR_HEIGHT } from '@/command-menu/constants/CommandMenuSearchBarHeight'; import { COMMAND_MENU_SEARCH_BAR_PADDING } from '@/command-menu/constants/CommandMenuSearchBarPadding'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; -import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState'; +import { useCommandMenuContextChips } from '@/command-menu/hooks/useCommandMenuContextChips'; import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState'; import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState'; import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; @@ -16,7 +16,7 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; import { AnimatePresence, motion } from 'framer-motion'; -import { useMemo, useRef } from 'react'; +import { useRef } from 'react'; import { useLocation } from 'react-router-dom'; import { useRecoilState, useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared'; @@ -100,11 +100,7 @@ export const CommandMenuTopBar = () => { const isMobile = useIsMobile(); - const { - closeCommandMenu, - goBackFromCommandMenu, - navigateCommandMenuHistory, - } = useCommandMenu(); + const { closeCommandMenu, goBackFromCommandMenu } = useCommandMenu(); const contextStoreCurrentObjectMetadataItem = useRecoilComponentValueV2( contextStoreCurrentObjectMetadataItemComponentState, @@ -112,42 +108,13 @@ export const CommandMenuTopBar = () => { const commandMenuPage = useRecoilValue(commandMenuPageState); - const commandMenuNavigationStack = useRecoilValue( - commandMenuNavigationStackState, - ); - const theme = useTheme(); const isCommandMenuV2Enabled = useIsFeatureEnabled( FeatureFlagKey.IsCommandMenuV2Enabled, ); - const contextChips = useMemo(() => { - const filteredCommandMenuNavigationStack = - commandMenuNavigationStack.filter( - (page) => page.page !== CommandMenuPages.Root, - ); - - return filteredCommandMenuNavigationStack.map((page, index) => { - const isLastChip = - index === filteredCommandMenuNavigationStack.length - 1; - - return { - page, - Icons: [], - text: page.pageTitle, - onClick: isLastChip - ? undefined - : () => { - navigateCommandMenuHistory(index); - }, - }; - }); - }, [ - commandMenuNavigationStack, - navigateCommandMenuHistory, - theme.icon.size.sm, - ]); + const { contextChips } = useCommandMenuContextChips(); const location = useLocation(); const isButtonVisible = 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 d6f969267..97995edb7 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts @@ -24,6 +24,8 @@ import { useResetContextStoreStates } from '@/command-menu/hooks/useResetContext import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState'; import { viewableRecordNameSingularComponentState } from '@/command-menu/pages/record-page/states/viewableRecordNameSingularComponentState'; import { workflowIdComponentState } from '@/command-menu/pages/workflow/states/workflowIdComponentState'; +import { commandMenuNavigationMorphItemByPageState } from '@/command-menu/states/commandMenuNavigationMorphItemsState'; +import { commandMenuNavigationRecordsState } from '@/command-menu/states/commandMenuNavigationRecordsState'; import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState'; import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState'; import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState'; @@ -38,10 +40,12 @@ import { contextStoreFiltersComponentState } from '@/context-store/states/contex import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType'; +import { getIconColorForObjectType } from '@/object-metadata/utils/getIconColorForObjectType'; import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState'; import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2'; import { emitRightDrawerCloseEvent } from '@/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent'; import { isDragSelectionStartEnabledState } from '@/ui/utilities/drag-select/states/internal/isDragSelectionStartEnabledState'; +import { useTheme } from '@emotion/react'; import { t } from '@lingui/core/macro'; import { useCallback } from 'react'; import { capitalize, isDefined } from 'twenty-shared'; @@ -52,6 +56,7 @@ export type CommandMenuNavigationStackItem = { page: CommandMenuPages; pageTitle: string; pageIcon: IconComponent; + pageIconColor?: string; pageId?: string; }; @@ -68,6 +73,8 @@ export const useCommandMenu = () => { const { closeDropdown } = useDropdownV2(); + const theme = useTheme(); + const closeCommandMenu = useRecoilCallback( ({ set }) => () => { @@ -95,6 +102,8 @@ export const useCommandMenu = () => { }); set(isCommandMenuOpenedState, false); set(commandMenuSearchState, ''); + set(commandMenuNavigationMorphItemByPageState, new Map()); + set(commandMenuNavigationRecordsState, []); set(commandMenuNavigationStackState, []); resetSelectedItem(); set(hasUserSelectedCommandState, false); @@ -154,6 +163,7 @@ export const useCommandMenu = () => { page, pageTitle, pageIcon, + pageIconColor, pageId, resetNavigationStack = false, }: CommandMenuNavigationStackItem & { @@ -185,9 +195,13 @@ export const useCommandMenu = () => { page, pageTitle, pageIcon, + pageIconColor, pageId, }, ]); + + set(commandMenuNavigationRecordsState, []); + set(commandMenuNavigationMorphItemByPageState, new Map()); } else { set(commandMenuNavigationStackState, [ ...currentNavigationStack, @@ -195,6 +209,7 @@ export const useCommandMenu = () => { page, pageTitle, pageIcon, + pageIconColor, pageId, }, ]); @@ -255,6 +270,21 @@ export const useCommandMenu = () => { }); set(commandMenuNavigationStackState, newNavigationStack); + + const currentMorphItems = snapshot + .getLoadable(commandMenuNavigationMorphItemByPageState) + .getValue(); + + if (currentNavigationStack.length > 0) { + const removedItem = currentNavigationStack.at(-1); + + if (isDefined(removedItem)) { + const newMorphItems = new Map(currentMorphItems); + newMorphItems.delete(removedItem.pageId); + set(commandMenuNavigationMorphItemByPageState, newMorphItems); + } + } + set(hasUserSelectedCommandState, false); }; }, @@ -285,6 +315,17 @@ export const useCommandMenu = () => { Icon: newNavigationStackItem?.pageIcon, instanceId: newNavigationStackItem?.pageId, }); + const currentMorphItems = snapshot + .getLoadable(commandMenuNavigationMorphItemByPageState) + .getValue(); + + const newMorphItems = new Map( + Array.from(currentMorphItems.entries()).filter(([pageId]) => + newNavigationStack.some((item) => item.pageId === pageId), + ), + ); + + set(commandMenuNavigationMorphItemByPageState, newMorphItems); set(hasUserSelectedCommandState, false); }; @@ -376,10 +417,31 @@ export const useCommandMenu = () => { .getValue(), ); + const currentMorphItems = snapshot + .getLoadable(commandMenuNavigationMorphItemByPageState) + .getValue(); + + const morphItemToAdd = { + objectMetadataId: objectMetadataItem.id, + recordId, + }; + + const newMorphItems = new Map([ + ...currentMorphItems, + [pageComponentInstanceId, morphItemToAdd], + ]); + + set(commandMenuNavigationMorphItemByPageState, newMorphItems); + const Icon = objectMetadataItem?.icon ? getIcon(objectMetadataItem.icon) : getIcon('IconList'); + const IconColor = getIconColorForObjectType({ + objectType: objectMetadataItem.nameSingular, + theme, + }); + const capitalizedObjectNameSingular = capitalize(objectNameSingular); navigateCommandMenu({ @@ -388,12 +450,13 @@ export const useCommandMenu = () => { ? t`New ${capitalizedObjectNameSingular}` : capitalizedObjectNameSingular, pageIcon: Icon, + pageIconColor: IconColor, pageId: pageComponentInstanceId, resetNavigationStack: false, }); }; }, - [getIcon, navigateCommandMenu], + [getIcon, navigateCommandMenu, theme], ); const openWorkflowTriggerTypeInCommandMenu = useRecoilCallback( @@ -522,6 +585,31 @@ export const useCommandMenu = () => { calendarEventId, ); + // TODO: Uncomment this once we need to calendar event title in the navigation + // const objectMetadataItem = snapshot + // .getLoadable(objectMetadataItemsState) + // .getValue() + // .find( + // ({ nameSingular }) => + // nameSingular === CoreObjectNameSingular.CalendarEvent, + // ); + + // set( + // commandMenuNavigationMorphItemsState, + // new Map([ + // ...snapshot + // .getLoadable(commandMenuNavigationMorphItemsState) + // .getValue(), + // [ + // pageComponentInstanceId, + // { + // objectMetadataId: objectMetadataItem?.id, + // recordId: calendarEventId, + // }, + // ], + // ]), + // ); + navigateCommandMenu({ page: CommandMenuPages.ViewCalendarEvent, pageTitle: 'Calendar Event', @@ -545,6 +633,31 @@ export const useCommandMenu = () => { emailThreadId, ); + // TODO: Uncomment this once we need to show the thread title in the navigation + // const objectMetadataItem = snapshot + // .getLoadable(objectMetadataItemsState) + // .getValue() + // .find( + // ({ nameSingular }) => + // nameSingular === CoreObjectNameSingular.MessageThread, + // ); + + // set( + // commandMenuNavigationMorphItemsState, + // new Map([ + // ...snapshot + // .getLoadable(commandMenuNavigationMorphItemsState) + // .getValue(), + // [ + // pageComponentInstanceId, + // { + // objectMetadataId: objectMetadataItem?.id, + // recordId: emailThreadId, + // }, + // ], + // ]), + // ); + navigateCommandMenu({ page: CommandMenuPages.ViewEmailThread, pageTitle: 'Email Thread', diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuContextChips.tsx b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuContextChips.tsx new file mode 100644 index 000000000..ef16aac44 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuContextChips.tsx @@ -0,0 +1,133 @@ +import { CommandMenuContextRecordChipAvatars } from '@/command-menu/components/CommandMenuContextRecordChipAvatars'; +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { commandMenuNavigationMorphItemByPageState } from '@/command-menu/states/commandMenuNavigationMorphItemsState'; +import { commandMenuNavigationRecordsState } from '@/command-menu/states/commandMenuNavigationRecordsState'; +import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared'; + +const StyledIconWrapper = styled.div` + background: ${({ theme }) => theme.background.primary}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + display: flex; + align-items: center; + justify-content: center; +`; + +export const useCommandMenuContextChips = () => { + const commandMenuNavigationStack = useRecoilValue( + commandMenuNavigationStackState, + ); + + const { navigateCommandMenuHistory } = useCommandMenu(); + + const theme = useTheme(); + + const commandMenuNavigationMorphItemByPage = useRecoilValue( + commandMenuNavigationMorphItemByPageState, + ); + + const commandMenuNavigationRecords = useRecoilValue( + commandMenuNavigationRecordsState, + ); + + const contextChips = useMemo(() => { + const filteredCommandMenuNavigationStack = + commandMenuNavigationStack.filter( + (page) => page.page !== CommandMenuPages.Root, + ); + + return filteredCommandMenuNavigationStack + .map((page, index) => { + const isLastChip = + index === filteredCommandMenuNavigationStack.length - 1; + + const isRecordPage = page.page === CommandMenuPages.ViewRecord; + + if (isRecordPage && !isLastChip) { + const commandMenuNavigationMorphItem = + commandMenuNavigationMorphItemByPage.get(page.pageId); + + if (!isDefined(commandMenuNavigationMorphItem?.recordId)) { + return null; + } + + const objectMetadataItem = commandMenuNavigationRecords.find( + ({ objectMetadataItem }) => + objectMetadataItem.id === + commandMenuNavigationMorphItem.objectMetadataId, + )?.objectMetadataItem; + + const record = commandMenuNavigationRecords.find( + ({ record }) => + record.id === commandMenuNavigationMorphItem.recordId, + )?.record; + + if (!isDefined(objectMetadataItem) || !isDefined(record)) { + return null; + } + + const name = getObjectRecordIdentifier({ + objectMetadataItem, + record, + }).name; + + return { + page, + Icons: [ + , + ], + text: name, + onClick: () => { + navigateCommandMenuHistory(index); + }, + }; + } + + return { + page, + Icons: isLastChip + ? [] + : [ + + + , + ], + text: page.pageTitle, + onClick: isLastChip + ? undefined + : () => { + navigateCommandMenuHistory(index); + }, + }; + }) + .filter(isDefined); + }, [ + commandMenuNavigationMorphItemByPage, + commandMenuNavigationRecords, + commandMenuNavigationStack, + navigateCommandMenuHistory, + theme.font.color.tertiary, + theme.icon.size.sm, + ]); + + return { + contextChips, + }; +}; diff --git a/packages/twenty-front/src/modules/command-menu/states/commandMenuNavigationMorphItemsState.ts b/packages/twenty-front/src/modules/command-menu/states/commandMenuNavigationMorphItemsState.ts new file mode 100644 index 000000000..11a082cb6 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/states/commandMenuNavigationMorphItemsState.ts @@ -0,0 +1,9 @@ +import { MorphItem } from '@/object-record/multiple-objects/types/MorphItem'; +import { createState } from '@ui/utilities/state/utils/createState'; + +export const commandMenuNavigationMorphItemByPageState = createState< + Map +>({ + key: 'command-menu/commandMenuNavigationMorphItemByPageState', + defaultValue: new Map(), +}); diff --git a/packages/twenty-front/src/modules/command-menu/states/commandMenuNavigationRecordsState.ts b/packages/twenty-front/src/modules/command-menu/states/commandMenuNavigationRecordsState.ts new file mode 100644 index 000000000..a33e0e1dc --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/states/commandMenuNavigationRecordsState.ts @@ -0,0 +1,13 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { createState } from '@ui/utilities/state/utils/createState'; + +export const commandMenuNavigationRecordsState = createState< + { + objectMetadataItem: ObjectMetadataItem; + record: ObjectRecord; + }[] +>({ + key: 'command-menu/commandMenuNavigationRecordsState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/command-menu/states/commandMenuNavigationStackState.ts b/packages/twenty-front/src/modules/command-menu/states/commandMenuNavigationStackState.ts index 127eaf811..3160f3e4f 100644 --- a/packages/twenty-front/src/modules/command-menu/states/commandMenuNavigationStackState.ts +++ b/packages/twenty-front/src/modules/command-menu/states/commandMenuNavigationStackState.ts @@ -5,6 +5,7 @@ export type CommandMenuNavigationStackItem = { page: CommandMenuPages; pageTitle: string; pageIcon: IconComponent; + pageIconColor?: string; pageId: string; }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useLimitPerMetadataItem.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useLimitPerMetadataItem.test.tsx deleted file mode 100644 index 01c0df841..000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useLimitPerMetadataItem.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { RecoilRoot } from 'recoil'; - -import { useLimitPerMetadataItem } from '@/object-metadata/hooks/useLimitPerMetadataItem'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext'; - -const instanceId = 'instanceId'; -const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); - -describe('useLimitPerMetadataItem', () => { - const objectData: ObjectMetadataItem[] = [ - { - createdAt: 'createdAt', - id: 'id', - isActive: true, - isCustom: true, - isSystem: true, - isRemote: false, - isSearchable: true, - labelPlural: 'labelPlural', - labelSingular: 'labelSingular', - namePlural: 'namePlural', - nameSingular: 'nameSingular', - labelIdentifierFieldMetadataId: '20202020-72ba-4e11-a36d-e17b544541e1', - updatedAt: 'updatedAt', - isLabelSyncedWithName: false, - fields: [], - indexMetadatas: [], - }, - ]; - - it('should return object with nameSingular and default limit', async () => { - const { result } = renderHook( - () => useLimitPerMetadataItem({ objectMetadataItems: objectData }), - { - wrapper: Wrapper, - }, - ); - - expect(result.current.limitPerMetadataItem).toStrictEqual({ - limitNameSingular: 60, - }); - }); - - it('should return an object with nameSingular and specified limit', async () => { - const { result } = renderHook( - () => - useLimitPerMetadataItem({ objectMetadataItems: objectData, limit: 30 }), - { - wrapper: Wrapper, - }, - ); - - expect(result.current.limitPerMetadataItem).toStrictEqual({ - limitNameSingular: 30, - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useGetStandardObjectIcon.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useGetStandardObjectIcon.ts index 92b014d73..d058fca5a 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useGetStandardObjectIcon.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useGetStandardObjectIcon.ts @@ -1,36 +1,16 @@ +import { getIconColorForObjectType } from '@/object-metadata/utils/getIconColorForObjectType'; +import { getIconForObjectType } from '@/object-metadata/utils/getIconForObjectType'; import { useTheme } from '@emotion/react'; -import { IconCheckbox, IconComponent, IconNotes } from 'twenty-ui'; export const useGetStandardObjectIcon = (objectNameSingular: string) => { const theme = useTheme(); - const getIconForObjectType = ( - objectType: string, - ): IconComponent | undefined => { - switch (objectType) { - case 'note': - return IconNotes; - case 'task': - return IconCheckbox; - default: - return undefined; - } - }; - - const getIconColorForObjectType = (objectType: string): string => { - switch (objectType) { - case 'note': - return theme.color.yellow; - case 'task': - return theme.color.blue; - default: - return 'currentColor'; - } - }; - const { Icon, IconColor } = { Icon: getIconForObjectType(objectNameSingular), - IconColor: getIconColorForObjectType(objectNameSingular), + IconColor: getIconColorForObjectType({ + objectType: objectNameSingular, + theme, + }), }; return { Icon, IconColor }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useLimitPerMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useLimitPerMetadataItem.ts deleted file mode 100644 index cca9e525e..000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useLimitPerMetadataItem.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit'; -import { capitalize, isDefined } from 'twenty-shared'; - -export const useLimitPerMetadataItem = ({ - objectMetadataItems, - limit = DEFAULT_SEARCH_REQUEST_LIMIT, -}: { - objectMetadataItems: ObjectMetadataItem[]; - limit?: number; -}) => { - const limitPerMetadataItem = Object.fromEntries( - objectMetadataItems - .map(({ nameSingular }) => { - return [`limit${capitalize(nameSingular)}`, limit]; - }) - .filter(isDefined), - ); - - return { - limitPerMetadataItem, - }; -}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getIconColorForObjectType.ts b/packages/twenty-front/src/modules/object-metadata/utils/getIconColorForObjectType.ts new file mode 100644 index 000000000..341ce84b4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getIconColorForObjectType.ts @@ -0,0 +1,18 @@ +import { Theme } from '@emotion/react'; + +export const getIconColorForObjectType = ({ + objectType, + theme, +}: { + objectType: string; + theme: Theme; +}): string => { + switch (objectType) { + case 'note': + return theme.color.yellow; + case 'task': + return theme.color.blue; + default: + return 'currentColor'; + } +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getIconForObjectType.ts b/packages/twenty-front/src/modules/object-metadata/utils/getIconForObjectType.ts new file mode 100644 index 000000000..3689bd53f --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getIconForObjectType.ts @@ -0,0 +1,14 @@ +import { IconCheckbox, IconComponent, IconNotes } from 'twenty-ui'; + +export const getIconForObjectType = ( + objectType: string, +): IconComponent | undefined => { + switch (objectType) { + case 'note': + return IconNotes; + case 'task': + return IconCheckbox; + default: + return undefined; + } +}; diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useCombinedFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useCombinedFindManyRecords.ts index 0247b0e46..2d9774763 100644 --- a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useCombinedFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useCombinedFindManyRecords.ts @@ -3,9 +3,9 @@ import { useQuery } from '@apollo/client'; import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery'; import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature'; -import { useCombinedFindManyRecordsQueryVariables } from '@/object-record/multiple-objects/hooks/useCombinedFindManyRecordsQueryVariables'; import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery'; import { CombinedFindManyRecordsQueryResult } from '@/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult'; +import { generateCombinedFindManyRecordsQueryVariables } from '@/object-record/multiple-objects/utils/generateCombinedFindManyRecordsQueryVariables'; export const useCombinedFindManyRecords = ({ operationSignatures, @@ -18,7 +18,7 @@ export const useCombinedFindManyRecords = ({ operationSignatures, }); - const queryVariables = useCombinedFindManyRecordsQueryVariables({ + const queryVariables = generateCombinedFindManyRecordsQueryVariables({ operationSignatures, }); diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/usePerformCombinedFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/usePerformCombinedFindManyRecords.ts new file mode 100644 index 000000000..d117b3b47 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/usePerformCombinedFindManyRecords.ts @@ -0,0 +1,153 @@ +import { ApolloClient, gql, useApolloClient } from '@apollo/client'; +import { isUndefined } from '@sniptt/guards'; +import { capitalize } from 'twenty-shared'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; +import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery'; +import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature'; +import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; +import { CombinedFindManyRecordsQueryResult } from '@/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult'; +import { generateCombinedFindManyRecordsQueryVariables } from '@/object-record/multiple-objects/utils/generateCombinedFindManyRecordsQueryVariables'; +import { getCombinedFindManyRecordsQueryFilteringPart } from '@/object-record/multiple-objects/utils/getCombinedFindManyRecordsQueryFilteringPart'; +import { useRecoilValue } from 'recoil'; + +export const usePerformCombinedFindManyRecords = () => { + const client = useApolloClient(); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const generateCombinedFindManyRecordsQuery = ( + operationSignatures: RecordGqlOperationSignature[], + objectMetadataItemsValue: ObjectMetadataItem[], + ) => { + const filterPerMetadataItemArray = operationSignatures + .map( + ({ objectNameSingular }) => + `$filter${capitalize(objectNameSingular)}: ${capitalize( + objectNameSingular, + )}FilterInput`, + ) + .join(', '); + + const orderByPerMetadataItemArray = operationSignatures + .map( + ({ objectNameSingular }) => + `$orderBy${capitalize(objectNameSingular)}: [${capitalize( + objectNameSingular, + )}OrderByInput]`, + ) + .join(', '); + + const cursorFilteringPerMetadataItemArray = operationSignatures + .map( + ({ objectNameSingular }) => + `$after${capitalize(objectNameSingular)}: String, $before${capitalize(objectNameSingular)}: String, $first${capitalize(objectNameSingular)}: Int, $last${capitalize(objectNameSingular)}: Int`, + ) + .join(', '); + + const limitPerMetadataItemArray = operationSignatures + .map( + ({ objectNameSingular }) => + `$limit${capitalize(objectNameSingular)}: Int`, + ) + .join(', '); + + const queryOperationSignatureWithObjectMetadataItemArray = + operationSignatures.map((operationSignature) => { + const objectMetadataItem = objectMetadataItemsValue.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === + operationSignature.objectNameSingular, + ); + + if (isUndefined(objectMetadataItem)) { + throw new Error( + `Object metadata item not found for object name singular: ${operationSignature.objectNameSingular}`, + ); + } + + return { operationSignature, objectMetadataItem }; + }); + + return gql` + query CombinedFindManyRecords( + ${filterPerMetadataItemArray}, + ${orderByPerMetadataItemArray}, + ${cursorFilteringPerMetadataItemArray}, + ${limitPerMetadataItemArray} + ) { + ${queryOperationSignatureWithObjectMetadataItemArray + .map( + ({ objectMetadataItem, operationSignature }) => + `${getCombinedFindManyRecordsQueryFilteringPart( + objectMetadataItem, + )} { + edges { + node ${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems: objectMetadataItemsValue, + objectMetadataItem, + recordGqlFields: + operationSignature.fields ?? + generateDepthOneRecordGqlFields({ + objectMetadataItem, + }), + })} + cursor + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + }`, + ) + .join('\n')} + } + `; + }; + + const performCombinedFindManyRecords = async ({ + operationSignatures, + client: customClient, + }: { + operationSignatures: RecordGqlOperationSignature[]; + client?: ApolloClient; + }) => { + const apolloClient = customClient || client; + + const findManyQuery = generateCombinedFindManyRecordsQuery( + operationSignatures, + objectMetadataItems, + ); + + const queryVariables = generateCombinedFindManyRecordsQueryVariables({ + operationSignatures, + }); + + const { data, loading } = + await apolloClient.query({ + query: findManyQuery ?? EMPTY_QUERY, + variables: queryVariables, + }); + + const resultWithoutConnection = Object.fromEntries( + Object.entries(data ?? {}).map(([namePlural, objectRecordConnection]) => [ + namePlural, + getRecordsFromRecordConnection({ + recordConnection: objectRecordConnection, + }), + ]), + ); + + return { + result: resultWithoutConnection, + loading, + }; + }; + + return { performCombinedFindManyRecords }; +}; diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/types/MorphItem.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/types/MorphItem.ts new file mode 100644 index 000000000..c6e8528db --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/types/MorphItem.ts @@ -0,0 +1,4 @@ +export type MorphItem = { + recordId: string; + objectMetadataId: string; +}; diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/__tests__/useCombinedFindManyRecordsQueryVariables.test.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/utils/__tests__/generateCombinedFindManyRecordsQueryVariables.test.ts similarity index 87% rename from packages/twenty-front/src/modules/object-record/multiple-objects/hooks/__tests__/useCombinedFindManyRecordsQueryVariables.test.ts rename to packages/twenty-front/src/modules/object-record/multiple-objects/utils/__tests__/generateCombinedFindManyRecordsQueryVariables.test.ts index a1cbfabe9..44daccd9f 100644 --- a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/__tests__/useCombinedFindManyRecordsQueryVariables.test.ts +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/utils/__tests__/generateCombinedFindManyRecordsQueryVariables.test.ts @@ -1,6 +1,6 @@ import { RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields'; import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature'; -import { useCombinedFindManyRecordsQueryVariables } from '@/object-record/multiple-objects/hooks/useCombinedFindManyRecordsQueryVariables'; +import { generateCombinedFindManyRecordsQueryVariables } from '@/object-record/multiple-objects/utils/generateCombinedFindManyRecordsQueryVariables'; describe('useCombinedFindManyRecordsQueryVariables', () => { it('should generate variables with after cursor and first limit', () => { @@ -26,7 +26,7 @@ describe('useCombinedFindManyRecordsQueryVariables', () => { }, ]; - const result = useCombinedFindManyRecordsQueryVariables({ + const result = generateCombinedFindManyRecordsQueryVariables({ operationSignatures, }); @@ -58,7 +58,7 @@ describe('useCombinedFindManyRecordsQueryVariables', () => { }, ]; - const result = useCombinedFindManyRecordsQueryVariables({ + const result = generateCombinedFindManyRecordsQueryVariables({ operationSignatures, }); @@ -86,7 +86,7 @@ describe('useCombinedFindManyRecordsQueryVariables', () => { }, ]; - const result = useCombinedFindManyRecordsQueryVariables({ + const result = generateCombinedFindManyRecordsQueryVariables({ operationSignatures, }); @@ -125,7 +125,7 @@ describe('useCombinedFindManyRecordsQueryVariables', () => { }, ]; - const result = useCombinedFindManyRecordsQueryVariables({ + const result = generateCombinedFindManyRecordsQueryVariables({ operationSignatures, }); @@ -139,7 +139,7 @@ describe('useCombinedFindManyRecordsQueryVariables', () => { }); it('should handle empty operation signatures', () => { - const result = useCombinedFindManyRecordsQueryVariables({ + const result = generateCombinedFindManyRecordsQueryVariables({ operationSignatures: [], }); @@ -157,7 +157,7 @@ describe('useCombinedFindManyRecordsQueryVariables', () => { }, ]; - const result = useCombinedFindManyRecordsQueryVariables({ + const result = generateCombinedFindManyRecordsQueryVariables({ operationSignatures, }); @@ -180,7 +180,7 @@ describe('useCombinedFindManyRecordsQueryVariables', () => { }, ]; - const result = useCombinedFindManyRecordsQueryVariables({ + const result = generateCombinedFindManyRecordsQueryVariables({ operationSignatures, }); diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useCombinedFindManyRecordsQueryVariables.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/utils/generateCombinedFindManyRecordsQueryVariables.ts similarity index 96% rename from packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useCombinedFindManyRecordsQueryVariables.ts rename to packages/twenty-front/src/modules/object-record/multiple-objects/utils/generateCombinedFindManyRecordsQueryVariables.ts index bbe9db107..a145fd4e2 100644 --- a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useCombinedFindManyRecordsQueryVariables.ts +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/utils/generateCombinedFindManyRecordsQueryVariables.ts @@ -3,7 +3,7 @@ import { isNonEmptyString } from '@sniptt/guards'; import { capitalize, isDefined } from 'twenty-shared'; import { isNonEmptyArray } from '~/utils/isNonEmptyArray'; -export const useCombinedFindManyRecordsQueryVariables = ({ +export const generateCombinedFindManyRecordsQueryVariables = ({ operationSignatures, }: { operationSignatures: RecordGqlOperationSignature[]; diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/utils/getLimitPerMetadataItem.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/utils/getLimitPerMetadataItem.ts new file mode 100644 index 000000000..f8b4af2c7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/utils/getLimitPerMetadataItem.ts @@ -0,0 +1,13 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { capitalize } from 'twenty-shared'; + +export const getLimitPerMetadataItem = ( + objectMetadataItems: Pick[], + limit: number, +) => { + return Object.fromEntries( + objectMetadataItems.map(({ nameSingular }) => { + return [`limit${capitalize(nameSingular)}`, limit]; + }), + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch.ts b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch.ts index 650d21353..cae27861c 100644 --- a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch.ts +++ b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch.ts @@ -1,6 +1,7 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { CombinedFindManyRecordsQueryResult } from '@/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult'; import { generateCombinedSearchRecordsQuery } from '@/object-record/multiple-objects/utils/generateCombinedSearchRecordsQuery'; +import { getLimitPerMetadataItem } from '@/object-record/multiple-objects/utils/getLimitPerMetadataItem'; import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState'; import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState'; import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState'; @@ -222,12 +223,9 @@ const performSearchForPickedRecords = async ({ ), }); - const limitPerMetadataItem = Object.fromEntries( - searchableObjectMetadataItems - .map(({ nameSingular }) => { - return [`limit${capitalize(nameSingular)}`, 10]; - }) - .filter(isDefined), + const limitPerMetadataItem = getLimitPerMetadataItem( + searchableObjectMetadataItemsFilteredOnPickedRecordId, + 10, ); const { data: combinedSearchRecordFilteredOnPickedRecordsQueryResult } = @@ -309,12 +307,9 @@ const performSearchExcludingPickedRecords = async ({ ), }); - const limitPerMetadataItem = Object.fromEntries( - searchableObjectMetadataItems - .map(({ nameSingular }) => { - return [`limit${capitalize(nameSingular)}`, 10]; - }) - .filter(isDefined), + const limitPerMetadataItem = getLimitPerMetadataItem( + searchableObjectMetadataItems, + 10, ); const { data: combinedSearchRecordExcludingPickedRecordsQueryResult } =