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:
@ -5,8 +5,10 @@ import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMen
|
|||||||
import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown';
|
import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown';
|
||||||
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
|
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
|
||||||
import { contextStoreCurrentObjectMetadataItemComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemComponentState';
|
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 { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
|
import { isDefined } from 'twenty-shared';
|
||||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
export const RecordShowRightDrawerActionMenu = () => {
|
export const RecordShowRightDrawerActionMenu = () => {
|
||||||
@ -14,6 +16,10 @@ export const RecordShowRightDrawerActionMenu = () => {
|
|||||||
contextStoreCurrentObjectMetadataItemComponentState,
|
contextStoreCurrentObjectMetadataItemComponentState,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
|
||||||
|
contextStoreTargetedRecordsRuleComponentState,
|
||||||
|
);
|
||||||
|
|
||||||
const isWorkflowEnabled = useIsFeatureEnabled(
|
const isWorkflowEnabled = useIsFeatureEnabled(
|
||||||
FeatureFlagKey.IsWorkflowEnabled,
|
FeatureFlagKey.IsWorkflowEnabled,
|
||||||
);
|
);
|
||||||
@ -24,7 +30,12 @@ export const RecordShowRightDrawerActionMenu = () => {
|
|||||||
<ActionMenuContext.Provider value={{ isInRightDrawer: true }}>
|
<ActionMenuContext.Provider value={{ isInRightDrawer: true }}>
|
||||||
<RightDrawerActionMenuDropdown />
|
<RightDrawerActionMenuDropdown />
|
||||||
<ActionMenuConfirmationModals />
|
<ActionMenuConfirmationModals />
|
||||||
<RecordActionMenuEntriesSetter />
|
|
||||||
|
{isDefined(contextStoreTargetedRecordsRule) &&
|
||||||
|
contextStoreTargetedRecordsRule.mode === 'selection' &&
|
||||||
|
contextStoreTargetedRecordsRule.selectedRecordIds.length > 0 && (
|
||||||
|
<RecordActionMenuEntriesSetter />
|
||||||
|
)}
|
||||||
<RecordAgnosticActionMenuEntriesSetter />
|
<RecordAgnosticActionMenuEntriesSetter />
|
||||||
{isWorkflowEnabled && (
|
{isWorkflowEnabled && (
|
||||||
<RunWorkflowRecordAgnosticActionMenuEntriesSetter />
|
<RunWorkflowRecordAgnosticActionMenuEntriesSetter />
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendar
|
|||||||
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
|
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
|
||||||
import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded';
|
import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded';
|
||||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
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 { isDefined } from 'twenty-shared';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@ -22,6 +24,7 @@ import {
|
|||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
import {
|
import {
|
||||||
CalendarChannelVisibility,
|
CalendarChannelVisibility,
|
||||||
|
FeatureFlagKey,
|
||||||
TimelineCalendarEvent,
|
TimelineCalendarEvent,
|
||||||
} from '~/generated-metadata/graphql';
|
} from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
@ -115,6 +118,10 @@ export const CalendarEventRow = ({
|
|||||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||||
const { displayCurrentEventCursor = false } = useContext(CalendarContext);
|
const { displayCurrentEventCursor = false } = useContext(CalendarContext);
|
||||||
const { openCalendarEventRightDrawer } = useOpenCalendarEventRightDrawer();
|
const { openCalendarEventRightDrawer } = useOpenCalendarEventRightDrawer();
|
||||||
|
const { openCalendarEventInCommandMenu } = useCommandMenu();
|
||||||
|
const isCommandMenuV2Enabled = useIsFeatureEnabled(
|
||||||
|
FeatureFlagKey.IsCommandMenuV2Enabled,
|
||||||
|
);
|
||||||
|
|
||||||
const startsAt = getCalendarEventStartDate(calendarEvent);
|
const startsAt = getCalendarEventStartDate(calendarEvent);
|
||||||
const endsAt = getCalendarEventEndDate(calendarEvent);
|
const endsAt = getCalendarEventEndDate(calendarEvent);
|
||||||
@ -137,7 +144,13 @@ export const CalendarEventRow = ({
|
|||||||
showTitle={showTitle}
|
showTitle={showTitle}
|
||||||
onClick={
|
onClick={
|
||||||
showTitle
|
showTitle
|
||||||
? () => openCalendarEventRightDrawer(calendarEvent.id)
|
? () => {
|
||||||
|
if (isCommandMenuV2Enabled) {
|
||||||
|
openCalendarEventInCommandMenu(calendarEvent.id);
|
||||||
|
} else {
|
||||||
|
openCalendarEventRightDrawer(calendarEvent.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -6,8 +6,14 @@ import { ActivityRow } from '@/activities/components/ActivityRow';
|
|||||||
import { EmailThreadNotShared } from '@/activities/emails/components/EmailThreadNotShared';
|
import { EmailThreadNotShared } from '@/activities/emails/components/EmailThreadNotShared';
|
||||||
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
|
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
|
||||||
import { emailThreadIdWhenEmailThreadWasClosedState } from '@/activities/emails/states/lastViewableEmailThreadIdState';
|
import { emailThreadIdWhenEmailThreadWasClosedState } from '@/activities/emails/states/lastViewableEmailThreadIdState';
|
||||||
|
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||||
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
|
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';
|
import { formatToHumanReadableDate } from '~/utils/date-utils';
|
||||||
|
|
||||||
const StyledHeading = styled.div<{ unread: boolean }>`
|
const StyledHeading = styled.div<{ unread: boolean }>`
|
||||||
@ -72,6 +78,10 @@ type EmailThreadPreviewProps = {
|
|||||||
|
|
||||||
export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
|
export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
|
||||||
const { openEmailThread } = useEmailThread();
|
const { openEmailThread } = useEmailThread();
|
||||||
|
const { openEmailThreadInCommandMenu } = useCommandMenu();
|
||||||
|
const isCommandMenuV2Enabled = useIsFeatureEnabled(
|
||||||
|
FeatureFlagKey.IsCommandMenuV2Enabled,
|
||||||
|
);
|
||||||
|
|
||||||
const visibility = thread.visibility;
|
const visibility = thread.visibility;
|
||||||
|
|
||||||
@ -111,12 +121,18 @@ export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
|
|||||||
emailThreadIdWhenEmailThreadWasClosed !== thread.id);
|
emailThreadIdWhenEmailThreadWasClosed !== thread.id);
|
||||||
|
|
||||||
if (canOpen) {
|
if (canOpen) {
|
||||||
openEmailThread(thread.id);
|
if (isCommandMenuV2Enabled) {
|
||||||
|
openEmailThreadInCommandMenu(thread.id);
|
||||||
|
} else {
|
||||||
|
openEmailThread(thread.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
isCommandMenuV2Enabled,
|
||||||
isSameEventThanRightDrawerClose,
|
isSameEventThanRightDrawerClose,
|
||||||
openEmailThread,
|
openEmailThread,
|
||||||
|
openEmailThreadInCommandMenu,
|
||||||
thread.id,
|
thread.id,
|
||||||
thread.visibility,
|
thread.visibility,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { isUndefined } from '@sniptt/guards';
|
import { isUndefined } from '@sniptt/guards';
|
||||||
|
|
||||||
import { useOpenCalendarEventRightDrawer } from '@/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer';
|
|
||||||
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
|
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
|
||||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||||
@ -107,8 +106,6 @@ export const EventCardCalendarEvent = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { openCalendarEventRightDrawer } = useOpenCalendarEventRightDrawer();
|
|
||||||
|
|
||||||
const { timeZone } = useContext(UserContext);
|
const { timeZone } = useContext(UserContext);
|
||||||
|
|
||||||
if (isDefined(error)) {
|
if (isDefined(error)) {
|
||||||
@ -152,9 +149,7 @@ export const EventCardCalendarEvent = ({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledEventCardCalendarEventContainer
|
<StyledEventCardCalendarEventContainer>
|
||||||
onClick={() => openCalendarEventRightDrawer(calendarEvent.id)}
|
|
||||||
>
|
|
||||||
<StyledCalendarEventDateCard>
|
<StyledCalendarEventDateCard>
|
||||||
<StyledCalendarEventDateCardMonth>
|
<StyledCalendarEventDateCardMonth>
|
||||||
{startsAtMonth}
|
{startsAtMonth}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import styled from '@emotion/styled';
|
|||||||
import { isUndefined } from '@sniptt/guards';
|
import { isUndefined } from '@sniptt/guards';
|
||||||
import { OverflowingTextWithTooltip } from 'twenty-ui';
|
import { OverflowingTextWithTooltip } from 'twenty-ui';
|
||||||
|
|
||||||
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
|
|
||||||
import { EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage';
|
import { EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage';
|
||||||
import { EventCardMessageNotShared } from '@/activities/timeline-activities/rows/message/components/EventCardMessageNotShared';
|
import { EventCardMessageNotShared } from '@/activities/timeline-activities/rows/message/components/EventCardMessageNotShared';
|
||||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||||
@ -80,8 +79,6 @@ export const EventCardMessage = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { openEmailThread } = useEmailThread();
|
|
||||||
|
|
||||||
if (isDefined(error)) {
|
if (isDefined(error)) {
|
||||||
const shouldHideMessageContent = error.graphQLErrors.some(
|
const shouldHideMessageContent = error.graphQLErrors.some(
|
||||||
(e) => e.extensions?.code === 'FORBIDDEN',
|
(e) => e.extensions?.code === 'FORBIDDEN',
|
||||||
@ -120,11 +117,7 @@ export const EventCardMessage = ({
|
|||||||
<OverflowingTextWithTooltip text={messageParticipantHandles} />
|
<OverflowingTextWithTooltip text={messageParticipantHandles} />
|
||||||
</StyledEmailParticipants>
|
</StyledEmailParticipants>
|
||||||
</StyledEmailTop>
|
</StyledEmailTop>
|
||||||
<StyledEmailBody
|
<StyledEmailBody>{message.text}</StyledEmailBody>
|
||||||
onClick={() => openEmailThread(message.messageThreadId)}
|
|
||||||
>
|
|
||||||
{message.text}
|
|
||||||
</StyledEmailBody>
|
|
||||||
</StyledEmailContent>
|
</StyledEmailContent>
|
||||||
</StyledEventCardMessageContainer>
|
</StyledEventCardMessageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID } from '@/command-menu/constants/CommandMenuContextChipGroupsDropdownId';
|
import { COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID } from '@/command-menu/constants/CommandMenuContextChipGroupsDropdownId';
|
||||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
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 { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
|
||||||
import { isDefined } from 'twenty-shared';
|
import { isDefined } from 'twenty-shared';
|
||||||
import { MenuItem } from 'twenty-ui';
|
import { MenuItem } from 'twenty-ui';
|
||||||
@ -14,6 +15,8 @@ export const CommandMenuContextChipGroups = ({
|
|||||||
}: {
|
}: {
|
||||||
contextChips: CommandMenuContextChipProps[];
|
contextChips: CommandMenuContextChipProps[];
|
||||||
}) => {
|
}) => {
|
||||||
|
const { closeDropdown } = useDropdownV2();
|
||||||
|
|
||||||
if (contextChips.length === 0) {
|
if (contextChips.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -34,6 +37,7 @@ export const CommandMenuContextChipGroups = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const firstChips = contextChips.slice(0, -1);
|
const firstChips = contextChips.slice(0, -1);
|
||||||
|
const firstThreeChips = firstChips.slice(0, 3);
|
||||||
const lastChip = contextChips.at(-1);
|
const lastChip = contextChips.at(-1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -42,8 +46,9 @@ export const CommandMenuContextChipGroups = ({
|
|||||||
<Dropdown
|
<Dropdown
|
||||||
clickableComponent={
|
clickableComponent={
|
||||||
<CommandMenuContextChip
|
<CommandMenuContextChip
|
||||||
Icons={firstChips.map((chip) => chip.Icons?.[0])}
|
Icons={firstThreeChips.map((chip) => chip.Icons?.[0])}
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
|
text={`${firstChips.length}`}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
dropdownComponents={
|
dropdownComponents={
|
||||||
@ -52,7 +57,10 @@ export const CommandMenuContextChipGroups = ({
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
LeftComponent={chip.Icons}
|
LeftComponent={chip.Icons}
|
||||||
text={chip.text}
|
text={chip.text}
|
||||||
onClick={chip.onClick}
|
onClick={() => {
|
||||||
|
closeDropdown(COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID);
|
||||||
|
chip.onClick?.();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuItemsContainer>
|
</DropdownMenuItemsContainer>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { getSelectedRecordsContextText } from '@/command-menu/utils/getRecordCon
|
|||||||
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
|
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
|
||||||
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||||
|
|
||||||
export const CommandMenuContextRecordChip = ({
|
export const CommandMenuContextRecordsChip = ({
|
||||||
objectMetadataItemId,
|
objectMetadataItemId,
|
||||||
instanceId,
|
instanceId,
|
||||||
}: {
|
}: {
|
||||||
@ -1,7 +1,9 @@
|
|||||||
import { CommandMenuContainer } from '@/command-menu/components/CommandMenuContainer';
|
import { CommandMenuContainer } from '@/command-menu/components/CommandMenuContainer';
|
||||||
import { CommandMenuTopBar } from '@/command-menu/components/CommandMenuTopBar';
|
import { CommandMenuTopBar } from '@/command-menu/components/CommandMenuTopBar';
|
||||||
import { COMMAND_MENU_PAGES_CONFIG } from '@/command-menu/constants/CommandMenuPagesConfig';
|
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 { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
|
||||||
|
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
|
||||||
import { useTheme } from '@emotion/react';
|
import { useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
@ -16,6 +18,8 @@ const StyledCommandMenuContent = styled.div`
|
|||||||
export const CommandMenuRouter = () => {
|
export const CommandMenuRouter = () => {
|
||||||
const commandMenuPage = useRecoilValue(commandMenuPageState);
|
const commandMenuPage = useRecoilValue(commandMenuPageState);
|
||||||
|
|
||||||
|
const commandMenuPageInfo = useRecoilValue(commandMenuPageInfoState);
|
||||||
|
|
||||||
const commandMenuPageComponent = isDefined(commandMenuPage) ? (
|
const commandMenuPageComponent = isDefined(commandMenuPage) ? (
|
||||||
COMMAND_MENU_PAGES_CONFIG.get(commandMenuPage)
|
COMMAND_MENU_PAGES_CONFIG.get(commandMenuPage)
|
||||||
) : (
|
) : (
|
||||||
@ -26,20 +30,24 @@ export const CommandMenuRouter = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandMenuContainer>
|
<CommandMenuContainer>
|
||||||
<motion.div
|
<CommandMenuPageComponentInstanceContext.Provider
|
||||||
initial={{ opacity: 0 }}
|
value={{ instanceId: commandMenuPageInfo.instanceId }}
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{
|
|
||||||
duration: theme.animation.duration.instant,
|
|
||||||
delay: 0.1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<CommandMenuTopBar />
|
<motion.div
|
||||||
</motion.div>
|
initial={{ opacity: 0 }}
|
||||||
<StyledCommandMenuContent>
|
animate={{ opacity: 1 }}
|
||||||
{commandMenuPageComponent}
|
exit={{ opacity: 0 }}
|
||||||
</StyledCommandMenuContent>
|
transition={{
|
||||||
|
duration: theme.animation.duration.instant,
|
||||||
|
delay: 0.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CommandMenuTopBar />
|
||||||
|
</motion.div>
|
||||||
|
<StyledCommandMenuContent>
|
||||||
|
{commandMenuPageComponent}
|
||||||
|
</StyledCommandMenuContent>
|
||||||
|
</CommandMenuPageComponentInstanceContext.Provider>
|
||||||
</CommandMenuContainer>
|
</CommandMenuContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -128,16 +128,21 @@ export const CommandMenuTopBar = () => {
|
|||||||
(page) => page.page !== CommandMenuPages.Root,
|
(page) => page.page !== CommandMenuPages.Root,
|
||||||
);
|
);
|
||||||
|
|
||||||
return filteredCommandMenuNavigationStack.map((page, index) => ({
|
return filteredCommandMenuNavigationStack.map((page, index) => {
|
||||||
Icons: [<page.pageIcon size={theme.icon.size.sm} />],
|
const isLastChip =
|
||||||
text: page.pageTitle,
|
index === filteredCommandMenuNavigationStack.length - 1;
|
||||||
onClick:
|
|
||||||
index === filteredCommandMenuNavigationStack.length - 1
|
return {
|
||||||
|
page,
|
||||||
|
Icons: [<page.pageIcon size={theme.icon.size.sm} />],
|
||||||
|
text: page.pageTitle,
|
||||||
|
onClick: isLastChip
|
||||||
? undefined
|
? undefined
|
||||||
: () => {
|
: () => {
|
||||||
navigateCommandMenuHistory(index);
|
navigateCommandMenuHistory(index);
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}, [
|
}, [
|
||||||
commandMenuNavigationStack,
|
commandMenuNavigationStack,
|
||||||
navigateCommandMenuHistory,
|
navigateCommandMenuHistory,
|
||||||
|
|||||||
@ -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 { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
|
||||||
import { RESET_CONTEXT_TO_SELECTION } from '@/command-menu/constants/ResetContextToSelection';
|
import { RESET_CONTEXT_TO_SELECTION } from '@/command-menu/constants/ResetContextToSelection';
|
||||||
import { useResetPreviousCommandMenuContext } from '@/command-menu/hooks/useResetPreviousCommandMenuContext';
|
import { useResetPreviousCommandMenuContext } from '@/command-menu/hooks/useResetPreviousCommandMenuContext';
|
||||||
@ -46,7 +46,7 @@ export const ResetContextToSelectionCommandButton = () => {
|
|||||||
Icon={IconArrowBackUp}
|
Icon={IconArrowBackUp}
|
||||||
label={t`Reset to`}
|
label={t`Reset to`}
|
||||||
RightComponent={
|
RightComponent={
|
||||||
<CommandMenuContextRecordChip
|
<CommandMenuContextRecordsChip
|
||||||
objectMetadataItemId={objectMetadataItem.id}
|
objectMetadataItemId={objectMetadataItem.id}
|
||||||
instanceId="command-menu-previous"
|
instanceId="command-menu-previous"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -100,6 +100,7 @@ const meta: Meta<typeof CommandMenu> = {
|
|||||||
page: CommandMenuPages.Root,
|
page: CommandMenuPages.Root,
|
||||||
pageTitle: 'Command Menu',
|
pageTitle: 'Command Menu',
|
||||||
pageIcon: IconDotsVertical,
|
pageIcon: IconDotsVertical,
|
||||||
|
pageId: '1',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { gql } from '@apollo/client';
|
import { gql } from '@apollo/client';
|
||||||
import { Decorator, Meta, StoryObj } from '@storybook/react';
|
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 { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext';
|
||||||
import { RecordChipData } from '@/object-record/record-field/types/RecordChipData';
|
import { RecordChipData } from '@/object-record/record-field/types/RecordChipData';
|
||||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
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',
|
title: 'Modules/CommandMenu/CommandMenuContextRecordChip',
|
||||||
component: CommandMenuContextRecordChip,
|
component: CommandMenuContextRecordsChip,
|
||||||
decorators: [
|
decorators: [
|
||||||
ContextStoreDecorator,
|
ContextStoreDecorator,
|
||||||
ChipGeneratorsDecorator,
|
ChipGeneratorsDecorator,
|
||||||
@ -221,7 +221,7 @@ const meta: Meta<typeof CommandMenuContextRecordChip> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof CommandMenuContextRecordChip>;
|
type Story = StoryObj<typeof CommandMenuContextRecordsChip>;
|
||||||
|
|
||||||
export const Default: Story = {};
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { RightDrawerCalendarEvent } from '@/activities/calendar/right-drawer/components/RightDrawerCalendarEvent';
|
|
||||||
import { RightDrawerAIChat } from '@/activities/copilot/right-drawer/components/RightDrawerAIChat';
|
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 { 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 { 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 { 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 { RightDrawerWorkflowEditStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowEditStep';
|
||||||
import { RightDrawerWorkflowRunViewStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowRunViewStep';
|
import { RightDrawerWorkflowRunViewStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowRunViewStep';
|
||||||
import { RightDrawerWorkflowViewStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowViewStep';
|
import { RightDrawerWorkflowViewStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowViewStep';
|
||||||
@ -16,9 +16,9 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map<
|
|||||||
React.ReactNode
|
React.ReactNode
|
||||||
>([
|
>([
|
||||||
[CommandMenuPages.Root, <CommandMenu />],
|
[CommandMenuPages.Root, <CommandMenu />],
|
||||||
[CommandMenuPages.ViewRecord, <RightDrawerRecord />],
|
[CommandMenuPages.ViewRecord, <CommandMenuRecordPage />],
|
||||||
[CommandMenuPages.ViewEmailThread, <RightDrawerEmailThread />],
|
[CommandMenuPages.ViewEmailThread, <CommandMenuMessageThreadPage />],
|
||||||
[CommandMenuPages.ViewCalendarEvent, <RightDrawerCalendarEvent />],
|
[CommandMenuPages.ViewCalendarEvent, <CommandMenuCalendarEventPage />],
|
||||||
[CommandMenuPages.Copilot, <RightDrawerAIChat />],
|
[CommandMenuPages.Copilot, <RightDrawerAIChat />],
|
||||||
[
|
[
|
||||||
CommandMenuPages.WorkflowStepSelectTriggerType,
|
CommandMenuPages.WorkflowStepSelectTriggerType,
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import { RecoilRoot, useRecoilValue } from 'recoil';
|
|||||||
|
|
||||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||||
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
|
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
|
||||||
|
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
|
||||||
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
|
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
|
||||||
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageTitle';
|
|
||||||
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
|
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
|
||||||
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
||||||
import { IconList, IconSearch } from 'twenty-ui';
|
import { IconList, IconSearch } from 'twenty-ui';
|
||||||
@ -91,6 +91,7 @@ describe('useCommandMenu', () => {
|
|||||||
expect(result.current.commandMenuPageInfo).toEqual({
|
expect(result.current.commandMenuPageInfo).toEqual({
|
||||||
title: undefined,
|
title: undefined,
|
||||||
Icon: undefined,
|
Icon: undefined,
|
||||||
|
instanceId: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -98,6 +99,7 @@ describe('useCommandMenu', () => {
|
|||||||
page: CommandMenuPages.SearchRecords,
|
page: CommandMenuPages.SearchRecords,
|
||||||
pageTitle: 'Search',
|
pageTitle: 'Search',
|
||||||
pageIcon: IconSearch,
|
pageIcon: IconSearch,
|
||||||
|
pageId: '1',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -106,12 +108,14 @@ describe('useCommandMenu', () => {
|
|||||||
page: CommandMenuPages.SearchRecords,
|
page: CommandMenuPages.SearchRecords,
|
||||||
pageTitle: 'Search',
|
pageTitle: 'Search',
|
||||||
pageIcon: IconSearch,
|
pageIcon: IconSearch,
|
||||||
|
pageId: '1',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(result.current.commandMenuPage).toBe(CommandMenuPages.SearchRecords);
|
expect(result.current.commandMenuPage).toBe(CommandMenuPages.SearchRecords);
|
||||||
expect(result.current.commandMenuPageInfo).toEqual({
|
expect(result.current.commandMenuPageInfo).toEqual({
|
||||||
title: 'Search',
|
title: 'Search',
|
||||||
Icon: IconSearch,
|
Icon: IconSearch,
|
||||||
|
instanceId: '1',
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -119,6 +123,7 @@ describe('useCommandMenu', () => {
|
|||||||
page: CommandMenuPages.ViewRecord,
|
page: CommandMenuPages.ViewRecord,
|
||||||
pageTitle: 'Company',
|
pageTitle: 'Company',
|
||||||
pageIcon: IconList,
|
pageIcon: IconList,
|
||||||
|
pageId: '2',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -127,17 +132,20 @@ describe('useCommandMenu', () => {
|
|||||||
page: CommandMenuPages.SearchRecords,
|
page: CommandMenuPages.SearchRecords,
|
||||||
pageTitle: 'Search',
|
pageTitle: 'Search',
|
||||||
pageIcon: IconSearch,
|
pageIcon: IconSearch,
|
||||||
|
pageId: '1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
page: CommandMenuPages.ViewRecord,
|
page: CommandMenuPages.ViewRecord,
|
||||||
pageTitle: 'Company',
|
pageTitle: 'Company',
|
||||||
pageIcon: IconList,
|
pageIcon: IconList,
|
||||||
|
pageId: '2',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(result.current.commandMenuPage).toBe(CommandMenuPages.ViewRecord);
|
expect(result.current.commandMenuPage).toBe(CommandMenuPages.ViewRecord);
|
||||||
expect(result.current.commandMenuPageInfo).toEqual({
|
expect(result.current.commandMenuPageInfo).toEqual({
|
||||||
title: 'Company',
|
title: 'Company',
|
||||||
Icon: IconList,
|
Icon: IconList,
|
||||||
|
instanceId: '2',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -149,6 +157,7 @@ describe('useCommandMenu', () => {
|
|||||||
page: CommandMenuPages.SearchRecords,
|
page: CommandMenuPages.SearchRecords,
|
||||||
pageTitle: 'Search',
|
pageTitle: 'Search',
|
||||||
pageIcon: IconSearch,
|
pageIcon: IconSearch,
|
||||||
|
pageId: '1',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -157,6 +166,7 @@ describe('useCommandMenu', () => {
|
|||||||
page: CommandMenuPages.ViewRecord,
|
page: CommandMenuPages.ViewRecord,
|
||||||
pageTitle: 'Company',
|
pageTitle: 'Company',
|
||||||
pageIcon: IconList,
|
pageIcon: IconList,
|
||||||
|
pageId: '2',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -165,11 +175,13 @@ describe('useCommandMenu', () => {
|
|||||||
page: CommandMenuPages.SearchRecords,
|
page: CommandMenuPages.SearchRecords,
|
||||||
pageTitle: 'Search',
|
pageTitle: 'Search',
|
||||||
pageIcon: IconSearch,
|
pageIcon: IconSearch,
|
||||||
|
pageId: '1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
page: CommandMenuPages.ViewRecord,
|
page: CommandMenuPages.ViewRecord,
|
||||||
pageTitle: 'Company',
|
pageTitle: 'Company',
|
||||||
pageIcon: IconList,
|
pageIcon: IconList,
|
||||||
|
pageId: '2',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -182,12 +194,14 @@ describe('useCommandMenu', () => {
|
|||||||
page: CommandMenuPages.SearchRecords,
|
page: CommandMenuPages.SearchRecords,
|
||||||
pageTitle: 'Search',
|
pageTitle: 'Search',
|
||||||
pageIcon: IconSearch,
|
pageIcon: IconSearch,
|
||||||
|
pageId: '1',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(result.current.commandMenuPage).toBe(CommandMenuPages.SearchRecords);
|
expect(result.current.commandMenuPage).toBe(CommandMenuPages.SearchRecords);
|
||||||
expect(result.current.commandMenuPageInfo).toEqual({
|
expect(result.current.commandMenuPageInfo).toEqual({
|
||||||
title: 'Search',
|
title: 'Search',
|
||||||
Icon: IconSearch,
|
Icon: IconSearch,
|
||||||
|
instanceId: '1',
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -199,6 +213,7 @@ describe('useCommandMenu', () => {
|
|||||||
expect(result.current.commandMenuPage).toBe(CommandMenuPages.Root);
|
expect(result.current.commandMenuPage).toBe(CommandMenuPages.Root);
|
||||||
expect(result.current.commandMenuPageInfo).toEqual({
|
expect(result.current.commandMenuPageInfo).toEqual({
|
||||||
title: undefined,
|
title: undefined,
|
||||||
|
instanceId: '',
|
||||||
Icon: undefined,
|
Icon: undefined,
|
||||||
});
|
});
|
||||||
expect(result.current.isCommandMenuOpened).toBe(false);
|
expect(result.current.isCommandMenuOpened).toBe(false);
|
||||||
@ -212,6 +227,7 @@ describe('useCommandMenu', () => {
|
|||||||
page: CommandMenuPages.SearchRecords,
|
page: CommandMenuPages.SearchRecords,
|
||||||
pageTitle: 'Search',
|
pageTitle: 'Search',
|
||||||
pageIcon: IconSearch,
|
pageIcon: IconSearch,
|
||||||
|
pageId: '1',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -223,6 +239,7 @@ describe('useCommandMenu', () => {
|
|||||||
expect(result.current.commandMenuPageInfo).toEqual({
|
expect(result.current.commandMenuPageInfo).toEqual({
|
||||||
title: 'Search',
|
title: 'Search',
|
||||||
Icon: IconSearch,
|
Icon: IconSearch,
|
||||||
|
instanceId: '1',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,19 +5,25 @@ import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objec
|
|||||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||||
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
|
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_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
|
||||||
import { COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID } from '@/command-menu/constants/CommandMenuContextChipGroupsDropdownId';
|
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 { COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuPreviousComponentInstanceId';
|
||||||
import { useCopyContextStoreStates } from '@/command-menu/hooks/useCopyContextStoreAndActionMenuStates';
|
import { useCopyContextStoreStates } from '@/command-menu/hooks/useCopyContextStoreAndActionMenuStates';
|
||||||
import { useResetContextStoreStates } from '@/command-menu/hooks/useResetContextStoreStates';
|
import { useResetContextStoreStates } from '@/command-menu/hooks/useResetContextStoreStates';
|
||||||
import {
|
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
|
||||||
CommandMenuNavigationStackItem,
|
import { viewableRecordNameSingularComponentState } from '@/command-menu/pages/record-page/states/viewableRecordNameSingularComponentState';
|
||||||
commandMenuNavigationStackState,
|
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
|
||||||
} from '@/command-menu/states/commandMenuNavigationStackState';
|
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
|
||||||
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
|
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
|
||||||
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageTitle';
|
|
||||||
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
|
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
|
||||||
import { isCommandMenuClosingState } from '@/command-menu/states/isCommandMenuClosingState';
|
import { isCommandMenuClosingState } from '@/command-menu/states/isCommandMenuClosingState';
|
||||||
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
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 { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
|
||||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||||
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
|
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 { 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 { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
|
||||||
import { emitRightDrawerCloseEvent } from '@/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent';
|
import { emitRightDrawerCloseEvent } from '@/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent';
|
||||||
import { isDragSelectionStartEnabledState } from '@/ui/utilities/drag-select/states/internal/isDragSelectionStartEnabledState';
|
import { isDragSelectionStartEnabledState } from '@/ui/utilities/drag-select/states/internal/isDragSelectionStartEnabledState';
|
||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { capitalize, isDefined } from 'twenty-shared';
|
import { capitalize, isDefined } from 'twenty-shared';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
|
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
|
||||||
|
|
||||||
|
export type CommandMenuNavigationStackItem = {
|
||||||
|
page: CommandMenuPages;
|
||||||
|
pageTitle: string;
|
||||||
|
pageIcon: IconComponent;
|
||||||
|
pageId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const useCommandMenu = () => {
|
export const useCommandMenu = () => {
|
||||||
const { resetSelectedItem } = useSelectableList('command-menu-list');
|
const { resetSelectedItem } = useSelectableList('command-menu-list');
|
||||||
const {
|
const {
|
||||||
@ -76,6 +88,7 @@ export const useCommandMenu = () => {
|
|||||||
set(commandMenuPageInfoState, {
|
set(commandMenuPageInfoState, {
|
||||||
title: undefined,
|
title: undefined,
|
||||||
Icon: undefined,
|
Icon: undefined,
|
||||||
|
instanceId: '',
|
||||||
});
|
});
|
||||||
set(isCommandMenuOpenedState, false);
|
set(isCommandMenuOpenedState, false);
|
||||||
set(commandMenuSearchState, '');
|
set(commandMenuSearchState, '');
|
||||||
@ -138,15 +151,21 @@ export const useCommandMenu = () => {
|
|||||||
page,
|
page,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
pageIcon,
|
pageIcon,
|
||||||
|
pageId,
|
||||||
resetNavigationStack = false,
|
resetNavigationStack = false,
|
||||||
}: CommandMenuNavigationStackItem & {
|
}: CommandMenuNavigationStackItem & {
|
||||||
resetNavigationStack?: boolean;
|
resetNavigationStack?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (!pageId) {
|
||||||
|
pageId = v4();
|
||||||
|
}
|
||||||
|
|
||||||
openCommandMenu();
|
openCommandMenu();
|
||||||
set(commandMenuPageState, page);
|
set(commandMenuPageState, page);
|
||||||
set(commandMenuPageInfoState, {
|
set(commandMenuPageInfoState, {
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
Icon: pageIcon,
|
Icon: pageIcon,
|
||||||
|
instanceId: pageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isCommandMenuClosing = snapshot
|
const isCommandMenuClosing = snapshot
|
||||||
@ -157,16 +176,24 @@ export const useCommandMenu = () => {
|
|||||||
? []
|
? []
|
||||||
: snapshot.getLoadable(commandMenuNavigationStackState).getValue();
|
: snapshot.getLoadable(commandMenuNavigationStackState).getValue();
|
||||||
|
|
||||||
const itemIsAlreadyInStack = currentNavigationStack.some(
|
if (resetNavigationStack) {
|
||||||
(item) => item.page === page,
|
set(commandMenuNavigationStackState, [
|
||||||
);
|
{
|
||||||
|
page,
|
||||||
if (resetNavigationStack || itemIsAlreadyInStack) {
|
pageTitle,
|
||||||
set(commandMenuNavigationStackState, [{ page, pageTitle, pageIcon }]);
|
pageIcon,
|
||||||
|
pageId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
set(commandMenuNavigationStackState, [
|
set(commandMenuNavigationStackState, [
|
||||||
...currentNavigationStack,
|
...currentNavigationStack,
|
||||||
{ page, pageTitle, pageIcon },
|
{
|
||||||
|
page,
|
||||||
|
pageTitle,
|
||||||
|
pageIcon,
|
||||||
|
pageId,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -221,6 +248,7 @@ export const useCommandMenu = () => {
|
|||||||
set(commandMenuPageInfoState, {
|
set(commandMenuPageInfoState, {
|
||||||
title: lastNavigationStackItem.pageTitle,
|
title: lastNavigationStackItem.pageTitle,
|
||||||
Icon: lastNavigationStackItem.pageIcon,
|
Icon: lastNavigationStackItem.pageIcon,
|
||||||
|
instanceId: lastNavigationStackItem.pageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
set(commandMenuNavigationStackState, newNavigationStack);
|
set(commandMenuNavigationStackState, newNavigationStack);
|
||||||
@ -252,6 +280,7 @@ export const useCommandMenu = () => {
|
|||||||
set(commandMenuPageInfoState, {
|
set(commandMenuPageInfoState, {
|
||||||
title: newNavigationStackItem?.pageTitle,
|
title: newNavigationStackItem?.pageTitle,
|
||||||
Icon: newNavigationStackItem?.pageIcon,
|
Icon: newNavigationStackItem?.pageIcon,
|
||||||
|
instanceId: newNavigationStackItem?.pageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
set(hasUserSelectedCommandState, false);
|
set(hasUserSelectedCommandState, false);
|
||||||
@ -269,7 +298,20 @@ export const useCommandMenu = () => {
|
|||||||
objectNameSingular: string;
|
objectNameSingular: string;
|
||||||
isNewRecord?: boolean;
|
isNewRecord?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
set(viewableRecordNameSingularState, objectNameSingular);
|
const pageComponentInstanceId = v4();
|
||||||
|
|
||||||
|
set(
|
||||||
|
viewableRecordNameSingularComponentState.atomFamily({
|
||||||
|
instanceId: pageComponentInstanceId,
|
||||||
|
}),
|
||||||
|
objectNameSingular,
|
||||||
|
);
|
||||||
|
set(
|
||||||
|
viewableRecordIdComponentState.atomFamily({
|
||||||
|
instanceId: pageComponentInstanceId,
|
||||||
|
}),
|
||||||
|
recordId,
|
||||||
|
);
|
||||||
set(viewableRecordIdState, recordId);
|
set(viewableRecordIdState, recordId);
|
||||||
|
|
||||||
const objectMetadataItem = snapshot
|
const objectMetadataItem = snapshot
|
||||||
@ -289,14 +331,14 @@ export const useCommandMenu = () => {
|
|||||||
|
|
||||||
set(
|
set(
|
||||||
contextStoreCurrentObjectMetadataItemComponentState.atomFamily({
|
contextStoreCurrentObjectMetadataItemComponentState.atomFamily({
|
||||||
instanceId: RIGHT_DRAWER_RECORD_INSTANCE_ID,
|
instanceId: pageComponentInstanceId,
|
||||||
}),
|
}),
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
);
|
);
|
||||||
|
|
||||||
set(
|
set(
|
||||||
contextStoreTargetedRecordsRuleComponentState.atomFamily({
|
contextStoreTargetedRecordsRuleComponentState.atomFamily({
|
||||||
instanceId: RIGHT_DRAWER_RECORD_INSTANCE_ID,
|
instanceId: pageComponentInstanceId,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
mode: 'selection',
|
mode: 'selection',
|
||||||
@ -306,21 +348,21 @@ export const useCommandMenu = () => {
|
|||||||
|
|
||||||
set(
|
set(
|
||||||
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
|
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
|
||||||
instanceId: RIGHT_DRAWER_RECORD_INSTANCE_ID,
|
instanceId: pageComponentInstanceId,
|
||||||
}),
|
}),
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
set(
|
set(
|
||||||
contextStoreCurrentViewTypeComponentState.atomFamily({
|
contextStoreCurrentViewTypeComponentState.atomFamily({
|
||||||
instanceId: RIGHT_DRAWER_RECORD_INSTANCE_ID,
|
instanceId: pageComponentInstanceId,
|
||||||
}),
|
}),
|
||||||
ContextStoreViewType.ShowPage,
|
ContextStoreViewType.ShowPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
set(
|
set(
|
||||||
contextStoreCurrentViewIdComponentState.atomFamily({
|
contextStoreCurrentViewIdComponentState.atomFamily({
|
||||||
instanceId: RIGHT_DRAWER_RECORD_INSTANCE_ID,
|
instanceId: pageComponentInstanceId,
|
||||||
}),
|
}),
|
||||||
snapshot
|
snapshot
|
||||||
.getLoadable(
|
.getLoadable(
|
||||||
@ -343,8 +385,8 @@ export const useCommandMenu = () => {
|
|||||||
? t`New ${capitalizedObjectNameSingular}`
|
? t`New ${capitalizedObjectNameSingular}`
|
||||||
: capitalizedObjectNameSingular,
|
: capitalizedObjectNameSingular,
|
||||||
pageIcon: Icon,
|
pageIcon: Icon,
|
||||||
// TODO: remove this once we can store the navigation stack page states
|
pageId: pageComponentInstanceId,
|
||||||
resetNavigationStack: true,
|
resetNavigationStack: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -356,9 +398,56 @@ export const useCommandMenu = () => {
|
|||||||
page: CommandMenuPages.SearchRecords,
|
page: CommandMenuPages.SearchRecords,
|
||||||
pageTitle: 'Search',
|
pageTitle: 'Search',
|
||||||
pageIcon: IconSearch,
|
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(
|
const setGlobalCommandMenuContext = useRecoilCallback(
|
||||||
({ set }) => {
|
({ set }) => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -401,6 +490,7 @@ export const useCommandMenu = () => {
|
|||||||
set(commandMenuPageInfoState, {
|
set(commandMenuPageInfoState, {
|
||||||
title: undefined,
|
title: undefined,
|
||||||
Icon: undefined,
|
Icon: undefined,
|
||||||
|
instanceId: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
set(hasUserSelectedCommandState, false);
|
set(hasUserSelectedCommandState, false);
|
||||||
@ -420,5 +510,7 @@ export const useCommandMenu = () => {
|
|||||||
openRecordInCommandMenu,
|
openRecordInCommandMenu,
|
||||||
toggleCommandMenu,
|
toggleCommandMenu,
|
||||||
setGlobalCommandMenuContext,
|
setGlobalCommandMenuContext,
|
||||||
|
openCalendarEventInCommandMenu,
|
||||||
|
openEmailThreadInCommandMenu,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
});
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
});
|
||||||
@ -5,6 +5,7 @@ export type CommandMenuNavigationStackItem = {
|
|||||||
page: CommandMenuPages;
|
page: CommandMenuPages;
|
||||||
pageTitle: string;
|
pageTitle: string;
|
||||||
pageIcon: IconComponent;
|
pageIcon: IconComponent;
|
||||||
|
pageId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const commandMenuNavigationStackState = createState<
|
export const commandMenuNavigationStackState = createState<
|
||||||
|
|||||||
@ -2,9 +2,14 @@ import { createState } from '@ui/utilities/state/utils/createState';
|
|||||||
import { IconComponent } from 'twenty-ui';
|
import { IconComponent } from 'twenty-ui';
|
||||||
|
|
||||||
export const commandMenuPageInfoState = createState<{
|
export const commandMenuPageInfoState = createState<{
|
||||||
title: string | undefined;
|
title?: string;
|
||||||
Icon: IconComponent | undefined;
|
Icon?: IconComponent;
|
||||||
|
instanceId: string;
|
||||||
}>({
|
}>({
|
||||||
key: 'command-menu/commandMenuPageInfoState',
|
key: 'command-menu/commandMenuPageInfoState',
|
||||||
defaultValue: { title: undefined, Icon: undefined },
|
defaultValue: {
|
||||||
|
title: undefined,
|
||||||
|
Icon: undefined,
|
||||||
|
instanceId: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
|
||||||
|
|
||||||
|
export const CommandMenuPageComponentInstanceContext =
|
||||||
|
createComponentInstanceContext({
|
||||||
|
instanceId: '',
|
||||||
|
});
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { createState } from '@ui/utilities/state/utils/createState';
|
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>({
|
export const viewableRecordNameSingularState = createState<string | null>({
|
||||||
key: 'activities/viewable-record-name-singular',
|
key: 'activities/viewable-record-name-singular',
|
||||||
defaultValue: null,
|
defaultValue: null,
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinit
|
|||||||
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
||||||
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
|
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
|
||||||
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
|
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 { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
|
||||||
import { SOFT_FOCUS_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/SoftFocusClickOutsideListenerId';
|
import { SOFT_FOCUS_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/SoftFocusClickOutsideListenerId';
|
||||||
import { useLeaveTableFocus } from '@/object-record/record-table/hooks/internal/useLeaveTableFocus';
|
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 { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||||
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
|
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
|
||||||
import { recordIndexOpenRecordInSelector } from '@/object-record/record-index/states/selectors/recordIndexOpenRecordInSelector';
|
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 { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId';
|
||||||
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
|
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
|
||||||
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
|
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
|
||||||
|
|||||||
Reference in New Issue
Block a user