545 replace objects icons and names with records avatars and labelidentifiers in command menu context chips (#10787)

Closes https://github.com/twentyhq/core-team-issues/issues/545

This PR:
- Introduces `commandMenuNavigationMorphItemsState` which stores the
information about the `recordId` and the `objectMetadataItemId` for each
page
- Creates `CommandMenuContextChipEffect`, which queries the records from
the previous pages in case a record has been updated during the
navigation, to keep up to date information and stores it inside
`commandMenuNavigationRecordsState`
- `useCommandMenuContextChips` returns the context chips information
- Style updates (icons background and color)
- Updates `useCommandMenu` to set and reset these new states


https://github.com/user-attachments/assets/8886848a-721d-4709-9330-8e84ebc0d51e
This commit is contained in:
Raphaël Bosi
2025-03-12 15:26:14 +01:00
committed by GitHub
parent e030fc8917
commit bfc542290b
22 changed files with 620 additions and 174 deletions

View File

@ -47,6 +47,7 @@ export const CommandMenuContextChipGroupsWithRecordSelection = ({
),
Icons: Avatars,
onClick: contextChips.length > 0 ? openRootCommandMenu : undefined,
withIconBackground: false,
}
: undefined;

View File

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

View File

@ -32,7 +32,6 @@ export const CommandMenuContextRecordChipAvatars = ({
objectNameSingular: objectMetadataItem.nameSingular,
record,
});
const { Icon, IconColor } = useGetStandardObjectIcon(
objectMetadataItem.nameSingular,
);

View File

@ -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 (
<CommandMenuContainer>
<CommandMenuContextChipRecordSetterEffect />
<CommandMenuPageComponentInstanceContext.Provider
value={{ instanceId: commandMenuPageInfo.instanceId }}
>

View File

@ -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: [<page.pageIcon size={theme.icon.size.sm} />],
text: page.pageTitle,
onClick: isLastChip
? undefined
: () => {
navigateCommandMenuHistory(index);
},
};
});
}, [
commandMenuNavigationStack,
navigateCommandMenuHistory,
theme.icon.size.sm,
]);
const { contextChips } = useCommandMenuContextChips();
const location = useLocation();
const isButtonVisible =

View File

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

View File

@ -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: [
<CommandMenuContextRecordChipAvatars
objectMetadataItem={objectMetadataItem}
record={record}
/>,
],
text: name,
onClick: () => {
navigateCommandMenuHistory(index);
},
};
}
return {
page,
Icons: isLastChip
? [<page.pageIcon size={theme.icon.size.sm} />]
: [
<StyledIconWrapper>
<page.pageIcon
size={theme.icon.size.sm}
color={
isDefined(page.pageIconColor) &&
page.pageIconColor !== 'currentColor'
? page.pageIconColor
: theme.font.color.tertiary
}
/>
</StyledIconWrapper>,
],
text: page.pageTitle,
onClick: isLastChip
? undefined
: () => {
navigateCommandMenuHistory(index);
},
};
})
.filter(isDefined);
}, [
commandMenuNavigationMorphItemByPage,
commandMenuNavigationRecords,
commandMenuNavigationStack,
navigateCommandMenuHistory,
theme.font.color.tertiary,
theme.icon.size.sm,
]);
return {
contextChips,
};
};

View File

@ -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<string, MorphItem>
>({
key: 'command-menu/commandMenuNavigationMorphItemByPageState',
defaultValue: new Map(),
});

View File

@ -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: [],
});

View File

@ -5,6 +5,7 @@ export type CommandMenuNavigationStackItem = {
page: CommandMenuPages;
pageTitle: string;
pageIcon: IconComponent;
pageIconColor?: string;
pageId: string;
};

View File

@ -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 }) => (
<SingleRecordPickerComponentInstanceContext.Provider value={{ instanceId }}>
<RecoilRoot>{children}</RecoilRoot>
</SingleRecordPickerComponentInstanceContext.Provider>
);
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,
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
});

View File

@ -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<object>;
}) => {
const apolloClient = customClient || client;
const findManyQuery = generateCombinedFindManyRecordsQuery(
operationSignatures,
objectMetadataItems,
);
const queryVariables = generateCombinedFindManyRecordsQueryVariables({
operationSignatures,
});
const { data, loading } =
await apolloClient.query<CombinedFindManyRecordsQueryResult>({
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 };
};

View File

@ -0,0 +1,4 @@
export type MorphItem = {
recordId: string;
objectMetadataId: string;
};

View File

@ -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,
});

View File

@ -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[];

View File

@ -0,0 +1,13 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from 'twenty-shared';
export const getLimitPerMetadataItem = (
objectMetadataItems: Pick<ObjectMetadataItem, 'nameSingular'>[],
limit: number,
) => {
return Object.fromEntries(
objectMetadataItems.map(({ nameSingular }) => {
return [`limit${capitalize(nameSingular)}`, limit];
}),
);
};

View File

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