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:
@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user