491 save the page component instance id for side panel navigation (#10700)

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

This PR:
- Duplicates the right drawer pages for the command menu and replace all
the states used in these pages by component states (The right drawer
pages will be deleted when we deprecate the command menu v1)
- Wraps those pages into a component instance provider
- We store the component instance id upon navigation to restore the
states when we navigate back to a page

The only pages which are not updated for now are the pages related to
the workflow objects, this will be done in another PR.
In another PR, to improve the navigation experience I will replace the
icons and titles of the chips by the label identifier and the avatar if
the page is a record page.


https://github.com/user-attachments/assets/a76d3345-01f3-4db9-8a55-331cca8b87e0

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Raphaël Bosi
2025-03-06 17:09:40 +01:00
committed by GitHub
parent 37d7c0c994
commit e86116aa57
30 changed files with 1255 additions and 80 deletions

View File

@ -5,8 +5,10 @@ import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMen
import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { contextStoreCurrentObjectMetadataItemComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const RecordShowRightDrawerActionMenu = () => {
@ -14,6 +16,10 @@ export const RecordShowRightDrawerActionMenu = () => {
contextStoreCurrentObjectMetadataItemComponentState,
);
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const isWorkflowEnabled = useIsFeatureEnabled(
FeatureFlagKey.IsWorkflowEnabled,
);
@ -24,7 +30,12 @@ export const RecordShowRightDrawerActionMenu = () => {
<ActionMenuContext.Provider value={{ isInRightDrawer: true }}>
<RightDrawerActionMenuDropdown />
<ActionMenuConfirmationModals />
<RecordActionMenuEntriesSetter />
{isDefined(contextStoreTargetedRecordsRule) &&
contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length > 0 && (
<RecordActionMenuEntriesSetter />
)}
<RecordAgnosticActionMenuEntriesSetter />
{isWorkflowEnabled && (
<RunWorkflowRecordAgnosticActionMenuEntriesSetter />

View File

@ -11,6 +11,8 @@ import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendar
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared';
import {
Avatar,
@ -22,6 +24,7 @@ import {
} from 'twenty-ui';
import {
CalendarChannelVisibility,
FeatureFlagKey,
TimelineCalendarEvent,
} from '~/generated-metadata/graphql';
@ -115,6 +118,10 @@ export const CalendarEventRow = ({
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { displayCurrentEventCursor = false } = useContext(CalendarContext);
const { openCalendarEventRightDrawer } = useOpenCalendarEventRightDrawer();
const { openCalendarEventInCommandMenu } = useCommandMenu();
const isCommandMenuV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsCommandMenuV2Enabled,
);
const startsAt = getCalendarEventStartDate(calendarEvent);
const endsAt = getCalendarEventEndDate(calendarEvent);
@ -137,7 +144,13 @@ export const CalendarEventRow = ({
showTitle={showTitle}
onClick={
showTitle
? () => openCalendarEventRightDrawer(calendarEvent.id)
? () => {
if (isCommandMenuV2Enabled) {
openCalendarEventInCommandMenu(calendarEvent.id);
} else {
openCalendarEventRightDrawer(calendarEvent.id);
}
}
: undefined
}
>

View File

@ -6,8 +6,14 @@ import { ActivityRow } from '@/activities/components/ActivityRow';
import { EmailThreadNotShared } from '@/activities/emails/components/EmailThreadNotShared';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { emailThreadIdWhenEmailThreadWasClosedState } from '@/activities/emails/states/lastViewableEmailThreadIdState';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { MessageChannelVisibility, TimelineThread } from '~/generated/graphql';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import {
FeatureFlagKey,
MessageChannelVisibility,
TimelineThread,
} from '~/generated/graphql';
import { formatToHumanReadableDate } from '~/utils/date-utils';
const StyledHeading = styled.div<{ unread: boolean }>`
@ -72,6 +78,10 @@ type EmailThreadPreviewProps = {
export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
const { openEmailThread } = useEmailThread();
const { openEmailThreadInCommandMenu } = useCommandMenu();
const isCommandMenuV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsCommandMenuV2Enabled,
);
const visibility = thread.visibility;
@ -111,12 +121,18 @@ export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
emailThreadIdWhenEmailThreadWasClosed !== thread.id);
if (canOpen) {
openEmailThread(thread.id);
if (isCommandMenuV2Enabled) {
openEmailThreadInCommandMenu(thread.id);
} else {
openEmailThread(thread.id);
}
}
},
[
isCommandMenuV2Enabled,
isSameEventThanRightDrawerClose,
openEmailThread,
openEmailThreadInCommandMenu,
thread.id,
thread.visibility,
],

View File

@ -1,7 +1,6 @@
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { useOpenCalendarEventRightDrawer } from '@/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer';
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
@ -107,8 +106,6 @@ export const EventCardCalendarEvent = ({
},
});
const { openCalendarEventRightDrawer } = useOpenCalendarEventRightDrawer();
const { timeZone } = useContext(UserContext);
if (isDefined(error)) {
@ -152,9 +149,7 @@ export const EventCardCalendarEvent = ({
: null;
return (
<StyledEventCardCalendarEventContainer
onClick={() => openCalendarEventRightDrawer(calendarEvent.id)}
>
<StyledEventCardCalendarEventContainer>
<StyledCalendarEventDateCard>
<StyledCalendarEventDateCardMonth>
{startsAtMonth}

View File

@ -2,7 +2,6 @@ import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { OverflowingTextWithTooltip } from 'twenty-ui';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage';
import { EventCardMessageNotShared } from '@/activities/timeline-activities/rows/message/components/EventCardMessageNotShared';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -80,8 +79,6 @@ export const EventCardMessage = ({
},
});
const { openEmailThread } = useEmailThread();
if (isDefined(error)) {
const shouldHideMessageContent = error.graphQLErrors.some(
(e) => e.extensions?.code === 'FORBIDDEN',
@ -120,11 +117,7 @@ export const EventCardMessage = ({
<OverflowingTextWithTooltip text={messageParticipantHandles} />
</StyledEmailParticipants>
</StyledEmailTop>
<StyledEmailBody
onClick={() => openEmailThread(message.messageThreadId)}
>
{message.text}
</StyledEmailBody>
<StyledEmailBody>{message.text}</StyledEmailBody>
</StyledEmailContent>
</StyledEventCardMessageContainer>
);

View File

@ -1,6 +1,7 @@
import { COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID } from '@/command-menu/constants/CommandMenuContextChipGroupsDropdownId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { isDefined } from 'twenty-shared';
import { MenuItem } from 'twenty-ui';
@ -14,6 +15,8 @@ export const CommandMenuContextChipGroups = ({
}: {
contextChips: CommandMenuContextChipProps[];
}) => {
const { closeDropdown } = useDropdownV2();
if (contextChips.length === 0) {
return null;
}
@ -34,6 +37,7 @@ export const CommandMenuContextChipGroups = ({
}
const firstChips = contextChips.slice(0, -1);
const firstThreeChips = firstChips.slice(0, 3);
const lastChip = contextChips.at(-1);
return (
@ -42,8 +46,9 @@ export const CommandMenuContextChipGroups = ({
<Dropdown
clickableComponent={
<CommandMenuContextChip
Icons={firstChips.map((chip) => chip.Icons?.[0])}
Icons={firstThreeChips.map((chip) => chip.Icons?.[0])}
onClick={() => {}}
text={`${firstChips.length}`}
/>
}
dropdownComponents={
@ -52,7 +57,10 @@ export const CommandMenuContextChipGroups = ({
<MenuItem
LeftComponent={chip.Icons}
text={chip.text}
onClick={chip.onClick}
onClick={() => {
closeDropdown(COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID);
chip.onClick?.();
}}
/>
))}
</DropdownMenuItemsContainer>

View File

@ -4,7 +4,7 @@ import { getSelectedRecordsContextText } from '@/command-menu/utils/getRecordCon
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
export const CommandMenuContextRecordChip = ({
export const CommandMenuContextRecordsChip = ({
objectMetadataItemId,
instanceId,
}: {

View File

@ -1,7 +1,9 @@
import { CommandMenuContainer } from '@/command-menu/components/CommandMenuContainer';
import { CommandMenuTopBar } from '@/command-menu/components/CommandMenuTopBar';
import { COMMAND_MENU_PAGES_CONFIG } from '@/command-menu/constants/CommandMenuPagesConfig';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
@ -16,6 +18,8 @@ const StyledCommandMenuContent = styled.div`
export const CommandMenuRouter = () => {
const commandMenuPage = useRecoilValue(commandMenuPageState);
const commandMenuPageInfo = useRecoilValue(commandMenuPageInfoState);
const commandMenuPageComponent = isDefined(commandMenuPage) ? (
COMMAND_MENU_PAGES_CONFIG.get(commandMenuPage)
) : (
@ -26,20 +30,24 @@ export const CommandMenuRouter = () => {
return (
<CommandMenuContainer>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: theme.animation.duration.instant,
delay: 0.1,
}}
<CommandMenuPageComponentInstanceContext.Provider
value={{ instanceId: commandMenuPageInfo.instanceId }}
>
<CommandMenuTopBar />
</motion.div>
<StyledCommandMenuContent>
{commandMenuPageComponent}
</StyledCommandMenuContent>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: theme.animation.duration.instant,
delay: 0.1,
}}
>
<CommandMenuTopBar />
</motion.div>
<StyledCommandMenuContent>
{commandMenuPageComponent}
</StyledCommandMenuContent>
</CommandMenuPageComponentInstanceContext.Provider>
</CommandMenuContainer>
);
};

View File

@ -128,16 +128,21 @@ export const CommandMenuTopBar = () => {
(page) => page.page !== CommandMenuPages.Root,
);
return filteredCommandMenuNavigationStack.map((page, index) => ({
Icons: [<page.pageIcon size={theme.icon.size.sm} />],
text: page.pageTitle,
onClick:
index === filteredCommandMenuNavigationStack.length - 1
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,

View File

@ -1,4 +1,4 @@
import { CommandMenuContextRecordChip } from '@/command-menu/components/CommandMenuContextRecordChip';
import { CommandMenuContextRecordsChip } from '@/command-menu/components/CommandMenuContextRecordsChip';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { RESET_CONTEXT_TO_SELECTION } from '@/command-menu/constants/ResetContextToSelection';
import { useResetPreviousCommandMenuContext } from '@/command-menu/hooks/useResetPreviousCommandMenuContext';
@ -46,7 +46,7 @@ export const ResetContextToSelectionCommandButton = () => {
Icon={IconArrowBackUp}
label={t`Reset to`}
RightComponent={
<CommandMenuContextRecordChip
<CommandMenuContextRecordsChip
objectMetadataItemId={objectMetadataItem.id}
instanceId="command-menu-previous"
/>

View File

@ -100,6 +100,7 @@ const meta: Meta<typeof CommandMenu> = {
page: CommandMenuPages.Root,
pageTitle: 'Command Menu',
pageIcon: IconDotsVertical,
pageId: '1',
},
]);

View File

@ -1,7 +1,7 @@
import { gql } from '@apollo/client';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { CommandMenuContextRecordChip } from '@/command-menu/components/CommandMenuContextRecordChip';
import { CommandMenuContextRecordsChip } from '@/command-menu/components/CommandMenuContextRecordsChip';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext';
import { RecordChipData } from '@/object-record/record-field/types/RecordChipData';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
@ -207,9 +207,9 @@ const ContextStoreDecorator: Decorator = (Story) => {
);
};
const meta: Meta<typeof CommandMenuContextRecordChip> = {
const meta: Meta<typeof CommandMenuContextRecordsChip> = {
title: 'Modules/CommandMenu/CommandMenuContextRecordChip',
component: CommandMenuContextRecordChip,
component: CommandMenuContextRecordsChip,
decorators: [
ContextStoreDecorator,
ChipGeneratorsDecorator,
@ -221,7 +221,7 @@ const meta: Meta<typeof CommandMenuContextRecordChip> = {
};
export default meta;
type Story = StoryObj<typeof CommandMenuContextRecordChip>;
type Story = StoryObj<typeof CommandMenuContextRecordsChip>;
export const Default: Story = {};

View File

@ -1,10 +1,10 @@
import { RightDrawerCalendarEvent } from '@/activities/calendar/right-drawer/components/RightDrawerCalendarEvent';
import { RightDrawerAIChat } from '@/activities/copilot/right-drawer/components/RightDrawerAIChat';
import { RightDrawerEmailThread } from '@/activities/emails/right-drawer/components/RightDrawerEmailThread';
import { CommandMenu } from '@/command-menu/components/CommandMenu';
import { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage';
import { CommandMenuSearchRecordsPage } from '@/command-menu/pages/components/CommandMenuSearchRecordsPage';
import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage';
import { CommandMenuRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuRecordPage';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { RightDrawerRecord } from '@/object-record/record-right-drawer/components/RightDrawerRecord';
import { RightDrawerWorkflowEditStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowEditStep';
import { RightDrawerWorkflowRunViewStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowRunViewStep';
import { RightDrawerWorkflowViewStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowViewStep';
@ -16,9 +16,9 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map<
React.ReactNode
>([
[CommandMenuPages.Root, <CommandMenu />],
[CommandMenuPages.ViewRecord, <RightDrawerRecord />],
[CommandMenuPages.ViewEmailThread, <RightDrawerEmailThread />],
[CommandMenuPages.ViewCalendarEvent, <RightDrawerCalendarEvent />],
[CommandMenuPages.ViewRecord, <CommandMenuRecordPage />],
[CommandMenuPages.ViewEmailThread, <CommandMenuMessageThreadPage />],
[CommandMenuPages.ViewCalendarEvent, <CommandMenuCalendarEventPage />],
[CommandMenuPages.Copilot, <RightDrawerAIChat />],
[
CommandMenuPages.WorkflowStepSelectTriggerType,

View File

@ -5,8 +5,8 @@ import { RecoilRoot, useRecoilValue } from 'recoil';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageTitle';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { IconList, IconSearch } from 'twenty-ui';
@ -91,6 +91,7 @@ describe('useCommandMenu', () => {
expect(result.current.commandMenuPageInfo).toEqual({
title: undefined,
Icon: undefined,
instanceId: '',
});
act(() => {
@ -98,6 +99,7 @@ describe('useCommandMenu', () => {
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
pageId: '1',
});
});
@ -106,12 +108,14 @@ describe('useCommandMenu', () => {
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
pageId: '1',
},
]);
expect(result.current.commandMenuPage).toBe(CommandMenuPages.SearchRecords);
expect(result.current.commandMenuPageInfo).toEqual({
title: 'Search',
Icon: IconSearch,
instanceId: '1',
});
act(() => {
@ -119,6 +123,7 @@ describe('useCommandMenu', () => {
page: CommandMenuPages.ViewRecord,
pageTitle: 'Company',
pageIcon: IconList,
pageId: '2',
});
});
@ -127,17 +132,20 @@ describe('useCommandMenu', () => {
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
pageId: '1',
},
{
page: CommandMenuPages.ViewRecord,
pageTitle: 'Company',
pageIcon: IconList,
pageId: '2',
},
]);
expect(result.current.commandMenuPage).toBe(CommandMenuPages.ViewRecord);
expect(result.current.commandMenuPageInfo).toEqual({
title: 'Company',
Icon: IconList,
instanceId: '2',
});
});
@ -149,6 +157,7 @@ describe('useCommandMenu', () => {
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
pageId: '1',
});
});
@ -157,6 +166,7 @@ describe('useCommandMenu', () => {
page: CommandMenuPages.ViewRecord,
pageTitle: 'Company',
pageIcon: IconList,
pageId: '2',
});
});
@ -165,11 +175,13 @@ describe('useCommandMenu', () => {
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
pageId: '1',
},
{
page: CommandMenuPages.ViewRecord,
pageTitle: 'Company',
pageIcon: IconList,
pageId: '2',
},
]);
@ -182,12 +194,14 @@ describe('useCommandMenu', () => {
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
pageId: '1',
},
]);
expect(result.current.commandMenuPage).toBe(CommandMenuPages.SearchRecords);
expect(result.current.commandMenuPageInfo).toEqual({
title: 'Search',
Icon: IconSearch,
instanceId: '1',
});
act(() => {
@ -199,6 +213,7 @@ describe('useCommandMenu', () => {
expect(result.current.commandMenuPage).toBe(CommandMenuPages.Root);
expect(result.current.commandMenuPageInfo).toEqual({
title: undefined,
instanceId: '',
Icon: undefined,
});
expect(result.current.isCommandMenuOpened).toBe(false);
@ -212,6 +227,7 @@ describe('useCommandMenu', () => {
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
pageId: '1',
});
});
@ -223,6 +239,7 @@ describe('useCommandMenu', () => {
expect(result.current.commandMenuPageInfo).toEqual({
title: 'Search',
Icon: IconSearch,
instanceId: '1',
});
});
});

View File

@ -5,19 +5,25 @@ import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objec
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { IconDotsVertical, IconSearch, useIcons } from 'twenty-ui';
import {
IconCalendarEvent,
IconComponent,
IconDotsVertical,
IconMail,
IconSearch,
useIcons,
} from 'twenty-ui';
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID } from '@/command-menu/constants/CommandMenuContextChipGroupsDropdownId';
import { COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuPreviousComponentInstanceId';
import { useCopyContextStoreStates } from '@/command-menu/hooks/useCopyContextStoreAndActionMenuStates';
import { useResetContextStoreStates } from '@/command-menu/hooks/useResetContextStoreStates';
import {
CommandMenuNavigationStackItem,
commandMenuNavigationStackState,
} from '@/command-menu/states/commandMenuNavigationStackState';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { viewableRecordNameSingularComponentState } from '@/command-menu/pages/record-page/states/viewableRecordNameSingularComponentState';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageTitle';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { isCommandMenuClosingState } from '@/command-menu/states/isCommandMenuClosingState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
@ -29,17 +35,23 @@ 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 { RIGHT_DRAWER_RECORD_INSTANCE_ID } from '@/object-record/record-right-drawer/constants/RightDrawerRecordInstanceId';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
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 { t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { capitalize, isDefined } from 'twenty-shared';
import { v4 } from 'uuid';
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
export type CommandMenuNavigationStackItem = {
page: CommandMenuPages;
pageTitle: string;
pageIcon: IconComponent;
pageId?: string;
};
export const useCommandMenu = () => {
const { resetSelectedItem } = useSelectableList('command-menu-list');
const {
@ -76,6 +88,7 @@ export const useCommandMenu = () => {
set(commandMenuPageInfoState, {
title: undefined,
Icon: undefined,
instanceId: '',
});
set(isCommandMenuOpenedState, false);
set(commandMenuSearchState, '');
@ -138,15 +151,21 @@ export const useCommandMenu = () => {
page,
pageTitle,
pageIcon,
pageId,
resetNavigationStack = false,
}: CommandMenuNavigationStackItem & {
resetNavigationStack?: boolean;
}) => {
if (!pageId) {
pageId = v4();
}
openCommandMenu();
set(commandMenuPageState, page);
set(commandMenuPageInfoState, {
title: pageTitle,
Icon: pageIcon,
instanceId: pageId,
});
const isCommandMenuClosing = snapshot
@ -157,16 +176,24 @@ export const useCommandMenu = () => {
? []
: snapshot.getLoadable(commandMenuNavigationStackState).getValue();
const itemIsAlreadyInStack = currentNavigationStack.some(
(item) => item.page === page,
);
if (resetNavigationStack || itemIsAlreadyInStack) {
set(commandMenuNavigationStackState, [{ page, pageTitle, pageIcon }]);
if (resetNavigationStack) {
set(commandMenuNavigationStackState, [
{
page,
pageTitle,
pageIcon,
pageId,
},
]);
} else {
set(commandMenuNavigationStackState, [
...currentNavigationStack,
{ page, pageTitle, pageIcon },
{
page,
pageTitle,
pageIcon,
pageId,
},
]);
}
};
@ -221,6 +248,7 @@ export const useCommandMenu = () => {
set(commandMenuPageInfoState, {
title: lastNavigationStackItem.pageTitle,
Icon: lastNavigationStackItem.pageIcon,
instanceId: lastNavigationStackItem.pageId,
});
set(commandMenuNavigationStackState, newNavigationStack);
@ -252,6 +280,7 @@ export const useCommandMenu = () => {
set(commandMenuPageInfoState, {
title: newNavigationStackItem?.pageTitle,
Icon: newNavigationStackItem?.pageIcon,
instanceId: newNavigationStackItem?.pageId,
});
set(hasUserSelectedCommandState, false);
@ -269,7 +298,20 @@ export const useCommandMenu = () => {
objectNameSingular: string;
isNewRecord?: boolean;
}) => {
set(viewableRecordNameSingularState, objectNameSingular);
const pageComponentInstanceId = v4();
set(
viewableRecordNameSingularComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
objectNameSingular,
);
set(
viewableRecordIdComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
recordId,
);
set(viewableRecordIdState, recordId);
const objectMetadataItem = snapshot
@ -289,14 +331,14 @@ export const useCommandMenu = () => {
set(
contextStoreCurrentObjectMetadataItemComponentState.atomFamily({
instanceId: RIGHT_DRAWER_RECORD_INSTANCE_ID,
instanceId: pageComponentInstanceId,
}),
objectMetadataItem,
);
set(
contextStoreTargetedRecordsRuleComponentState.atomFamily({
instanceId: RIGHT_DRAWER_RECORD_INSTANCE_ID,
instanceId: pageComponentInstanceId,
}),
{
mode: 'selection',
@ -306,21 +348,21 @@ export const useCommandMenu = () => {
set(
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
instanceId: RIGHT_DRAWER_RECORD_INSTANCE_ID,
instanceId: pageComponentInstanceId,
}),
1,
);
set(
contextStoreCurrentViewTypeComponentState.atomFamily({
instanceId: RIGHT_DRAWER_RECORD_INSTANCE_ID,
instanceId: pageComponentInstanceId,
}),
ContextStoreViewType.ShowPage,
);
set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: RIGHT_DRAWER_RECORD_INSTANCE_ID,
instanceId: pageComponentInstanceId,
}),
snapshot
.getLoadable(
@ -343,8 +385,8 @@ export const useCommandMenu = () => {
? t`New ${capitalizedObjectNameSingular}`
: capitalizedObjectNameSingular,
pageIcon: Icon,
// TODO: remove this once we can store the navigation stack page states
resetNavigationStack: true,
pageId: pageComponentInstanceId,
resetNavigationStack: false,
});
};
},
@ -356,9 +398,56 @@ export const useCommandMenu = () => {
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
pageId: v4(),
});
};
const openCalendarEventInCommandMenu = useRecoilCallback(
({ set }) => {
return (calendarEventId: string) => {
const pageComponentInstanceId = v4();
set(
viewableRecordIdComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
calendarEventId,
);
navigateCommandMenu({
page: CommandMenuPages.ViewCalendarEvent,
pageTitle: 'Calendar Event',
pageIcon: IconCalendarEvent,
pageId: pageComponentInstanceId,
});
};
},
[navigateCommandMenu],
);
const openEmailThreadInCommandMenu = useRecoilCallback(
({ set }) => {
return (emailThreadId: string) => {
const pageComponentInstanceId = v4();
set(
viewableRecordIdComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
emailThreadId,
);
navigateCommandMenu({
page: CommandMenuPages.ViewEmailThread,
pageTitle: 'Email Thread',
pageIcon: IconMail,
pageId: pageComponentInstanceId,
});
};
},
[navigateCommandMenu],
);
const setGlobalCommandMenuContext = useRecoilCallback(
({ set }) => {
return () => {
@ -401,6 +490,7 @@ export const useCommandMenu = () => {
set(commandMenuPageInfoState, {
title: undefined,
Icon: undefined,
instanceId: '',
});
set(hasUserSelectedCommandState, false);
@ -420,5 +510,7 @@ export const useCommandMenu = () => {
openRecordInCommandMenu,
toggleCommandMenu,
setGlobalCommandMenuContext,
openCalendarEventInCommandMenu,
openEmailThreadInCommandMenu,
};
};

View File

@ -0,0 +1,37 @@
import { CalendarEventDetails } from '@/activities/calendar/components/CalendarEventDetails';
import { CalendarEventDetailsEffect } from '@/activities/calendar/components/CalendarEventDetailsEffect';
import { FIND_ONE_CALENDAR_EVENT_OPERATION_SIGNATURE } from '@/activities/calendar/graphql/operation-signatures/FindOneCalendarEventOperationSignature';
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const CommandMenuCalendarEventPage = () => {
const { upsertRecords } = useUpsertRecordsInStore();
const viewableRecordId = useRecoilComponentValueV2(
viewableRecordIdComponentState,
);
const { record: calendarEvent } = useFindOneRecord<CalendarEvent>({
objectNameSingular:
FIND_ONE_CALENDAR_EVENT_OPERATION_SIGNATURE.objectNameSingular,
objectRecordId: viewableRecordId ?? '',
recordGqlFields: FIND_ONE_CALENDAR_EVENT_OPERATION_SIGNATURE.fields,
onCompleted: (record) => upsertRecords([record]),
});
if (!calendarEvent) {
return null;
}
return (
<RecordFieldValueSelectorContextProvider>
<CalendarEventDetailsEffect record={calendarEvent} />
<RecordValueSetterEffect recordId={calendarEvent.id} />
<CalendarEventDetails calendarEvent={calendarEvent} />
</RecordFieldValueSelectorContextProvider>
);
};

View File

@ -0,0 +1,44 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { Button, IconArrowsVertical } from 'twenty-ui';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
const StyledButtonContainer = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
padding: 16px 24px;
`;
export const CommandMenuMessageThreadIntermediaryMessages = ({
messages,
}: {
messages: EmailThreadMessageWithSender[];
}) => {
const [areMessagesOpen, setAreMessagesOpen] = useState(false);
if (messages.length === 0) {
return null;
}
return areMessagesOpen ? (
messages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))
) : (
<StyledButtonContainer>
<Button
Icon={IconArrowsVertical}
title={`${messages.length} email${messages.length > 1 ? 's' : ''}`}
size="small"
onClick={() => setAreMessagesOpen(true)}
/>
</StyledButtonContainer>
);
};

View File

@ -0,0 +1,169 @@
import styled from '@emotion/styled';
import { useEffect, useMemo } from 'react';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { EmailLoader } from '@/activities/emails/components/EmailLoader';
import { EmailThreadHeader } from '@/activities/emails/components/EmailThreadHeader';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { CommandMenuMessageThreadIntermediaryMessages } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadIntermediaryMessages';
import { useEmailThreadInCommandMenu } from '@/command-menu/pages/message-thread/hooks/useEmailThreadInCommandMenu';
import { messageThreadComponentState } from '@/command-menu/pages/message-thread/states/messageThreadComponentState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { ConnectedAccountProvider } from 'twenty-shared';
import { Button, IconArrowBackUp } from 'twenty-ui';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
flex: 1;
height: 85%;
overflow-y: auto;
`;
const StyledButtonContainer = styled.div<{ isMobile: boolean }>`
background: ${({ theme }) => theme.background.secondary};
border-top: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
justify-content: flex-end;
height: ${({ isMobile }) => (isMobile ? '100px' : '50px')};
padding: ${({ theme }) => theme.spacing(2)};
width: 100%;
box-sizing: border-box;
`;
export const CommandMenuMessageThreadPage = () => {
const setMessageThread = useSetRecoilComponentStateV2(
messageThreadComponentState,
);
const isMobile = useIsMobile();
const {
thread,
messages,
fetchMoreMessages,
threadLoading,
messageThreadExternalId,
connectedAccountHandle,
messageChannelLoading,
connectedAccountProvider,
lastMessageExternalId,
} = useEmailThreadInCommandMenu();
useEffect(() => {
if (!messages[0]?.messageThread) {
return;
}
setMessageThread(messages[0]?.messageThread);
}, [messages, setMessageThread]);
const messagesCount = messages.length;
const is5OrMoreMessages = messagesCount >= 5;
const firstMessages = messages.slice(
0,
is5OrMoreMessages ? 2 : messagesCount - 1,
);
const intermediaryMessages = is5OrMoreMessages
? messages.slice(2, messagesCount - 1)
: [];
const lastMessage = messages[messagesCount - 1];
const subject = messages[0]?.subject;
const canReply = useMemo(() => {
return (
connectedAccountHandle &&
connectedAccountProvider &&
lastMessage &&
messageThreadExternalId != null
);
}, [
connectedAccountHandle,
connectedAccountProvider,
lastMessage,
messageThreadExternalId,
]);
const handleReplyClick = () => {
if (!canReply) {
return;
}
let url: string;
switch (connectedAccountProvider) {
case ConnectedAccountProvider.MICROSOFT:
url = `https://outlook.office.com/mail/deeplink?ItemID=${lastMessageExternalId}`;
window.open(url, '_blank');
break;
case ConnectedAccountProvider.GOOGLE:
url = `https://mail.google.com/mail/?authuser=${connectedAccountHandle}#all/${messageThreadExternalId}`;
window.open(url, '_blank');
break;
case null:
throw new Error('Account provider not provided');
default:
assertUnreachable(connectedAccountProvider);
}
};
if (!thread || !messages.length) {
return null;
}
return (
<StyledWrapper>
<StyledContainer>
{threadLoading ? (
<EmailLoader loadingText="Loading thread" />
) : (
<>
<EmailThreadHeader
subject={subject}
lastMessageSentAt={lastMessage.receivedAt}
/>
{firstMessages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))}
<CommandMenuMessageThreadIntermediaryMessages
messages={intermediaryMessages}
/>
<EmailThreadMessage
key={lastMessage.id}
sender={lastMessage.sender}
participants={lastMessage.messageParticipants}
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
isExpanded
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
onLastRowVisible={fetchMoreMessages}
/>
</>
)}
</StyledContainer>
{canReply && !messageChannelLoading && (
<StyledButtonContainer isMobile={isMobile}>
<Button
onClick={handleReplyClick}
title="Reply"
Icon={IconArrowBackUp}
disabled={!canReply}
/>
</StyledButtonContainer>
)}
</StyledWrapper>
);
};

View File

@ -0,0 +1,434 @@
import { renderHook, waitFor } from '@testing-library/react';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import gql from 'graphql-tag';
import { generateEmptyJestRecordNode } from '~/testing/jest/generateEmptyJestRecordNode';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { useEmailThreadInCommandMenu } from '../useEmailThreadInCommandMenu';
const mocks = [
{
request: {
query: gql`
query FindOneMessageThread($objectRecordId: ID!) {
messageThread(filter: { id: { eq: $objectRecordId } }) {
__typename
id
}
}
`,
variables: { objectRecordId: '1' },
},
result: jest.fn(() => ({
data: {
messageThread: {
id: '1',
__typename: 'MessageThread',
},
},
})),
},
{
request: {
query: gql`
query FindManyMessages(
$filter: MessageFilterInput
$orderBy: [MessageOrderByInput]
$lastCursor: String
$limit: Int
) {
messages(
filter: $filter
orderBy: $orderBy
first: $limit
after: $lastCursor
) {
edges {
node {
__typename
createdAt
headerMessageId
id
messageParticipants {
edges {
node {
__typename
displayName
handle
id
person {
__typename
avatarUrl
city
companyId
createdAt
createdBy {
source
workspaceMemberId
name
context
}
deletedAt
emails {
primaryEmail
additionalEmails
}
id
intro
jobTitle
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name {
firstName
lastName
}
performanceRating
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
position
updatedAt
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
workPreference
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
}
role
workspaceMember {
__typename
avatarUrl
colorScheme
createdAt
dateFormat
deletedAt
id
locale
name {
firstName
lastName
}
timeFormat
timeZone
updatedAt
userEmail
userId
}
}
}
}
messageThread {
__typename
id
}
receivedAt
subject
text
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
`,
variables: {
filter: { messageThreadId: { eq: '1' } },
orderBy: [{ receivedAt: 'AscNullsLast' }],
lastCursor: undefined,
limit: 10,
},
},
result: jest.fn(() => ({
data: {
messages: {
edges: [
{
node: generateEmptyJestRecordNode({
objectNameSingular: 'message',
input: {
id: '1',
text: 'Message 1',
createdAt: '2024-10-03T10:20:10.145Z',
},
}),
cursor: '1',
},
{
node: generateEmptyJestRecordNode({
objectNameSingular: 'message',
input: {
id: '2',
text: 'Message 2',
createdAt: '2024-10-03T10:20:10.145Z',
},
}),
cursor: '2',
},
],
totalCount: 2,
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '1',
endCursor: '2',
},
},
},
})),
},
{
request: {
query: gql`
query FindManyMessageParticipants(
$filter: MessageParticipantFilterInput
$orderBy: [MessageParticipantOrderByInput]
$lastCursor: String
$limit: Int
) {
messageParticipants(
filter: $filter
orderBy: $orderBy
first: $limit
after: $lastCursor
) {
edges {
node {
__typename
displayName
handle
id
messageId
person {
__typename
avatarUrl
city
companyId
createdAt
createdBy {
source
workspaceMemberId
name
context
}
deletedAt
emails {
primaryEmail
additionalEmails
}
id
intro
jobTitle
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name {
firstName
lastName
}
performanceRating
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
position
updatedAt
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones
}
workPreference
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
}
role
workspaceMember {
__typename
avatarUrl
colorScheme
createdAt
dateFormat
deletedAt
id
locale
name {
firstName
lastName
}
timeFormat
timeZone
updatedAt
userEmail
userId
}
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
`,
variables: {
filter: { messageId: { in: ['1', '2'] }, role: { eq: 'from' } },
orderBy: undefined,
lastCursor: undefined,
limit: undefined,
},
},
result: jest.fn(() => ({
data: {
messageParticipants: {
edges: [
{
node: generateEmptyJestRecordNode({
objectNameSingular: 'messageParticipant',
input: {
id: 'messageParticipant-1',
role: 'from',
messageId: '1',
},
}),
cursor: '1',
},
{
node: generateEmptyJestRecordNode({
objectNameSingular: 'messageParticipant',
input: {
id: 'messageParticipant-2',
role: 'from',
messageId: '2',
},
}),
cursor: '2',
},
],
totalCount: 2,
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '1',
endCursor: '2',
},
},
},
})),
},
];
const Wrapper = ({ children }: { children: React.ReactNode }) => {
const MetadataWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
onInitializeRecoilSnapshot: ({ set }) => {
set(
viewableRecordIdComponentState.atomFamily({
instanceId: 'test-instance',
}),
'1',
);
},
});
return (
<MetadataWrapper>
<CommandMenuPageComponentInstanceContext.Provider
value={{ instanceId: 'test-instance' }}
>
{children}
</CommandMenuPageComponentInstanceContext.Provider>
</MetadataWrapper>
);
};
describe('useEmailThreadInCommandMenu', () => {
it('should return correct values', async () => {
const mockMessages = [
{
__typename: 'Message',
createdAt: '2024-10-03T10:20:10.145Z',
headerMessageId: '',
id: '1',
messageParticipants: [],
messageThread: null,
receivedAt: null,
sender: {
__typename: 'MessageParticipant',
displayName: '',
handle: '',
id: 'messageParticipant-1',
messageId: '1',
person: null,
role: 'from',
workspaceMember: null,
},
subject: '',
text: 'Message 1',
},
{
__typename: 'Message',
createdAt: '2024-10-03T10:20:10.145Z',
headerMessageId: '',
id: '2',
messageParticipants: [],
messageThread: null,
receivedAt: null,
sender: {
__typename: 'MessageParticipant',
displayName: '',
handle: '',
id: 'messageParticipant-2',
messageId: '2',
person: null,
role: 'from',
workspaceMember: null,
},
subject: '',
text: 'Message 2',
},
];
const { result } = renderHook(() => useEmailThreadInCommandMenu(), {
wrapper: Wrapper,
});
await waitFor(() => {
expect(result.current.thread).toBeDefined();
expect(result.current.messages).toEqual(mockMessages);
expect(result.current.threadLoading).toBeFalsy();
expect(result.current.fetchMoreMessages).toBeInstanceOf(Function);
});
});
});

View File

@ -0,0 +1,188 @@
import { useCallback, useEffect, useState } from 'react';
import { fetchAllThreadMessagesOperationSignatureFactory } from '@/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory';
import { EmailThread } from '@/activities/emails/types/EmailThread';
import { EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage';
import { MessageChannel } from '@/accounts/types/MessageChannel';
import { EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
import { EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { MessageChannelMessageAssociation } from '@/activities/emails/types/MessageChannelMessageAssociation';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-shared';
export const useEmailThreadInCommandMenu = () => {
const viewableRecordId = useRecoilComponentValueV2(
viewableRecordIdComponentState,
);
const { upsertRecords } = useUpsertRecordsInStore();
const [lastMessageId, setLastMessageId] = useState<string | null>(null);
const [lastMessageChannelId, setLastMessageChannelId] = useState<
string | null
>(null);
const [isMessagesFetchComplete, setIsMessagesFetchComplete] = useState(false);
const { record: thread } = useFindOneRecord<EmailThread>({
objectNameSingular: CoreObjectNameSingular.MessageThread,
objectRecordId: viewableRecordId ?? '',
recordGqlFields: {
id: true,
},
onCompleted: (record) => {
upsertRecords([record]);
},
});
const FETCH_ALL_MESSAGES_OPERATION_SIGNATURE =
fetchAllThreadMessagesOperationSignatureFactory({
messageThreadId: viewableRecordId,
});
const {
records: messages,
loading: messagesLoading,
fetchMoreRecords,
hasNextPage,
} = useFindManyRecords<EmailThreadMessage>({
limit: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.variables.limit,
filter: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.variables.filter,
objectNameSingular:
FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.objectNameSingular,
orderBy: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.variables.orderBy,
recordGqlFields: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.fields,
skip: !viewableRecordId,
});
const fetchMoreMessages = useCallback(() => {
if (!messagesLoading && hasNextPage) {
fetchMoreRecords();
} else if (!hasNextPage) {
setIsMessagesFetchComplete(true);
}
}, [fetchMoreRecords, messagesLoading, hasNextPage]);
useEffect(() => {
if (messages.length > 0 && isMessagesFetchComplete) {
const lastMessage = messages[messages.length - 1];
setLastMessageId(lastMessage.id);
}
}, [messages, isMessagesFetchComplete]);
// TODO: introduce nested filters so we can retrieve the message sender directly from the message query
const { records: messageSenders } =
useFindManyRecords<EmailThreadMessageParticipant>({
filter: {
messageId: {
in: messages.map(({ id }) => id),
},
role: {
eq: 'from',
},
},
objectNameSingular: CoreObjectNameSingular.MessageParticipant,
recordGqlFields: {
id: true,
role: true,
displayName: true,
messageId: true,
handle: true,
person: true,
workspaceMember: true,
},
skip: messages.length === 0,
});
const { records: messageChannelMessageAssociationData } =
useFindManyRecords<MessageChannelMessageAssociation>({
filter: {
messageId: {
eq: lastMessageId ?? '',
},
},
objectNameSingular:
CoreObjectNameSingular.MessageChannelMessageAssociation,
recordGqlFields: {
id: true,
messageId: true,
messageChannelId: true,
messageThreadExternalId: true,
messageExternalId: true,
},
skip: !lastMessageId || !isMessagesFetchComplete,
});
useEffect(() => {
if (messageChannelMessageAssociationData.length > 0) {
setLastMessageChannelId(
messageChannelMessageAssociationData[0].messageChannelId,
);
}
}, [messageChannelMessageAssociationData]);
const { records: messageChannelData, loading: messageChannelLoading } =
useFindManyRecords<MessageChannel>({
filter: {
id: {
eq: lastMessageChannelId ?? '',
},
},
objectNameSingular: CoreObjectNameSingular.MessageChannel,
recordGqlFields: {
id: true,
handle: true,
connectedAccount: {
id: true,
provider: true,
},
},
skip: !lastMessageChannelId,
});
const messageThreadExternalId =
messageChannelMessageAssociationData.length > 0
? messageChannelMessageAssociationData[0].messageThreadExternalId
: null;
const lastMessageExternalId =
messageChannelMessageAssociationData.length > 0
? messageChannelMessageAssociationData[0].messageExternalId
: null;
const connectedAccountHandle =
messageChannelData.length > 0 ? messageChannelData[0].handle : null;
const messagesWithSender: EmailThreadMessageWithSender[] = messages
.map((message) => {
const sender = messageSenders.find(
(messageSender) => messageSender.messageId === message.id,
);
if (!sender) {
return null;
}
return {
...message,
sender,
};
})
.filter(isDefined);
const connectedAccount =
messageChannelData.length > 0
? messageChannelData[0]?.connectedAccount
: null;
const connectedAccountProvider = connectedAccount?.provider ?? null;
return {
thread,
messages: messagesWithSender,
messageThreadExternalId,
connectedAccountHandle,
connectedAccountProvider,
threadLoading: messagesLoading,
messageChannelLoading,
lastMessageExternalId,
fetchMoreMessages,
};
};

View File

@ -0,0 +1,10 @@
import { MessageThread } from '@/activities/emails/types/MessageThread';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const messageThreadComponentState =
createComponentStateV2<MessageThread | null>({
key: 'messageThreadComponentState',
defaultValue: null,
componentInstanceContext: CommandMenuPageComponentInstanceContext,
});

View File

@ -0,0 +1,92 @@
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { isNewViewableRecordLoadingComponentState } from '@/command-menu/pages/record-page/states/isNewViewableRecordLoadingComponentState';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { viewableRecordNameSingularComponentState } from '@/command-menu/pages/record-page/states/viewableRecordNameSingularComponentState';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { RecordFilterGroupsComponentInstanceContext } from '@/object-record/record-filter-group/states/context/RecordFilterGroupsComponentInstanceContext';
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { RecordShowContainer } from '@/object-record/record-show/components/RecordShowContainer';
import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage';
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import styled from '@emotion/styled';
const StyledRightDrawerRecord = styled.div<{ isMobile: boolean }>`
height: ${({ theme, isMobile }) =>
isMobile ? `calc(100% - ${theme.spacing(16)})` : '100%'};
`;
export const CommandMenuRecordPage = () => {
const isMobile = useIsMobile();
const viewableRecordNameSingular = useRecoilComponentValueV2(
viewableRecordNameSingularComponentState,
);
const isNewViewableRecordLoading = useRecoilComponentValueV2(
isNewViewableRecordLoadingComponentState,
);
const viewableRecordId = useRecoilComponentValueV2(
viewableRecordIdComponentState,
);
if (!viewableRecordNameSingular && !isNewViewableRecordLoading) {
throw new Error(`Object name is not defined`);
}
const { objectNameSingular, objectRecordId } = useRecordShowPage(
viewableRecordNameSingular ?? '',
viewableRecordId ?? '',
);
const commandMenuPageInstanceId = useComponentInstanceStateContext(
CommandMenuPageComponentInstanceContext,
)?.instanceId;
if (!commandMenuPageInstanceId) {
throw new Error('Command menu page instance id is not defined');
}
return (
<RecordFilterGroupsComponentInstanceContext.Provider
value={{ instanceId: `record-show-${objectRecordId}` }}
>
<RecordFiltersComponentInstanceContext.Provider
value={{ instanceId: `record-show-${objectRecordId}` }}
>
<RecordSortsComponentInstanceContext.Provider
value={{ instanceId: `record-show-${objectRecordId}` }}
>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: commandMenuPageInstanceId,
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: commandMenuPageInstanceId }}
>
<StyledRightDrawerRecord isMobile={isMobile}>
<RecordFieldValueSelectorContextProvider>
{!isNewViewableRecordLoading && (
<RecordValueSetterEffect recordId={objectRecordId} />
)}
<RecordShowContainer
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
loading={false}
isInRightDrawer={true}
isNewRightDrawerItemLoading={isNewViewableRecordLoading}
/>
</RecordFieldValueSelectorContextProvider>
</StyledRightDrawerRecord>
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</RecordSortsComponentInstanceContext.Provider>
</RecordFiltersComponentInstanceContext.Provider>
</RecordFilterGroupsComponentInstanceContext.Provider>
);
};

View File

@ -0,0 +1,9 @@
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const isNewViewableRecordLoadingComponentState =
createComponentStateV2<boolean>({
key: 'command-menu/is-new-viewable-record-loading',
defaultValue: false,
componentInstanceContext: CommandMenuPageComponentInstanceContext,
});

View File

@ -0,0 +1,10 @@
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const viewableRecordIdComponentState = createComponentStateV2<
string | null
>({
key: 'command-menu/viewable-record-id',
defaultValue: null,
componentInstanceContext: CommandMenuPageComponentInstanceContext,
});

View File

@ -0,0 +1,10 @@
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const viewableRecordNameSingularComponentState = createComponentStateV2<
string | null
>({
key: 'command-menu/viewable-record-name-singular',
defaultValue: null,
componentInstanceContext: CommandMenuPageComponentInstanceContext,
});

View File

@ -5,6 +5,7 @@ export type CommandMenuNavigationStackItem = {
page: CommandMenuPages;
pageTitle: string;
pageIcon: IconComponent;
pageId: string;
};
export const commandMenuNavigationStackState = createState<

View File

@ -2,9 +2,14 @@ import { createState } from '@ui/utilities/state/utils/createState';
import { IconComponent } from 'twenty-ui';
export const commandMenuPageInfoState = createState<{
title: string | undefined;
Icon: IconComponent | undefined;
title?: string;
Icon?: IconComponent;
instanceId: string;
}>({
key: 'command-menu/commandMenuPageInfoState',
defaultValue: { title: undefined, Icon: undefined },
defaultValue: {
title: undefined,
Icon: undefined,
instanceId: '',
},
});

View File

@ -0,0 +1,6 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const CommandMenuPageComponentInstanceContext =
createComponentInstanceContext({
instanceId: '',
});

View File

@ -1,5 +1,6 @@
import { createState } from '@ui/utilities/state/utils/createState';
// TODO: deprecate this state once we remove IS_COMMAND_MENU_V2_ENABLED flag
export const viewableRecordNameSingularState = createState<string | null>({
key: 'activities/viewable-record-name-singular',
defaultValue: null,

View File

@ -5,7 +5,6 @@ import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinit
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { SOFT_FOCUS_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/SoftFocusClickOutsideListenerId';
import { useLeaveTableFocus } from '@/object-record/record-table/hooks/internal/useLeaveTableFocus';
@ -23,6 +22,7 @@ import { isDefined } from 'twenty-shared';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { recordIndexOpenRecordInSelector } from '@/object-record/record-index/states/selectors/recordIndexOpenRecordInSelector';
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
import { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId';
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';