diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 91fc0a72d7e..c8633c541ad 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -22,7 +22,7 @@ Good first issues are a great way to start contributing to the project and get f 2. Use the "Labels" filter and select "[Good First Issue](https://github.com/twentyhq/twenty/labels/good%20first%20issue)" to see a list of beginner-friendly tasks. 3. Choose an issue that interests you, fork the project, and start working on it. Once you solve and test the issue, open a PR for review. -Note: We are aware that having multiple contributors address the same issue can cause frustration. To prevent this, we adhere to a specific guideline: if a core team member has assigned an issue to a contributor, either as the issue assignee or through explicit assignment in the issue comments within the past three days, that contributor's pull request takes precedence. Otherwise, the first PR submitted will be given priority. +Note: We are aware that having multiple contributors address the same issue can cause frustration. To prevent this, we adhere to a specific guideline: if a core team member has assigned an issue to a contributor, either as the issue assignee or through explicit assignment in the issue comments within the past three days, that contributor's pull request takes precedence. Otherwise, the first PR submitted will be given priority. This delay is reduced to one day for PR tagged with "size: minutes" and extended to a week for PR tagged "size: days". Therefore, ensure you are assigned to an issue before beginning work on it. diff --git a/.gitignore b/.gitignore index ac1000ee959..945b498ddc6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,6 @@ !.yarn/sdks !.yarn/versions coverage -.vercel \ No newline at end of file +.vercel + +**/**/logs/** \ No newline at end of file diff --git a/package.json b/package.json index e5dc52ef816..2c6eafec7ce 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "@blocknote/react": "^0.10.1", "@chakra-ui/accordion": "^2.3.0", "@chakra-ui/system": "^2.6.0", - "@codesandbox/sandpack-react": "^2.9.0", + "@codesandbox/sandpack-react": "^2.11.3", "@docusaurus/core": "^3.0.0", "@docusaurus/preset-classic": "^3.0.0", "@emotion/react": "^11.11.1", @@ -126,7 +126,7 @@ "react-error-boundary": "^4.0.11", "react-helmet-async": "^1.3.0", "react-hook-form": "^7.45.1", - "react-hotkeys-hook": "^4.4.3", + "react-hotkeys-hook": "^4.4.4", "react-icons": "^4.12.0", "react-intersection-observer": "^9.5.2", "react-loading-skeleton": "^3.3.1", diff --git a/packages/twenty-docs/package.json b/packages/twenty-docs/package.json index c3b25dcea88..243a8c30141 100644 --- a/packages/twenty-docs/package.json +++ b/packages/twenty-docs/package.json @@ -1,6 +1,6 @@ { "name": "twenty-docs", - "version": "0.1.0", + "version": "0.3.0", "private": true, "scripts": { "nx": "NX_DEFAULT_PROJECT=twenty-docs node ../../node_modules/nx/bin/nx.js", diff --git a/packages/twenty-emails/package.json b/packages/twenty-emails/package.json index 49fbeb059f9..3b2016d4641 100644 --- a/packages/twenty-emails/package.json +++ b/packages/twenty-emails/package.json @@ -1,6 +1,6 @@ { "name": "twenty-emails", - "version": "0.1.0", + "version": "0.3.0", "description": "", "author": "", "private": true, diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index 8414b781767..c9309f61580 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -1,6 +1,6 @@ { "name": "twenty-front", - "version": "0.2.3", + "version": "0.3.0", "private": true, "type": "module", "scripts": { @@ -60,4 +60,4 @@ "msw": { "workerDirectory": "public" } -} +} \ No newline at end of file diff --git a/packages/twenty-front/public/images/placeholders/background/empty_inbox_bg.png b/packages/twenty-front/public/images/placeholders/background/empty_inbox_bg.png new file mode 100644 index 00000000000..7668b8e8fd7 Binary files /dev/null and b/packages/twenty-front/public/images/placeholders/background/empty_inbox_bg.png differ diff --git a/packages/twenty-front/public/images/placeholders/background/loading_messages_bg.png b/packages/twenty-front/public/images/placeholders/background/loading_messages_bg.png new file mode 100644 index 00000000000..60f7e8dd4c1 Binary files /dev/null and b/packages/twenty-front/public/images/placeholders/background/loading_messages_bg.png differ diff --git a/packages/twenty-front/public/images/placeholders/moving-image/empty_inbox.png b/packages/twenty-front/public/images/placeholders/moving-image/empty_inbox.png new file mode 100644 index 00000000000..7c9fb25765d Binary files /dev/null and b/packages/twenty-front/public/images/placeholders/moving-image/empty_inbox.png differ diff --git a/packages/twenty-front/public/images/placeholders/moving-image/loading_messages.png b/packages/twenty-front/public/images/placeholders/moving-image/loading_messages.png new file mode 100644 index 00000000000..674d2810b53 Binary files /dev/null and b/packages/twenty-front/public/images/placeholders/moving-image/loading_messages.png differ diff --git a/packages/twenty-front/src/modules/activities/comment/CommentHeader.tsx b/packages/twenty-front/src/modules/activities/comment/CommentHeader.tsx index a8cbb6b19b5..390851d5276 100644 --- a/packages/twenty-front/src/modules/activities/comment/CommentHeader.tsx +++ b/packages/twenty-front/src/modules/activities/comment/CommentHeader.tsx @@ -77,7 +77,7 @@ export const CommentHeader = ({ comment, actionBar }: CommentHeaderProps) => { {authorName} diff --git a/packages/twenty-front/src/modules/activities/components/ActivityCreateButton.tsx b/packages/twenty-front/src/modules/activities/components/ActivityCreateButton.tsx deleted file mode 100644 index 8dd8dfcc1fe..00000000000 --- a/packages/twenty-front/src/modules/activities/components/ActivityCreateButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { - IconCheckbox, - IconNotes, - IconTimelineEvent, -} from '@/ui/display/icon/index'; -import { Button } from '@/ui/input/button/components/Button'; -import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup'; - -type ActivityCreateButtonProps = { - onNoteClick?: () => void; - onTaskClick?: () => void; - onActivityClick?: () => void; -}; - -export const ActivityCreateButton = ({ - onNoteClick, - onTaskClick, - onActivityClick, -}: ActivityCreateButtonProps) => ( - - - - - -); diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailLoader.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailLoader.tsx new file mode 100644 index 00000000000..cfaaab37b5a --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailLoader.tsx @@ -0,0 +1,17 @@ +import { Loader } from '@/ui/feedback/loader/components/Loader'; +import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; +import { + StyledEmptyContainer, + StyledEmptyTextContainer, + StyledEmptyTitle, +} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; + +export const EmailLoader = ({ loadingText }: { loadingText?: string }) => ( + + + + {loadingText || 'Loading emails'} + + + +); diff --git a/packages/twenty-front/src/modules/ui/utilities/loading-state/components/FetchMoreLoader.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadFetchMoreLoader.tsx similarity index 84% rename from packages/twenty-front/src/modules/ui/utilities/loading-state/components/FetchMoreLoader.tsx rename to packages/twenty-front/src/modules/activities/emails/components/EmailThreadFetchMoreLoader.tsx index fb45a23711f..94f0b39029f 100644 --- a/packages/twenty-front/src/modules/ui/utilities/loading-state/components/FetchMoreLoader.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadFetchMoreLoader.tsx @@ -3,7 +3,7 @@ import styled from '@emotion/styled'; import { grayScale } from '@/ui/theme/constants/colors'; -type FetchMoreLoaderProps = { +type EmailThreadFetchMoreLoaderProps = { loading: boolean; onLastRowVisible: (...args: any[]) => any; }; @@ -18,10 +18,10 @@ const StyledText = styled.div` padding-left: ${({ theme }) => theme.spacing(2)}; `; -export const FetchMoreLoader = ({ +export const EmailThreadFetchMoreLoader = ({ loading, onLastRowVisible, -}: FetchMoreLoaderProps) => { +}: EmailThreadFetchMoreLoaderProps) => { const { ref: tbodyRef } = useInView({ onChange: onLastRowVisible, }); diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx index 1e1e7471d88..e5d53542e6e 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx @@ -27,6 +27,7 @@ const StyledHeading = styled.h2` margin: 0; margin-bottom: ${({ theme }) => theme.spacing(3)}; max-width: 100%; + color: ${({ theme }) => theme.font.color.primary}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessage.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessage.tsx index 20c936e4ae7..96bf185f3ae 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessage.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessage.tsx @@ -11,7 +11,7 @@ const StyledThreadMessage = styled.div` border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; display: flex; flex-direction: column; - padding: ${({ theme }) => theme.spacing(4, 6)}; + padding: ${({ theme }) => theme.spacing(4, 0)}; `; const StyledThreadMessageHeader = styled.div` @@ -20,6 +20,11 @@ const StyledThreadMessageHeader = styled.div` flex-direction: column; justify-content: space-between; margin-bottom: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(0, 6)}; +`; + +const StyledThreadMessageBody = styled.div` + padding: ${({ theme }) => theme.spacing(0, 6)}; `; type EmailThreadMessageProps = { @@ -45,16 +50,21 @@ export const EmailThreadMessage = ({ } return ( - - setIsOpen(!isOpen)}> + !isOpen && setIsOpen(true)} + style={{ cursor: isOpen ? 'auto' : 'pointer' }} + > + isOpen && setIsOpen(false)}> {isOpen && } - {isOpen ? ( - - ) : ( - - )} + + {isOpen ? ( + + ) : ( + + )} + ); }; diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBody.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBody.tsx index cb48f07e98a..128197c6093 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBody.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBody.tsx @@ -1,20 +1,30 @@ import React from 'react'; import styled from '@emotion/styled'; +import { motion } from 'framer-motion'; -const StyledThreadMessageBody = styled.div` +import { AnimatedEaseInOut } from '@/ui/utilities/animation/components/AnimatedEaseInOut'; + +const StyledThreadMessageBody = styled(motion.div)` color: ${({ theme }) => theme.font.color.primary}; display: flex; flex-direction: column; margin-top: ${({ theme }) => theme.spacing(4)}; white-space: pre-line; + overflow-wrap: break-word; `; type EmailThreadMessageBodyProps = { body: string; + isDisplayed: boolean; }; export const EmailThreadMessageBody = ({ body, + isDisplayed, }: EmailThreadMessageBodyProps) => { - return {body}; + return ( + + {body} + + ); }; diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBodyPreview.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBodyPreview.tsx index 80ec0742f2b..40d9d4353fd 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBodyPreview.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBodyPreview.tsx @@ -1,9 +1,8 @@ import React from 'react'; import styled from '@emotion/styled'; -const StyledThreadMessageBodyPreview = styled.span` +const StyledThreadMessageBodyPreview = styled.div` color: ${({ theme }) => theme.font.color.tertiary}; - min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx index cb54fc90cbc..ae2552c203b 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx @@ -23,7 +23,9 @@ const StyledAvatar = styled(Avatar)` `; const StyledSenderName = styled.span` + color: ${({ theme }) => theme.font.color.primary}; font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; overflow: hidden; text-overflow: ellipsis; `; @@ -35,6 +37,10 @@ const StyledThreadMessageSentAt = styled.div` font-size: ${({ theme }) => theme.font.size.sm}; `; +const StyledRecordChip = styled(RecordChip)` + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + type EmailThreadMessageSenderProps = { sender: EmailThreadMessageParticipant; sentAt: string; @@ -57,7 +63,7 @@ export const EmailThreadMessageSender = ({ {person ? ( - diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx index 431c6ef0a03..34a05a2285c 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import { CardContent } from '@/ui/layout/card/components/CardContent'; +import { grayScale } from '@/ui/theme/constants/colors'; import { Avatar } from '@/users/components/Avatar'; import { TimelineThread } from '~/generated/graphql'; import { formatToHumanReadableDate } from '~/utils'; @@ -15,29 +16,13 @@ const StyledCardContent = styled(CardContent)` `; const StyledHeading = styled.div<{ unread: boolean }>` - align-items: center; - color: ${({ theme, unread }) => - unread ? theme.font.color.primary : theme.font.color.secondary}; display: flex; - font-weight: ${({ theme, unread }) => - unread ? theme.font.weight.medium : theme.font.weight.regular}; - gap: ${({ theme }) => theme.spacing(1)}; overflow: hidden; - width: 160px; - - :before { - background-color: ${({ theme, unread }) => - unread ? theme.color.blue : 'transparent'}; - border-radius: ${({ theme }) => theme.border.radius.rounded}; - content: ''; - display: block; - height: 6px; - width: 6px; - } + width: 20%; `; const StyledParticipantsContainer = styled.div` - align-items: center; + align-items: flex-start; display: flex; `; @@ -46,6 +31,7 @@ const StyledAvatar = styled(Avatar)` `; const StyledSenderNames = styled.span` + display: flex; margin: ${({ theme }) => theme.spacing(0, 1)}; overflow: hidden; text-overflow: ellipsis; @@ -56,26 +42,26 @@ const StyledThreadCount = styled.span` color: ${({ theme }) => theme.font.color.tertiary}; `; -const StyledSubject = styled.span<{ unread: boolean }>` - color: ${({ theme, unread }) => - unread ? theme.font.color.primary : theme.font.color.secondary}; - white-space: nowrap; -`; - -const StyledBody = styled.span` - color: ${({ theme }) => theme.font.color.tertiary}; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - const StyledSubjectAndBody = styled.div` display: flex; flex: 1; gap: ${({ theme }) => theme.spacing(2)}; overflow: hidden; +`; + +const StyledSubject = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; + flex-shrink: 0; +`; + +const StyledBody = styled.span` + color: ${({ theme }) => theme.font.color.tertiary}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; const StyledReceivedAt = styled.div` @@ -104,12 +90,13 @@ export const EmailThreadPreview = ({ ? `, ${thread.lastTwoParticipants?.[1]?.displayName}` : ''); - const [finalDisplayedName, finalAvatarUrl] = + const [finalDisplayedName, finalAvatarUrl, isCountIcon] = thread.participantCount > 3 - ? [`${thread.participantCount}`, ''] + ? [`${thread.participantCount}`, '', true] : [ thread?.lastTwoParticipants?.[1]?.displayName, thread?.lastTwoParticipants?.[1]?.avatarUrl, + false, ]; return ( @@ -133,6 +120,8 @@ export const EmailThreadPreview = ({ avatarUrl={finalAvatarUrl} placeholder={finalDisplayedName} type="rounded" + color={isCountIcon ? grayScale.gray50 : undefined} + backgroundColor={isCountIcon ? grayScale.gray10 : undefined} /> )} @@ -142,7 +131,7 @@ export const EmailThreadPreview = ({ - {thread.subject} + {thread.subject} {thread.lastMessageBody} diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx index e17909f75a6..acde0367673 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx @@ -3,15 +3,14 @@ import { useQuery } from '@apollo/client'; import styled from '@emotion/styled'; import { useRecoilState } from 'recoil'; +import { EmailLoader } from '@/activities/emails/components/EmailLoader'; +import { EmailThreadFetchMoreLoader } from '@/activities/emails/components/EmailThreadFetchMoreLoader'; import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview'; import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/messaging.constants'; +import { useEmailThreadStates } from '@/activities/emails/hooks/internal/useEmailThreadStates'; import { useEmailThread } from '@/activities/emails/hooks/useEmailThread'; import { getTimelineThreadsFromCompanyId } from '@/activities/emails/queries/getTimelineThreadsFromCompanyId'; import { getTimelineThreadsFromPersonId } from '@/activities/emails/queries/getTimelineThreadsFromPersonId'; -import { - emailThreadsPageState, - EmailThreadsPageType, -} from '@/activities/emails/state/emailThreadsPageState'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { @@ -19,9 +18,16 @@ import { H1TitleFontColor, } from '@/ui/display/typography/components/H1Title'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; +import { + StyledEmptyContainer, + StyledEmptySubTitle, + StyledEmptyTextContainer, + StyledEmptyTitle, +} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; import { Card } from '@/ui/layout/card/components/Card'; import { Section } from '@/ui/layout/section/components/Section'; -import { FetchMoreLoader } from '@/ui/utilities/loading-state/components/FetchMoreLoader'; +import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; import { GetTimelineThreadsFromPersonIdQueryVariables, TimelineThread, @@ -54,8 +60,13 @@ export const EmailThreads = ({ const { openEmailThread } = useEmailThread(); const { enqueueSnackBar } = useSnackBar(); - const [emailThreadsPage, setEmailThreadsPage] = - useRecoilState(emailThreadsPageState); + const { getEmailThreadsPageState } = useEmailThreadStates({ + emailThreadScopeId: getScopeIdFromComponentId(entity.id), + }); + + const [emailThreadsPage, setEmailThreadsPage] = useRecoilState( + getEmailThreadsPageState(), + ); const [isFetchingMoreEmails, setIsFetchingMoreEmails] = useState(false); @@ -72,12 +83,21 @@ export const EmailThreads = ({ pageSize: TIMELINE_THREADS_DEFAULT_PAGE_SIZE, } as GetTimelineThreadsFromPersonIdQueryVariables; - const { data, loading, fetchMore, error } = useQuery(threadQuery, { + const { + data, + loading: firstQueryLoading, + fetchMore, + error, + } = useQuery(threadQuery, { variables: threadQueryVariables, }); const fetchMoreRecords = async () => { - if (emailThreadsPage.hasNextPage && !isFetchingMoreEmails) { + if ( + emailThreadsPage.hasNextPage && + !isFetchingMoreEmails && + !firstQueryLoading + ) { setIsFetchingMoreEmails(true); await fetchMore({ @@ -91,7 +111,14 @@ export const EmailThreads = ({ ...emailThreadsPage, hasNextPage: false, })); - return prev; + return { + [queryName]: { + ...prev?.[queryName], + timelineThreads: [ + ...(prev?.[queryName]?.timelineThreads ?? []), + ], + }, + }; } return { @@ -122,32 +149,49 @@ export const EmailThreads = ({ const { totalNumberOfThreads, timelineThreads }: TimelineThreadsWithTotal = data?.[queryName] ?? []; + if (firstQueryLoading) { + return ; + } + + if (!firstQueryLoading && !timelineThreads?.length) { + return ( + + + + Empty Inbox + + No email exchange has occurred with this record yet. + + + + ); + } + return ( - Inbox{' '} - {totalNumberOfThreads ?? 0} + Inbox {totalNumberOfThreads} > } fontColor={H1TitleFontColor.Primary} /> - {!loading && ( + {!firstQueryLoading && ( {timelineThreads?.map((thread: TimelineThread, index: number) => ( openEmailThread(thread)} + onClick={() => openEmailThread(thread.id)} /> ))} )} - diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/internal/useEmailThreadStates.ts b/packages/twenty-front/src/modules/activities/emails/hooks/internal/useEmailThreadStates.ts new file mode 100644 index 00000000000..dc40a314dfb --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/hooks/internal/useEmailThreadStates.ts @@ -0,0 +1,22 @@ +import { emailThreadsPageStateScopeMap } from '@/activities/emails/state/emailThreadsPageStateScopeMap'; +import { TabListScopeInternalContext } from '@/ui/layout/tab/scopes/scope-internal-context/TabListScopeInternalContext'; +import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; +import { getState } from '@/ui/utilities/recoil-scope/utils/getState'; + +type useEmailThreadStatesProps = { + emailThreadScopeId?: string; +}; + +export const useEmailThreadStates = ({ + emailThreadScopeId, +}: useEmailThreadStatesProps) => { + const scopeId = useAvailableScopeIdOrThrow( + TabListScopeInternalContext, + emailThreadScopeId, + ); + + return { + scopeId, + getEmailThreadsPageState: getState(emailThreadsPageStateScopeMap, scopeId), + }; +}; diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/useEmailThread.ts b/packages/twenty-front/src/modules/activities/emails/hooks/useEmailThread.ts index af5631a16da..a0e9bbc857c 100644 --- a/packages/twenty-front/src/modules/activities/emails/hooks/useEmailThread.ts +++ b/packages/twenty-front/src/modules/activities/emails/hooks/useEmailThread.ts @@ -1,18 +1,19 @@ import { useRecoilState } from 'recoil'; import { useOpenEmailThreadRightDrawer } from '@/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer'; -import { viewableEmailThreadState } from '@/activities/emails/state/viewableEmailThreadState'; -import { TimelineThread } from '~/generated/graphql'; +import { viewableEmailThreadIdState } from '@/activities/emails/state/viewableEmailThreadIdState'; export const useEmailThread = () => { - const [, setViewableEmailThread] = useRecoilState(viewableEmailThreadState); + const [, setViewableEmailThreadId] = useRecoilState( + viewableEmailThreadIdState, + ); const openEmailThredRightDrawer = useOpenEmailThreadRightDrawer(); - const openEmailThread = (thread: TimelineThread) => { + const openEmailThread = (threadId: string) => { openEmailThredRightDrawer(); - setViewableEmailThread(thread); + setViewableEmailThreadId(threadId); }; return { diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx index 13f8d8f2fd5..a59741d15de 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx @@ -1,14 +1,11 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; +import { EmailLoader } from '@/activities/emails/components/EmailLoader'; +import { EmailThreadFetchMoreLoader } from '@/activities/emails/components/EmailThreadFetchMoreLoader'; import { EmailThreadHeader } from '@/activities/emails/components/EmailThreadHeader'; import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage'; -import { viewableEmailThreadState } from '@/activities/emails/state/viewableEmailThreadState'; -import { EmailThreadMessage as EmailThreadMessageType } from '@/activities/emails/types/EmailThreadMessage'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { FetchMoreLoader } from '@/ui/utilities/loading-state/components/FetchMoreLoader'; +import { useRightDrawerEmailThread } from '@/activities/emails/right-drawer/hooks/useRightDrawerEmailThread'; const StyledContainer = styled.div` box-sizing: border-box; @@ -21,53 +18,37 @@ const StyledContainer = styled.div` `; export const RightDrawerEmailThread = () => { - const viewableEmailThread = useRecoilValue(viewableEmailThreadState); + const { thread, messages, fetchMoreMessages, loading } = + useRightDrawerEmailThread(); - const { - records: messages, - loading, - fetchMoreRecords, - } = useFindManyRecords({ - depth: 3, - limit: 10, - filter: { - messageThreadId: { - eq: viewableEmailThread?.id, - }, - }, - objectNameSingular: CoreObjectNameSingular.Message, - orderBy: { - receivedAt: 'DescNullsLast', - }, - skip: !viewableEmailThread, - useRecordsWithoutConnection: true, - }); - - const fetchMoreMessages = useCallback(() => { - if (!loading) { - fetchMoreRecords(); - } - }, [fetchMoreRecords, loading]); - - if (!viewableEmailThread) { + if (!thread) { return null; } return ( - {messages.map((message) => ( - - ))} - + {loading ? ( + + ) : ( + <> + {messages.map((message) => ( + + ))} + + > + )} ); }; diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts new file mode 100644 index 00000000000..1dbce6e0064 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts @@ -0,0 +1,58 @@ +import { useCallback } from 'react'; +import { useApolloClient } from '@apollo/client'; +import gql from 'graphql-tag'; +import { useRecoilValue } from 'recoil'; + +import { viewableEmailThreadIdState } from '@/activities/emails/state/viewableEmailThreadIdState'; +import { EmailThreadMessage as EmailThreadMessageType } from '@/activities/emails/types/EmailThreadMessage'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; + +export const useRightDrawerEmailThread = () => { + const viewableEmailThreadId = useRecoilValue(viewableEmailThreadIdState); + + const apolloClient = useApolloClient(); + const thread = apolloClient.readFragment({ + id: `TimelineThread:${viewableEmailThreadId}`, + fragment: gql` + fragment timelineThread on TimelineThread { + id + subject + lastMessageReceivedAt + } + `, + }); + + const { + records: messages, + loading, + fetchMoreRecords, + } = useFindManyRecords({ + depth: 3, + limit: 10, + filter: { + messageThreadId: { + eq: viewableEmailThreadId || '', + }, + }, + objectNameSingular: CoreObjectNameSingular.Message, + orderBy: { + receivedAt: 'AscNullsLast', + }, + skip: !viewableEmailThreadId, + useRecordsWithoutConnection: true, + }); + + const fetchMoreMessages = useCallback(() => { + if (!loading) { + fetchMoreRecords(); + } + }, [fetchMoreRecords, loading]); + + return { + thread, + messages, + loading, + fetchMoreMessages, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageState.ts b/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageState.ts deleted file mode 100644 index 641cf92bb83..00000000000 --- a/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageState.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { atom } from 'recoil'; - -export type EmailThreadsPageType = { - pageNumber: number; - hasNextPage: boolean; -}; - -export const emailThreadsPageState = atom({ - key: 'EmailThreadsPageState', - default: { pageNumber: 1, hasNextPage: true }, -}); diff --git a/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageStateScopeMap.ts b/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageStateScopeMap.ts new file mode 100644 index 00000000000..f7761acc793 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageStateScopeMap.ts @@ -0,0 +1,12 @@ +import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; + +export type EmailThreadsPageType = { + pageNumber: number; + hasNextPage: boolean; +}; + +export const emailThreadsPageStateScopeMap = + createStateScopeMap({ + key: 'emailThreadsPageStateScopeMap', + defaultValue: { pageNumber: 1, hasNextPage: true }, + }); diff --git a/packages/twenty-front/src/modules/activities/emails/state/viewableEmailThreadIdState.ts b/packages/twenty-front/src/modules/activities/emails/state/viewableEmailThreadIdState.ts new file mode 100644 index 00000000000..73b4a3b89a4 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/state/viewableEmailThreadIdState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const viewableEmailThreadIdState = atom({ + key: 'viewableEmailThreadIdState', + default: null, +}); diff --git a/packages/twenty-front/src/modules/activities/emails/state/viewableEmailThreadState.ts b/packages/twenty-front/src/modules/activities/emails/state/viewableEmailThreadState.ts deleted file mode 100644 index d514958f549..00000000000 --- a/packages/twenty-front/src/modules/activities/emails/state/viewableEmailThreadState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { atom } from 'recoil'; - -import { TimelineThread } from '~/generated/graphql'; - -export const viewableEmailThreadState = atom({ - key: 'viewableEmailThreadState', - default: null, -}); diff --git a/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx b/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx index 17bb16fa575..e2128bfc3ce 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx @@ -63,7 +63,7 @@ export const TaskGroups = ({ No Task - There are no associated tasks with this record + There are no tasks for this user filter - - openCreateActivity({ - type: 'Note', - targetableObjects: [targetableObject], - }) - } - onTaskClick={() => - openCreateActivity({ - type: 'Task', - targetableObjects: [targetableObject], - }) - } - /> + ); } diff --git a/packages/twenty-front/src/modules/activities/timeline/components/TimelineCreateButtonGroup.tsx b/packages/twenty-front/src/modules/activities/timeline/components/TimelineCreateButtonGroup.tsx new file mode 100644 index 00000000000..b8542cd19fe --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timeline/components/TimelineCreateButtonGroup.tsx @@ -0,0 +1,53 @@ +import { useSetRecoilState } from 'recoil'; +import { Button, ButtonGroup } from 'tsup.ui.index'; + +import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { + IconCheckbox, + IconNotes, + IconPaperclip, +} from '@/ui/display/icon/index'; +import { TAB_LIST_COMPONENT_ID } from '@/ui/layout/show-page/components/ShowPageRightContainer'; +import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; + +export const TimelineCreateButtonGroup = ({ + targetableObject, +}: { + targetableObject: ActivityTargetableObject; +}) => { + const { getActiveTabIdState } = useTabList(TAB_LIST_COMPONENT_ID); + const setActiveTabId = useSetRecoilState(getActiveTabIdState()); + + const openCreateActivity = useOpenCreateActivityDrawer(); + + return ( + + + openCreateActivity({ + type: 'Note', + targetableObjects: [targetableObject], + }) + } + /> + + openCreateActivity({ + type: 'Task', + targetableObjects: [targetableObject], + }) + } + /> + setActiveTabId('files')} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate.ts deleted file mode 100644 index 4e5c6245e16..00000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useRecoilCallback } from 'recoil'; - -import { TriggerUpdateRelationFieldOptimisticEffectParams } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect'; -import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; - -export const useGetRelationFieldsToOptimisticallyUpdate = () => - useRecoilCallback( - ({ snapshot }) => - ({ - cachedRecord, - objectMetadataItem, - updateRecordInput, - }: { - cachedRecord: UpdatedObjectRecord & { __typename: string }; - objectMetadataItem: ObjectMetadataItem; - updateRecordInput: Partial>; - }) => - Object.entries(updateRecordInput).reduce< - Pick< - TriggerUpdateRelationFieldOptimisticEffectParams, - | 'relationObjectMetadataNameSingular' - | 'relationFieldName' - | 'previousRelationRecord' - | 'nextRelationRecord' - >[] - >((result, [fieldName, nextRelationRecord]) => { - const fieldDefinition = objectMetadataItem.fields.find( - (fieldMetadataItem) => fieldMetadataItem.name === fieldName, - ); - - if (fieldDefinition?.type !== FieldMetadataType.Relation) - return result; - - const relationObjectMetadataNameSingular = ( - fieldDefinition.toRelationMetadata?.fromObjectMetadata || - fieldDefinition.fromRelationMetadata?.toObjectMetadata - )?.nameSingular; - const relationFieldMetadataId = - fieldDefinition.toRelationMetadata?.fromFieldMetadataId || - fieldDefinition.fromRelationMetadata?.toFieldMetadataId; - - if (!relationObjectMetadataNameSingular || !relationFieldMetadataId) - return result; - - const relationObjectMetadataItem = snapshot - .getLoadable( - objectMetadataItemFamilySelector({ - objectName: relationObjectMetadataNameSingular, - objectNameType: 'singular', - }), - ) - .valueOrThrow(); - - if (!relationObjectMetadataItem) return result; - - const relationFieldName = relationObjectMetadataItem.fields.find( - (fieldMetadataItem) => - fieldMetadataItem.id === relationFieldMetadataId, - )?.name; - - if (!relationFieldName) return result; - - return [ - ...result, - { - relationObjectMetadataNameSingular, - relationFieldName, - previousRelationRecord: cachedRecord[fieldName], - nextRelationRecord, - }, - ]; - }, []), - ); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectConnection.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectRecordConnection.ts similarity index 66% rename from packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectConnection.ts rename to packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectRecordConnection.ts index e77b1411983..92186f3df31 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectConnection.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectRecordConnection.ts @@ -4,15 +4,24 @@ import { z } from 'zod'; import { CachedObjectRecordConnection } from '@/apollo/types/CachedObjectRecordConnection'; import { capitalize } from '~/utils/string/capitalize'; -export const isCachedObjectConnection = ( +export const isCachedObjectRecordConnection = ( objectNameSingular: string, storeValue: StoreValue, ): storeValue is CachedObjectRecordConnection => { const objectConnectionTypeName = `${capitalize( objectNameSingular, )}Connection`; + const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`; const cachedObjectConnectionSchema = z.object({ __typename: z.literal(objectConnectionTypeName), + edges: z.array( + z.object({ + __typename: z.literal(objectEdgeTypeName), + node: z.object({ + __ref: z.string().startsWith(`${capitalize(objectNameSingular)}:`), + }), + }), + ), }); const cachedConnectionValidation = cachedObjectConnectionSchema.safeParse(storeValue); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts new file mode 100644 index 00000000000..7017d15c0f9 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; +import { capitalize } from '~/utils/string/capitalize'; + +export const isObjectRecordConnection = ( + objectNameSingular: string, + value: unknown, +): value is ObjectRecordConnection => { + const objectConnectionTypeName = `${capitalize( + objectNameSingular, + )}Connection`; + const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`; + const objectConnectionSchema = z.object({ + __typename: z.literal(objectConnectionTypeName), + edges: z.array( + z.object({ + __typename: z.literal(objectEdgeTypeName), + node: z.object({ + id: z.string().uuid(), + }), + }), + ), + }); + const connectionValidation = objectConnectionSchema.safeParse(value); + + return connectionValidation.success; +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts new file mode 100644 index 00000000000..68ac70a3455 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts @@ -0,0 +1,59 @@ +import { ApolloCache, StoreObject } from '@apollo/client'; + +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; +import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; +import { capitalize } from '~/utils/string/capitalize'; + +export const triggerAttachRelationOptimisticEffect = ({ + cache, + objectNameSingular, + recordId, + relationObjectMetadataNameSingular, + relationFieldName, + relationRecordId, +}: { + cache: ApolloCache; + objectNameSingular: string; + recordId: string; + relationObjectMetadataNameSingular: string; + relationFieldName: string; + relationRecordId: string; +}) => { + const recordTypeName = capitalize(objectNameSingular); + const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular); + + cache.modify({ + id: cache.identify({ + id: relationRecordId, + __typename: relationRecordTypeName, + }), + fields: { + [relationFieldName]: (cachedFieldValue, { toReference }) => { + const nodeReference = toReference({ + id: recordId, + __typename: recordTypeName, + }); + + if (!nodeReference) return cachedFieldValue; + + if ( + isCachedObjectRecordConnection(objectNameSingular, cachedFieldValue) + ) { + // To many objects => add record to next relation field list + const nextEdges: CachedObjectRecordEdge[] = [ + ...cachedFieldValue.edges, + { + __typename: `${recordTypeName}Edge`, + node: nodeReference, + cursor: '', + }, + ]; + return { ...cachedFieldValue, edges: nextEdges }; + } + + // To one object => attach next relation record + return nodeReference; + }, + }, + }); +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts index 4ca7e2f92b5..7fb85b0b19a 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts @@ -1,8 +1,10 @@ import { ApolloCache, StoreObject } from '@apollo/client'; -import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; +import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect'; import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { capitalize } from '~/utils/string/capitalize'; @@ -15,15 +17,27 @@ export const triggerCreateRecordsOptimisticEffect = ({ cache, objectMetadataItem, records, + getRelationMetadata, }: { cache: ApolloCache; objectMetadataItem: ObjectMetadataItem; records: CachedObjectRecord[]; + getRelationMetadata: ReturnType; }) => { const objectEdgeTypeName = `${capitalize( objectMetadataItem.nameSingular, )}Edge`; + records.forEach((record) => + triggerUpdateRelationsOptimisticEffect({ + cache, + objectMetadataItem, + previousRecord: null, + nextRecord: record, + getRelationMetadata, + }), + ); + cache.modify({ fields: { [objectMetadataItem.namePlural]: ( @@ -36,7 +50,7 @@ export const triggerCreateRecordsOptimisticEffect = ({ }, ) => { if ( - !isCachedObjectConnection( + !isCachedObjectRecordConnection( objectMetadataItem.nameSingular, cachedConnection, ) diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts index 51e6c3b9b3b..0ee50204632 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts @@ -1,9 +1,11 @@ import { ApolloCache, StoreObject } from '@apollo/client'; -import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; +import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect'; import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { isDefined } from '~/utils/isDefined'; import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName'; @@ -12,10 +14,12 @@ export const triggerDeleteRecordsOptimisticEffect = ({ cache, objectMetadataItem, records, + getRelationMetadata, }: { cache: ApolloCache; objectMetadataItem: ObjectMetadataItem; - records: Pick[]; + records: CachedObjectRecord[]; + getRelationMetadata: ReturnType; }) => { cache.modify({ fields: { @@ -24,7 +28,7 @@ export const triggerDeleteRecordsOptimisticEffect = ({ { DELETE, readField, storeFieldName }, ) => { if ( - !isCachedObjectConnection( + !isCachedObjectRecordConnection( objectMetadataItem.nameSingular, cachedConnection, ) @@ -64,5 +68,15 @@ export const triggerDeleteRecordsOptimisticEffect = ({ }, }); - records.forEach((record) => cache.evict({ id: cache.identify(record) })); + records.forEach((record) => { + triggerUpdateRelationsOptimisticEffect({ + cache, + objectMetadataItem, + previousRecord: record, + nextRecord: null, + getRelationMetadata, + }); + + cache.evict({ id: cache.identify(record) }); + }); }; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts new file mode 100644 index 00000000000..f68265920f5 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts @@ -0,0 +1,49 @@ +import { ApolloCache, StoreObject } from '@apollo/client'; + +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; +import { capitalize } from '~/utils/string/capitalize'; + +export const triggerDetachRelationOptimisticEffect = ({ + cache, + objectNameSingular, + recordId, + relationObjectMetadataNameSingular, + relationFieldName, + relationRecordId, +}: { + cache: ApolloCache; + objectNameSingular: string; + recordId: string; + relationObjectMetadataNameSingular: string; + relationFieldName: string; + relationRecordId: string; +}) => { + const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular); + + cache.modify({ + id: cache.identify({ + id: relationRecordId, + __typename: relationRecordTypeName, + }), + fields: { + [relationFieldName]: (cachedFieldValue, { isReference, readField }) => { + // To many objects => remove record from previous relation field list + if ( + isCachedObjectRecordConnection(objectNameSingular, cachedFieldValue) + ) { + const nextEdges = cachedFieldValue.edges.filter( + ({ node }) => readField('id', node) !== recordId, + ); + return { ...cachedFieldValue, edges: nextEdges }; + } + + // To one object => detach previous relation record + if (isReference(cachedFieldValue)) { + return null; + } + + return cachedFieldValue; + }, + }, + }); +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts index 4da9b2839e3..b83b4edbd56 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts @@ -1,10 +1,12 @@ import { ApolloCache, StoreObject } from '@apollo/client'; -import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges'; +import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect'; import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter'; import { isDefined } from '~/utils/isDefined'; @@ -14,16 +16,29 @@ import { capitalize } from '~/utils/string/capitalize'; export const triggerUpdateRecordOptimisticEffect = ({ cache, objectMetadataItem, - record, + previousRecord, + nextRecord, + getRelationMetadata, }: { cache: ApolloCache; objectMetadataItem: ObjectMetadataItem; - record: CachedObjectRecord; + previousRecord: CachedObjectRecord; + nextRecord: CachedObjectRecord; + getRelationMetadata: ReturnType; }) => { const objectEdgeTypeName = `${capitalize( objectMetadataItem.nameSingular, )}Edge`; + triggerUpdateRelationsOptimisticEffect({ + cache, + objectMetadataItem, + previousRecord, + nextRecord, + getRelationMetadata, + }); + + // Optimistically update record lists cache.modify({ fields: { [objectMetadataItem.namePlural]: ( @@ -31,7 +46,7 @@ export const triggerUpdateRecordOptimisticEffect = ({ { DELETE, readField, storeFieldName, toReference }, ) => { if ( - !isCachedObjectConnection( + !isCachedObjectRecordConnection( objectMetadataItem.nameSingular, cachedConnection, ) @@ -49,18 +64,20 @@ export const triggerUpdateRecordOptimisticEffect = ({ ); let nextCachedEdges = cachedEdges ? [...cachedEdges] : []; + // Test if the record matches this list's filters if (variables?.filter) { const matchesFilter = isRecordMatchingFilter({ - record, + record: nextRecord, filter: variables.filter, objectMetadataItem, }); const recordIndex = nextCachedEdges.findIndex( - (cachedEdge) => readField('id', cachedEdge.node) === record.id, + (cachedEdge) => readField('id', cachedEdge.node) === nextRecord.id, ); + // If after update, the record matches this list's filters, then add it to the list if (matchesFilter && recordIndex === -1) { - const nodeReference = toReference(record); + const nodeReference = toReference(nextRecord); nodeReference && nextCachedEdges.push({ __typename: objectEdgeTypeName, @@ -69,11 +86,13 @@ export const triggerUpdateRecordOptimisticEffect = ({ }); } + // If after update, the record does not match this list's filters anymore, then remove it from the list if (!matchesFilter && recordIndex > -1) { nextCachedEdges.splice(recordIndex, 1); } } + // Sort updated list if (variables?.orderBy) { nextCachedEdges = sortCachedObjectEdges({ edges: nextCachedEdges, @@ -82,6 +101,7 @@ export const triggerUpdateRecordOptimisticEffect = ({ }); } + // Limit the updated list to the required size if (isDefined(variables?.first)) { // If previous edges length was exactly at the required limit, // but after update next edges length is under the limit, diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect.ts deleted file mode 100644 index 0f77085c973..00000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ApolloCache, StoreObject } from '@apollo/client'; - -import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; -import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { capitalize } from '~/utils/string/capitalize'; - -export type TriggerUpdateRelationFieldOptimisticEffectParams = { - cache: ApolloCache; - objectNameSingular: string; - record: ObjectRecord; - relationObjectMetadataNameSingular: string; - relationFieldName: string; - previousRelationRecord: ObjectRecord | null; - nextRelationRecord: ObjectRecord | null; -}; - -export const triggerUpdateRelationFieldOptimisticEffect = ({ - cache, - objectNameSingular, - record, - relationObjectMetadataNameSingular, - relationFieldName, - previousRelationRecord, - nextRelationRecord, -}: TriggerUpdateRelationFieldOptimisticEffectParams) => { - const recordTypeName = capitalize(objectNameSingular); - const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular); - - if (previousRelationRecord) { - cache.modify({ - id: cache.identify({ - ...previousRelationRecord, - __typename: relationRecordTypeName, - }), - fields: { - [relationFieldName]: (cachedFieldValue, { isReference, readField }) => { - // To many objects => remove record from previous relation field list - if (isCachedObjectConnection(objectNameSingular, cachedFieldValue)) { - const nextEdges = cachedFieldValue.edges.filter( - ({ node }) => readField('id', node) !== record.id, - ); - return { ...cachedFieldValue, edges: nextEdges }; - } - - // To one object => detach previous relation record - if (isReference(cachedFieldValue)) { - return null; - } - }, - }, - }); - } - - if (nextRelationRecord) { - cache.modify({ - id: cache.identify({ - ...nextRelationRecord, - __typename: relationRecordTypeName, - }), - fields: { - [relationFieldName]: (cachedFieldValue, { toReference }) => { - const nodeReference = toReference(record); - - if (!nodeReference) return cachedFieldValue; - - if (isCachedObjectConnection(objectNameSingular, cachedFieldValue)) { - // To many objects => add record to next relation field list - const nextEdges: CachedObjectRecordEdge[] = [ - ...cachedFieldValue.edges, - { - __typename: `${recordTypeName}Edge`, - node: nodeReference, - cursor: '', - }, - ]; - return { ...cachedFieldValue, edges: nextEdges }; - } - - // To one object => attach next relation record - return nodeReference; - }, - }, - }); - } -}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts new file mode 100644 index 00000000000..f21652a6539 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts @@ -0,0 +1,112 @@ +import { ApolloCache } from '@apollo/client'; + +import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection'; +import { triggerAttachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect'; +import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; +import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect'; +import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; +import { coreObjectNamesToDeleteOnRelationDetach } from '@/apollo/types/coreObjectNamesToDeleteOnRelationDetach'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; +import { isDefined } from '~/utils/isDefined'; + +export const triggerUpdateRelationsOptimisticEffect = ({ + cache, + objectMetadataItem, + previousRecord, + nextRecord, + getRelationMetadata, +}: { + cache: ApolloCache; + objectMetadataItem: ObjectMetadataItem; + previousRecord: CachedObjectRecord | null; + nextRecord: CachedObjectRecord | null; + getRelationMetadata: ReturnType; +}) => + // Optimistically update relation records + objectMetadataItem.fields.forEach((fieldMetadataItem) => { + if (nextRecord && !(fieldMetadataItem.name in nextRecord)) return; + + const relationMetadata = getRelationMetadata({ + fieldMetadataItem, + }); + + if (!relationMetadata) return; + + const { + // Object metadata for the related record + relationObjectMetadataItem, + // Field on the related record + relationFieldMetadataItem, + } = relationMetadata; + + const previousFieldValue: + | ObjectRecordConnection + | CachedObjectRecord + | null = previousRecord?.[fieldMetadataItem.name]; + const nextFieldValue: ObjectRecordConnection | CachedObjectRecord | null = + nextRecord?.[fieldMetadataItem.name]; + + if (isDeeplyEqual(previousFieldValue, nextFieldValue)) return; + + const isPreviousFieldValueRecordConnection = isObjectRecordConnection( + relationObjectMetadataItem.nameSingular, + previousFieldValue, + ); + const relationRecordsToDetach = isPreviousFieldValueRecordConnection + ? previousFieldValue.edges.map(({ node }) => node as CachedObjectRecord) + : [previousFieldValue].filter(isDefined); + + const isNextFieldValueRecordConnection = isObjectRecordConnection( + relationObjectMetadataItem.nameSingular, + nextFieldValue, + ); + const relationRecordsToAttach = isNextFieldValueRecordConnection + ? nextFieldValue.edges.map(({ node }) => node as CachedObjectRecord) + : [nextFieldValue].filter(isDefined); + + if (previousRecord && relationRecordsToDetach.length) { + const shouldDeleteRelationRecord = + coreObjectNamesToDeleteOnRelationDetach.includes( + relationObjectMetadataItem.nameSingular as CoreObjectNameSingular, + ); + + if (shouldDeleteRelationRecord) { + triggerDeleteRecordsOptimisticEffect({ + cache, + objectMetadataItem: relationObjectMetadataItem, + records: relationRecordsToDetach, + getRelationMetadata, + }); + } else { + relationRecordsToDetach.forEach((relationRecordToDetach) => { + triggerDetachRelationOptimisticEffect({ + cache, + objectNameSingular: objectMetadataItem.nameSingular, + recordId: previousRecord.id, + relationFieldName: relationFieldMetadataItem.name, + relationObjectMetadataNameSingular: + relationObjectMetadataItem.nameSingular, + relationRecordId: relationRecordToDetach.id, + }); + }); + } + } + + if (nextRecord && relationRecordsToAttach.length) { + relationRecordsToAttach.forEach((relationRecordToAttach) => + triggerAttachRelationOptimisticEffect({ + cache, + objectNameSingular: objectMetadataItem.nameSingular, + recordId: nextRecord.id, + relationFieldName: relationFieldMetadataItem.name, + relationObjectMetadataNameSingular: + relationObjectMetadataItem.nameSingular, + relationRecordId: relationRecordToAttach.id, + }), + ); + } + }); diff --git a/packages/twenty-front/src/modules/apollo/types/coreObjectNamesToDeleteOnRelationDetach.ts b/packages/twenty-front/src/modules/apollo/types/coreObjectNamesToDeleteOnRelationDetach.ts new file mode 100644 index 00000000000..5e40afc6ba6 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/types/coreObjectNamesToDeleteOnRelationDetach.ts @@ -0,0 +1,5 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; + +export const coreObjectNamesToDeleteOnRelationDetach = [ + CoreObjectNameSingular.Favorite, +]; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx index 1e3a4722437..430301c54ee 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef, useState } from 'react'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; +import { Key } from 'ts-key-enum'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; import { Activity } from '@/activities/types/Activity'; @@ -124,7 +125,7 @@ export const CommandMenu = () => { ); useScopedHotkeys( - 'esc', + [Key.Escape], () => { closeCommandMenu(); }, @@ -328,7 +329,7 @@ export const CommandMenu = () => { { to={`object/company/${company.id}`} Icon={() => ( { label={labelIdentifier} Icon={() => ( { isKeyboardShortcutMenuOpenedState, ); const { closeCommandMenu } = useCommandMenu(); + useScopedHotkeys( 'shift+?,meta+?', () => { @@ -33,12 +35,12 @@ export const KeyboardShortcutMenu = () => { ); useScopedHotkeys( - 'esc', + [Key.Escape], () => { closeKeyboardShortcutMenu(); }, - AppHotkeyScope.KeyboardShortcutMenu, - [toggleKeyboardShortcutMenu], + AppHotkeyScope.KeyboardShortcutMenuOpen, + [closeKeyboardShortcutMenu], ); return ( @@ -46,8 +48,8 @@ export const KeyboardShortcutMenu = () => { {isKeyboardShortcutMenuOpened && ( - {keyboardShortcutsTable.map((TableShortcut) => ( - + {keyboardShortcutsTable.map((TableShortcut, index) => ( + ))} diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx new file mode 100644 index 00000000000..b13554ed6e1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx @@ -0,0 +1,70 @@ +import { ReactNode, useEffect } from 'react'; +import { MockedProvider } from '@apollo/client/testing'; +import { renderHook } from '@testing-library/react'; +import { RecoilRoot, useSetRecoilState } from 'recoil'; + +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; + +import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + {children} + + + +); + +describe('useGetRelationMetadata', () => { + it('should return correct properties', async () => { + const objectMetadataItems = getObjectMetadataItemsMock(); + const objectMetadata = objectMetadataItems.find( + (item) => item.nameSingular === 'person', + )!; + const fieldMetadataItem = objectMetadata.fields.find( + (field) => field.name === 'opportunities', + )!; + + const { result } = renderHook( + () => { + const setMetadataItems = useSetRecoilState(objectMetadataItemsState); + + useEffect(() => { + setMetadataItems(objectMetadataItems); + }, [setMetadataItems]); + + return useGetRelationMetadata(); + }, + { + wrapper: Wrapper, + initialProps: {}, + }, + ); + + const { + relationFieldMetadataItem, + relationObjectMetadataItem, + relationType, + } = result.current({ fieldMetadataItem }) ?? {}; + + const expectedRelationObjectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === 'opportunity', + ); + const expectedRelationFieldMetadataItem = + expectedRelationObjectMetadataItem?.fields.find( + (field) => field.name === 'person', + ); + + expect(relationObjectMetadataItem).toEqual( + expectedRelationObjectMetadataItem, + ); + expect(relationFieldMetadataItem).toEqual( + expectedRelationFieldMetadataItem, + ); + expect(relationType).toBe('ONE_TO_MANY'); + }); +}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useRelationMetadata.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useRelationMetadata.test.tsx deleted file mode 100644 index d377424bfaa..00000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useRelationMetadata.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { ReactNode } from 'react'; -import { MockedProvider } from '@apollo/client/testing'; -import { renderHook } from '@testing-library/react'; -import { RecoilRoot } from 'recoil'; - -import { useRelationMetadata } from '@/object-metadata/hooks/useRelationMetadata'; -import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; - -import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; - -const Wrapper = ({ children }: { children: ReactNode }) => ( - - - - {children} - - - -); - -describe('useRelationMetadata', () => { - it('should return correct properties', async () => { - const { result, rerender } = renderHook( - ({ fieldMetadataItem }: { fieldMetadataItem?: FieldMetadataItem }) => - useRelationMetadata({ fieldMetadataItem }), - { - wrapper: Wrapper, - initialProps: {}, - }, - ); - - const { - relationFieldMetadataItem, - relationObjectMetadataItem, - relationType, - } = result.current; - - expect(relationFieldMetadataItem).toBeUndefined(); - expect(relationObjectMetadataItem).toBeUndefined(); - expect(relationType).toBeUndefined(); - - const objectMetadataItems = getObjectMetadataItemsMock(); - const objectMetadata = objectMetadataItems.find( - (item) => item.nameSingular === 'person', - )!; - const fieldMetadataItem = objectMetadata.fields.find( - (field) => field.name === 'opportunities', - )!; - - rerender({ fieldMetadataItem }); - - expect(result.current.relationType).toBe('ONE_TO_MANY'); - }); -}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts new file mode 100644 index 00000000000..8b8765d4fe4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts @@ -0,0 +1,67 @@ +import { useRecoilCallback } from 'recoil'; + +import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; +import { RelationType } from '@/settings/data-model/types/RelationType'; +import { + FieldMetadataType, + RelationMetadataType, +} from '~/generated-metadata/graphql'; + +import { FieldMetadataItem } from '../types/FieldMetadataItem'; + +export const useGetRelationMetadata = () => + useRecoilCallback( + ({ snapshot }) => + ({ fieldMetadataItem }: { fieldMetadataItem: FieldMetadataItem }) => { + if (fieldMetadataItem.type !== FieldMetadataType.Relation) return null; + + const relationMetadata = + fieldMetadataItem.fromRelationMetadata || + fieldMetadataItem.toRelationMetadata; + + if (!relationMetadata) return null; + + const relationFieldMetadataId = + 'toFieldMetadataId' in relationMetadata + ? relationMetadata.toFieldMetadataId + : relationMetadata.fromFieldMetadataId; + + if (!relationFieldMetadataId) return null; + + const relationType = + relationMetadata.relationType === RelationMetadataType.OneToMany && + fieldMetadataItem.toRelationMetadata + ? 'MANY_TO_ONE' + : (relationMetadata.relationType as RelationType); + + const relationObjectMetadataNameSingular = + 'toObjectMetadata' in relationMetadata + ? relationMetadata.toObjectMetadata.nameSingular + : relationMetadata.fromObjectMetadata.nameSingular; + + const relationObjectMetadataItem = snapshot + .getLoadable( + objectMetadataItemFamilySelector({ + objectName: relationObjectMetadataNameSingular, + objectNameType: 'singular', + }), + ) + .valueOrThrow(); + + if (!relationObjectMetadataItem) return null; + + const relationFieldMetadataItem = + relationObjectMetadataItem.fields.find( + (field) => field.id === relationFieldMetadataId, + ); + + if (!relationFieldMetadataItem) return null; + + return { + relationFieldMetadataItem, + relationObjectMetadataItem, + relationType, + }; + }, + [], + ); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useRelationMetadata.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useRelationMetadata.ts deleted file mode 100644 index b6d2657d805..00000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useRelationMetadata.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { RelationType } from '@/settings/data-model/types/RelationType'; -import { RelationMetadataType } from '~/generated-metadata/graphql'; - -import { useObjectMetadataItemForSettings } from '../hooks/useObjectMetadataItemForSettings'; -import { FieldMetadataItem } from '../types/FieldMetadataItem'; - -export const useRelationMetadata = ({ - fieldMetadataItem, -}: { - fieldMetadataItem?: FieldMetadataItem; -}) => { - const { findObjectMetadataItemById } = useObjectMetadataItemForSettings(); - - const relationMetadata = - fieldMetadataItem?.fromRelationMetadata || - fieldMetadataItem?.toRelationMetadata; - - const relationType = - relationMetadata?.relationType === RelationMetadataType.OneToMany && - fieldMetadataItem?.toRelationMetadata - ? 'MANY_TO_ONE' - : (relationMetadata?.relationType as RelationType | undefined); - - const relationObjectMetadataId = - relationMetadata && 'toObjectMetadata' in relationMetadata - ? relationMetadata.toObjectMetadata.id - : relationMetadata?.fromObjectMetadata.id; - - const relationObjectMetadataItem = relationObjectMetadataId - ? findObjectMetadataItemById(relationObjectMetadataId) - : undefined; - - const relationFieldMetadataId = - relationMetadata && 'toFieldMetadataId' in relationMetadata - ? relationMetadata.toFieldMetadataId - : relationMetadata?.fromFieldMetadataId; - - const relationFieldMetadataItem = relationFieldMetadataId - ? relationObjectMetadataItem?.fields?.find( - (field) => field.id === relationFieldMetadataId, - ) - : undefined; - - return { - relationFieldMetadataItem, - relationObjectMetadataItem, - relationType, - }; -}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts index 655b80860fa..f0217bc3ce2 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts @@ -52,10 +52,13 @@ export const getObjectRecordIdentifier = ({ ? 'squared' : 'rounded'; + // TODO: This is a temporary solution before we seed imageIdentifierFieldMetadataId in the database const avatarUrl = (objectMetadataItem.nameSingular === CoreObjectNameSingular.Company ? getLogoUrlFromDomainName(record['domainName'] ?? '') - : imageIdentifierFieldValue) ?? ''; + : objectMetadataItem.nameSingular === CoreObjectNameSingular.Person + ? record['avatarUrl'] ?? '' + : imageIdentifierFieldValue) ?? ''; const basePathToShowPage = getBasePathToShowPage({ objectMetadataItem, diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useGenerateCachedObjectRecord.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useGenerateCachedObjectRecord.ts deleted file mode 100644 index 635056ed7a9..00000000000 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useGenerateCachedObjectRecord.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { v4 } from 'uuid'; -import { z } from 'zod'; - -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue'; -import { capitalize } from '~/utils/string/capitalize'; - -export const useGenerateCachedObjectRecord = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}) => { - const generateCachedObjectRecord = < - GeneratedObjectRecord extends ObjectRecord, - >( - input: Record, - ) => { - const recordSchema = z.object( - Object.fromEntries( - objectMetadataItem.fields.map((fieldMetadataItem) => [ - fieldMetadataItem.name, - z.unknown().default(generateEmptyFieldValue(fieldMetadataItem)), - ]), - ), - ); - - return { - __typename: capitalize(objectMetadataItem.nameSingular), - ...recordSchema.parse({ - id: v4(), - createdAt: new Date().toISOString(), - ...input, - }), - } as GeneratedObjectRecord & { __typename: string }; - }; - - return { - generateCachedObjectRecord, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse.ts new file mode 100644 index 00000000000..f07a757a28d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse.ts @@ -0,0 +1,72 @@ +import { v4 } from 'uuid'; +import { z } from 'zod'; + +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useGenerateObjectRecordOptimisticResponse = ({ + objectMetadataItem, +}: { + objectMetadataItem: ObjectMetadataItem; +}) => { + const getRelationMetadata = useGetRelationMetadata(); + + const generateObjectRecordOptimisticResponse = < + GeneratedObjectRecord extends ObjectRecord, + >( + input: Record, + ) => { + const recordSchema = z.object( + Object.fromEntries( + objectMetadataItem.fields.map((fieldMetadataItem) => [ + fieldMetadataItem.name, + z.unknown().default(generateEmptyFieldValue(fieldMetadataItem)), + ]), + ), + ); + + const inputWithRelationFields = objectMetadataItem.fields.reduce( + (result, fieldMetadataItem) => { + const relationIdFieldName = `${fieldMetadataItem.name}Id`; + + if (!(relationIdFieldName in input)) return result; + + const relationMetadata = getRelationMetadata({ fieldMetadataItem }); + + if (!relationMetadata) return result; + + const relationRecordTypeName = capitalize( + relationMetadata.relationObjectMetadataItem.nameSingular, + ); + const relationRecordId = result[relationIdFieldName] as string | null; + + return { + ...result, + [fieldMetadataItem.name]: relationRecordId + ? { + __typename: relationRecordTypeName, + id: relationRecordId, + } + : null, + }; + }, + input, + ); + + return { + __typename: capitalize(objectMetadataItem.nameSingular), + ...recordSchema.parse({ + id: v4(), + createdAt: new Date().toISOString(), + ...inputWithRelationFields, + }), + } as GeneratedObjectRecord & { __typename: string }; + }; + + return { + generateObjectRecordOptimisticResponse, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts index 78986eec3f9..a81109367bb 100644 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts @@ -15,6 +15,7 @@ export const useGetRecordFromCache = ({ return ( recordId: string, + cache = apolloClient.cache, ) => { if (!objectMetadataItem) { return null; @@ -31,7 +32,6 @@ export const useGetRecordFromCache = ({ } `; - const cache = apolloClient.cache; const cachedRecordId = cache.identify({ __typename: capitalize(objectMetadataItem.nameSingular), id: recordId, diff --git a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx index 8b09dc9476d..0da8ecf3b79 100644 --- a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx +++ b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx @@ -9,12 +9,14 @@ export type RecordChipProps = { objectNameSingular: string; record: ObjectRecord; maxWidth?: number; + className?: string; }; export const RecordChip = ({ objectNameSingular, record, maxWidth, + className, }: RecordChipProps) => { const { objectMetadataItem } = useObjectMetadataItemOnly({ objectNameSingular, @@ -34,6 +36,7 @@ export const RecordChip = ({ avatarUrl={objectRecordIdentifier.avatarUrl} linkToEntity={objectRecordIdentifier.linkToShowPage} maxWidth={maxWidth} + className={className} /> ); }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateManyRecords.ts index e928f9947e4..72c5c6711aa 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateManyRecords.ts @@ -1,5 +1,7 @@ import { gql } from '@apollo/client'; +import { Person } from '@/people/types/Person'; + export const query = gql` mutation CreatePeople($data: [PersonCreateInput!]!) { createPeople(data: $data) { @@ -67,12 +69,15 @@ export const query = gql` } `; -export const variables = { - data: [ - { id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9' }, - { id: '37faabcd-cb39-4a0a-8618-7e3fda9afca0' }, - ], -}; +const data = [ + { + id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9', + name: { firstName: 'John', lastName: 'Doe' }, + }, + { id: '37faabcd-cb39-4a0a-8618-7e3fda9afca0', jobTitle: 'manager' }, +] satisfies Partial[]; + +export const variables = { data }; export const responseData = { opportunities: { @@ -114,3 +119,8 @@ export const responseData = { avatarUrl: '', companyId: '', }; + +export const response = data.map((personData) => ({ + ...responseData, + ...personData, +})); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateOneRecord.ts index 426b120a783..1599d7bc79f 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateOneRecord.ts @@ -67,10 +67,6 @@ export const query = gql` } `; -export const variables = { - input: { id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9' }, -}; - export const responseData = { opportunities: { edges: [], diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useCreateManyRecords.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useCreateManyRecords.test.tsx index f0da527d46d..9eb14eadd79 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useCreateManyRecords.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useCreateManyRecords.test.tsx @@ -1,19 +1,27 @@ import { ReactNode } from 'react'; import { MockedProvider } from '@apollo/client/testing'; +import { mocked } from '@storybook/test'; import { act, renderHook } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; +import { v4 } from 'uuid'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { query, - responseData, + response, variables, } from '@/object-record/hooks/__mocks__/useCreateManyRecords'; import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords'; -const people = [ - { id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9' }, - { id: '37faabcd-cb39-4a0a-8618-7e3fda9afca0' }, -]; +jest.mock('uuid', () => ({ + v4: jest.fn(), +})); + +mocked(v4) + .mockReturnValueOnce(variables.data[0].id) + .mockReturnValueOnce(variables.data[1].id); + +const input = variables.data.map(({ id: _id, ...personInput }) => personInput); const mocks = [ { @@ -23,10 +31,7 @@ const mocks = [ }, result: jest.fn(() => ({ data: { - createPeople: people.map((person) => ({ - id: person.id, - ...responseData, - })), + createPeople: response, }, })), }, @@ -43,19 +48,18 @@ const Wrapper = ({ children }: { children: ReactNode }) => ( describe('useCreateManyRecords', () => { it('works as expected', async () => { const { result } = renderHook( - () => useCreateManyRecords({ objectNameSingular: 'person' }), + () => + useCreateManyRecords({ + objectNameSingular: CoreObjectNameSingular.Person, + }), { wrapper: Wrapper, }, ); await act(async () => { - const res = await result.current.createManyRecords(people); - expect(res).toBeDefined(); - expect(Array.isArray(res)).toBe(true); - expect(res?.length).toBe(2); - expect(res?.[0].id).toBe(people[0].id); - expect(res?.[1].id).toBe(people[1].id); + const res = await result.current.createManyRecords(input); + expect(res).toEqual(response); }); expect(mocks[0].result).toHaveBeenCalled(); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useCreateOneRecord.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useCreateOneRecord.test.tsx index 7deae9134e5..58a9e3fc221 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useCreateOneRecord.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useCreateOneRecord.test.tsx @@ -3,24 +3,29 @@ import { MockedProvider } from '@apollo/client/testing'; import { act, renderHook } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { query, responseData, - variables, } from '@/object-record/hooks/__mocks__/useCreateOneRecord'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; -const person = { id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9' }; +const personId = 'a7286b9a-c039-4a89-9567-2dfa7953cda9'; +const input = { name: { firstName: 'John', lastName: 'Doe' } }; + +jest.mock('uuid', () => ({ + v4: jest.fn(() => personId), +})); const mocks = [ { request: { query, - variables, + variables: { input: { ...input, id: personId } }, }, result: jest.fn(() => ({ data: { - createPerson: { ...person, ...responseData }, + createPerson: { ...responseData, ...input, id: personId }, }, })), }, @@ -37,16 +42,20 @@ const Wrapper = ({ children }: { children: ReactNode }) => ( describe('useCreateOneRecord', () => { it('works as expected', async () => { const { result } = renderHook( - () => useCreateOneRecord({ objectNameSingular: 'person' }), + () => + useCreateOneRecord({ + objectNameSingular: CoreObjectNameSingular.Person, + }), { wrapper: Wrapper, }, ); await act(async () => { - const res = await result.current.createOneRecord(person); + const res = await result.current.createOneRecord(input); + console.log('res', res); expect(res).toBeDefined(); - expect(res).toHaveProperty('id', person.id); + expect(res).toHaveProperty('id', personId); }); expect(mocks[0].result).toHaveBeenCalled(); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts index 13e3b2c2e9d..5aa32ceab61 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts @@ -1,9 +1,11 @@ import { useApolloClient } from '@apollo/client'; +import { v4 } from 'uuid'; import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; -import { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord'; +import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse'; import { getCreateManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateCreateManyRecordMutation'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; @@ -13,29 +15,33 @@ export const useCreateManyRecords = < >({ objectNameSingular, }: ObjectMetadataItemIdentifier) => { + const apolloClient = useApolloClient(); + const { objectMetadataItem, createManyRecordsMutation } = useObjectMetadataItem({ objectNameSingular, }); - const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({ - objectMetadataItem, - }); + const { generateObjectRecordOptimisticResponse } = + useGenerateObjectRecordOptimisticResponse({ + objectMetadataItem, + }); - const apolloClient = useApolloClient(); + const getRelationMetadata = useGetRelationMetadata(); const createManyRecords = async (data: Partial[]) => { - const optimisticallyCreatedRecords = data.map((record) => - generateCachedObjectRecord(record), - ); - - const sanitizedCreateManyRecordsInput = data.map((input, index) => + const sanitizedCreateManyRecordsInput = data.map((input) => sanitizeRecordInput({ objectMetadataItem, - recordInput: { ...input, id: optimisticallyCreatedRecords[index].id }, + recordInput: { ...input, id: v4() }, }), ); + const optimisticallyCreatedRecords = sanitizedCreateManyRecordsInput.map( + (record) => + generateObjectRecordOptimisticResponse(record), + ); + const mutationResponseField = getCreateManyRecordsMutationResponseField( objectMetadataItem.namePlural, ); @@ -57,6 +63,7 @@ export const useCreateManyRecords = < cache, objectMetadataItem, records, + getRelationMetadata, }); }, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecordsInCache.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecordsInCache.ts index cc3d3fd93e5..f1f131c4448 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecordsInCache.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecordsInCache.ts @@ -3,7 +3,7 @@ import { v4 } from 'uuid'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; import { useAddRecordInCache } from '@/object-record/cache/hooks/useAddRecordInCache'; -import { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord'; +import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; export const useCreateManyRecordsInCache = ({ @@ -13,9 +13,10 @@ export const useCreateManyRecordsInCache = ({ objectNameSingular, }); - const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({ - objectMetadataItem, - }); + const { generateObjectRecordOptimisticResponse } = + useGenerateObjectRecordOptimisticResponse({ + objectMetadataItem, + }); const addRecordInCache = useAddRecordInCache({ objectMetadataItem, @@ -30,9 +31,8 @@ export const useCreateManyRecordsInCache = ({ const createdRecordsInCache = [] as T[]; for (const record of recordsWithId) { - const generatedCachedObjectRecord = generateCachedObjectRecord({ - ...record, - }); + const generatedCachedObjectRecord = + generateObjectRecordOptimisticResponse(record); if (generatedCachedObjectRecord) { addRecordInCache(generatedCachedObjectRecord); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts index 00d72c53134..33f5d4a2083 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts @@ -1,8 +1,10 @@ import { useApolloClient } from '@apollo/client'; +import { v4 } from 'uuid'; import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord'; +import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse'; import { getCreateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateCreateOneRecordMutation'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; @@ -16,25 +18,27 @@ export const useCreateOneRecord = < >({ objectNameSingular, }: useCreateOneRecordProps) => { + const apolloClient = useApolloClient(); + const { objectMetadataItem, createOneRecordMutation } = useObjectMetadataItem( { objectNameSingular }, ); - // TODO: type this with a minimal type at least with Record - const apolloClient = useApolloClient(); + const { generateObjectRecordOptimisticResponse } = + useGenerateObjectRecordOptimisticResponse({ + objectMetadataItem, + }); - const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({ - objectMetadataItem, - }); + const getRelationMetadata = useGetRelationMetadata(); const createOneRecord = async (input: Partial) => { const sanitizedCreateOneRecordInput = sanitizeRecordInput({ objectMetadataItem, - recordInput: input, + recordInput: { ...input, id: v4() }, }); const optimisticallyCreatedRecord = - generateCachedObjectRecord({ + generateObjectRecordOptimisticResponse({ ...input, ...sanitizedCreateOneRecordInput, }); @@ -45,10 +49,7 @@ export const useCreateOneRecord = < const createdObject = await apolloClient.mutate({ mutation: createOneRecordMutation, variables: { - input: { - ...sanitizedCreateOneRecordInput, - id: optimisticallyCreatedRecord.id, - }, + input: sanitizedCreateOneRecordInput, }, optimisticResponse: { [mutationResponseField]: optimisticallyCreatedRecord, @@ -62,6 +63,7 @@ export const useCreateOneRecord = < cache, objectMetadataItem, records: [record], + getRelationMetadata, }); }, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecordInCache.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecordInCache.ts index c1d10022e09..358084a28a2 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecordInCache.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecordInCache.ts @@ -1,6 +1,6 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useAddRecordInCache } from '@/object-record/cache/hooks/useAddRecordInCache'; -import { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord'; +import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; type useCreateOneRecordInCacheProps = { @@ -14,19 +14,18 @@ export const useCreateOneRecordInCache = ({ objectNameSingular, }); - const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({ - objectMetadataItem, - }); + const { generateObjectRecordOptimisticResponse } = + useGenerateObjectRecordOptimisticResponse({ + objectMetadataItem, + }); const addRecordInCache = useAddRecordInCache({ objectMetadataItem, }); const createOneRecordInCache = async (input: ObjectRecord) => { - const generatedCachedObjectRecord = generateCachedObjectRecord({ - createdAt: new Date().toISOString(), - ...input, - }); + const generatedCachedObjectRecord = + generateObjectRecordOptimisticResponse(input); addRecordInCache(generatedCachedObjectRecord); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts index f62fa9570b4..fc0e8161a89 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts @@ -1,9 +1,10 @@ import { useApolloClient } from '@apollo/client'; import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { getDeleteManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation'; -import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; +import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; type useDeleteOneRecordProps = { @@ -14,26 +15,22 @@ type useDeleteOneRecordProps = { export const useDeleteManyRecords = ({ objectNameSingular, }: useDeleteOneRecordProps) => { - const { objectMetadataItem, deleteManyRecordsMutation } = + const apolloClient = useApolloClient(); + + const { objectMetadataItem, deleteManyRecordsMutation, getRecordFromCache } = useObjectMetadataItem({ objectNameSingular }); - const apolloClient = useApolloClient(); + const getRelationMetadata = useGetRelationMetadata(); const mutationResponseField = getDeleteManyRecordsMutationResponseField( objectMetadataItem.namePlural, ); const deleteManyRecords = async (idsToDelete: string[]) => { - const deleteRecordFilter: ObjectRecordQueryFilter = { - id: { - in: idsToDelete, - }, - }; const deletedRecords = await apolloClient.mutate({ mutation: deleteManyRecordsMutation, variables: { - filter: deleteRecordFilter, - // atMost: idsToDelete.length, + filter: { id: { in: idsToDelete } }, }, optimisticResponse: { [mutationResponseField]: idsToDelete.map((idToDelete) => ({ @@ -46,10 +43,15 @@ export const useDeleteManyRecords = ({ if (!records?.length) return; + const cachedRecords = records + .map((record) => getRecordFromCache(record.id, cache)) + .filter(isDefined); + triggerDeleteRecordsOptimisticEffect({ cache, objectMetadataItem, - records, + records: cachedRecords, + getRelationMetadata, }); }, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts index 5987d1472e3..14ce35a421a 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { useApolloClient } from '@apollo/client'; import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/generateDeleteOneRecordMutation'; import { capitalize } from '~/utils/string/capitalize'; @@ -14,12 +15,13 @@ type useDeleteOneRecordProps = { export const useDeleteOneRecord = ({ objectNameSingular, }: useDeleteOneRecordProps) => { - const { objectMetadataItem, deleteOneRecordMutation } = useObjectMetadataItem( - { objectNameSingular }, - ); - const apolloClient = useApolloClient(); + const { objectMetadataItem, deleteOneRecordMutation, getRecordFromCache } = + useObjectMetadataItem({ objectNameSingular }); + + const getRelationMetadata = useGetRelationMetadata(); + const mutationResponseField = getDeleteOneRecordMutationResponseField(objectNameSingular); @@ -39,10 +41,15 @@ export const useDeleteOneRecord = ({ if (!record) return; + const cachedRecord = getRecordFromCache(record.id, cache); + + if (!cachedRecord) return; + triggerDeleteRecordsOptimisticEffect({ cache, objectMetadataItem, - records: [record], + records: [cachedRecord], + getRelationMetadata, }); }, }); @@ -52,6 +59,8 @@ export const useDeleteOneRecord = ({ [ apolloClient, deleteOneRecordMutation, + getRecordFromCache, + getRelationMetadata, mutationResponseField, objectMetadataItem, objectNameSingular, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts index 796e417429f..031ed2c4c0b 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts @@ -1,13 +1,12 @@ import { useApolloClient } from '@apollo/client'; -import { useGetRelationFieldsToOptimisticallyUpdate } from '@/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate'; import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect'; -import { triggerUpdateRelationFieldOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse'; import { getUpdateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; -import { capitalize } from '~/utils/string/capitalize'; type useUpdateOneRecordProps = { objectNameSingular: string; @@ -18,13 +17,17 @@ export const useUpdateOneRecord = < >({ objectNameSingular, }: useUpdateOneRecordProps) => { + const apolloClient = useApolloClient(); + const { objectMetadataItem, updateOneRecordMutation, getRecordFromCache } = useObjectMetadataItem({ objectNameSingular }); - const getRelationFieldsToOptimisticallyUpdate = - useGetRelationFieldsToOptimisticallyUpdate(); + const { generateObjectRecordOptimisticResponse } = + useGenerateObjectRecordOptimisticResponse({ + objectMetadataItem, + }); - const apolloClient = useApolloClient(); + const getRelationMetadata = useGetRelationMetadata(); const updateOneRecord = async ({ idToUpdate, @@ -40,21 +43,11 @@ export const useUpdateOneRecord = < recordInput: updateOneRecordInput, }); - const optimisticallyUpdatedRecord = { + const optimisticallyUpdatedRecord = generateObjectRecordOptimisticResponse({ ...(cachedRecord ?? {}), - ...updateOneRecordInput, ...sanitizedUpdateOneRecordInput, - __typename: capitalize(objectNameSingular), id: idToUpdate, - }; - - const updatedRelationFields = cachedRecord - ? getRelationFieldsToOptimisticallyUpdate({ - cachedRecord, - objectMetadataItem, - updateRecordInput: updateOneRecordInput, - }) - : []; + }); const mutationResponseField = getUpdateOneRecordMutationResponseField(objectNameSingular); @@ -71,31 +64,15 @@ export const useUpdateOneRecord = < update: (cache, { data }) => { const record = data?.[mutationResponseField]; - if (!record) return; + if (!record || !cachedRecord) return; triggerUpdateRecordOptimisticEffect({ cache, objectMetadataItem, - record, + previousRecord: cachedRecord, + nextRecord: record, + getRelationMetadata, }); - - updatedRelationFields.forEach( - ({ - relationObjectMetadataNameSingular, - relationFieldName, - previousRelationRecord, - nextRelationRecord, - }) => - triggerUpdateRelationFieldOptimisticEffect({ - cache, - objectNameSingular, - record, - relationObjectMetadataNameSingular, - relationFieldName, - previousRelationRecord, - nextRelationRecord, - }), - ); }, }); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecated.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecated.tsx index 61bbd75f39d..854a138297d 100644 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecated.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecated.tsx @@ -12,9 +12,7 @@ import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-boa import { useSetRecordBoardDeprecatedCardSelectedInternal } from '@/object-record/record-board-deprecated/hooks/internal/useSetRecordBoardDeprecatedCardSelectedInternal'; import { RecordBoardDeprecatedScope } from '@/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope'; import { Opportunity } from '@/pipeline/types/Opportunity'; -import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { logError } from '~/utils/logError'; @@ -129,12 +127,6 @@ export const RecordBoardDeprecated = ({ const boardRef = useRef(null); - useScopedHotkeys( - 'escape', - unselectAllActiveCards, - PageHotkeyScope.OpportunitiesPage, - ); - return ( diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnDropdownMenu.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnDropdownMenu.tsx index 4c20960063a..69e99b95d02 100644 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnDropdownMenu.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnDropdownMenu.tsx @@ -69,7 +69,9 @@ export const RecordBoardDeprecatedColumnDropdownMenu = ({ useScopedHotkeys( [Key.Escape, Key.Enter], - closeMenu, + () => { + closeMenu(); + }, BoardColumnHotkeyScope.BoardColumn, [], ); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownContent.tsx index 58514c6c814..e673f1de3d0 100644 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownContent.tsx @@ -123,7 +123,7 @@ export const RecordBoardDeprecatedOptionsDropdownContent = ({ ); useScopedHotkeys( - Key.Escape, + [Key.Escape], () => { setViewEditMode('none'); closeDropdown(); diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx index 3cfddff76c0..3fff6daa518 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx @@ -2,15 +2,19 @@ import { useContext, useRef } from 'react'; import styled from '@emotion/styled'; import { DragDropContext, OnDragEndResponder } from '@hello-pangea/dnd'; // Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350 import { useRecoilCallback, useRecoilValue } from 'recoil'; +import { Key } from 'ts-key-enum'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; +import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; import { RecordBoardColumn } from '@/object-record/record-board/record-board-column/components/RecordBoardColumn'; import { RecordBoardScope } from '@/object-record/record-board/scopes/RecordBoardScope'; +import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; export type RecordBoardProps = { recordBoardId: string; @@ -40,17 +44,25 @@ const StyledBoardHeader = styled.div` `; export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => { - const { updateOneRecord, objectMetadataItem } = + const { updateOneRecord, selectFieldMetadataItem } = useContext(RecordBoardContext); const boardRef = useRef(null); const { getColumnIdsState, columnsFamilySelector } = useRecordBoardStates(recordBoardId); + const columnIds = useRecoilValue(getColumnIdsState()); - const selectFieldMetadataItem = objectMetadataItem.fields.find( - (field) => field.type === FieldMetadataType.Select, - ); + const { resetRecordSelection, setRecordAsSelected } = + useRecordBoardSelection(recordBoardId); + + useListenClickOutsideByClassName({ + classNames: ['record-board-card'], + excludeClassNames: ['action-bar', 'context-menu'], + callback: resetRecordSelection, + }); + + useScopedHotkeys([Key.Escape], resetRecordSelection, TableHotkeyScope.Table); const onDragEnd: OnDragEndResponder = useRecoilCallback( ({ snapshot }) => @@ -106,7 +118,8 @@ export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => { {}} + onDragSelectionStart={resetRecordSelection} + onDragSelectionChange={setRecordAsSelected} /> diff --git a/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardContext.ts b/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardContext.ts index 8043ec985b3..d8d7f149073 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardContext.ts @@ -1,10 +1,12 @@ import { createContext } from 'react'; +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; type RecordBoardContextProps = { objectMetadataItem: ObjectMetadataItem; + selectFieldMetadataItem: FieldMetadataItem; createOneRecord: (recordInput: Partial) => void; updateOneRecord: ({ idToUpdate, diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts index 63b98c80468..291db2655e4 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts @@ -3,6 +3,8 @@ import { isFirstRecordBoardColumnFamilyStateScopeMap } from '@/object-record/rec import { isLastRecordBoardColumnFamilyStateScopeMap } from '@/object-record/record-board/states/isLastRecordBoardColumnFamilyStateScopeMap'; import { isRecordBoardCardSelectedFamilyStateScopeMap } from '@/object-record/record-board/states/isRecordBoardCardSelectedFamilyStateScopeMap'; import { isRecordBoardCompactModeActiveStateScopeMap } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveStateScopeMap'; +import { isRecordBoardFetchingRecordsStateScopeMap } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap'; +import { onRecordBoardFetchMoreVisibilityChangeStateScopeMap } from '@/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap'; import { recordBoardColumnIdsStateScopeMap } from '@/object-record/record-board/states/recordBoardColumnIdsStateScopeMap'; import { recordBoardFieldDefinitionsStateScopeMap } from '@/object-record/record-board/states/recordBoardFieldDefinitionsStateScopeMap'; import { recordBoardFiltersStateScopeMap } from '@/object-record/record-board/states/recordBoardFiltersStateScopeMap'; @@ -30,6 +32,10 @@ export const useRecordBoardStates = (recordBoardId?: string) => { recordBoardObjectSingularNameStateScopeMap, scopeId, ), + getIsFetchingRecordState: getState( + isRecordBoardFetchingRecordsStateScopeMap, + scopeId, + ), getColumnIdsState: getState(recordBoardColumnIdsStateScopeMap, scopeId), isFirstColumnFamilyState: getFamilyState( isFirstRecordBoardColumnFamilyStateScopeMap, @@ -72,5 +78,10 @@ export const useRecordBoardStates = (recordBoardId?: string) => { isRecordBoardCompactModeActiveStateScopeMap, scopeId, ), + + getOnFetchMoreVisibilityChangeState: getState( + onRecordBoardFetchMoreVisibilityChangeStateScopeMap, + scopeId, + ), }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts index 486bce6e1af..59429b25465 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts @@ -10,6 +10,8 @@ export const useRecordBoard = (recordBoardId?: string) => { getFieldDefinitionsState, getObjectSingularNameState, getSelectedRecordIdsSelector, + getIsCompactModeActiveState, + getOnFetchMoreVisibilityChangeState, } = useRecordBoardStates(recordBoardId); const { setColumns } = useSetRecordBoardColumns(recordBoardId); @@ -24,5 +26,7 @@ export const useRecordBoard = (recordBoardId?: string) => { setFieldDefinitions, setObjectSingularName, getSelectedRecordIdsSelector, + getIsCompactModeActiveState, + getOnFetchMoreVisibilityChangeState, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useResetBoardRecordSelection.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts similarity index 53% rename from packages/twenty-front/src/modules/object-record/record-board/hooks/useResetBoardRecordSelection.ts rename to packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts index 6266eb5367d..ae89223c79e 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useResetBoardRecordSelection.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts @@ -2,7 +2,7 @@ import { useRecoilCallback } from 'recoil'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; -export const useResetBoardRecordSelection = (recordBoardId?: string) => { +export const useRecordBoardSelection = (recordBoardId?: string) => { const { getSelectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState } = useRecordBoardStates(recordBoardId); @@ -20,5 +20,21 @@ export const useResetBoardRecordSelection = (recordBoardId?: string) => { [getSelectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState], ); - return { resetRecordSelection }; + const setRecordAsSelected = useRecoilCallback( + ({ snapshot, set }) => + (recordId: string, isSelected: boolean) => { + const isRecordCurrentlySelected = snapshot + .getLoadable(isRecordBoardCardSelectedFamilyState(recordId)) + .getValue(); + + if (isRecordCurrentlySelected === isSelected) { + return; + } + + set(isRecordBoardCardSelectedFamilyState(recordId), isSelected); + }, + [isRecordBoardCardSelectedFamilyState], + ); + + return { resetRecordSelection, setRecordAsSelected }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx index efbc96d1f73..15f2424e97c 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx @@ -1,4 +1,5 @@ import { ReactNode, useContext, useState } from 'react'; +import { useInView } from 'react-intersection-observer'; import styled from '@emotion/styled'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; @@ -20,6 +21,7 @@ import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox'; import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState'; import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState'; import { AnimatedEaseInOut } from '@/ui/utilities/animation/components/AnimatedEaseInOut'; +import { ScrollWrapperContext } from '@/ui/utilities/scroll/components/ScrollWrapper'; const StyledBoardCard = styled.div<{ selected: boolean }>` background-color: ${({ theme, selected }) => @@ -119,22 +121,26 @@ const StyledCompactIconContainer = styled.div` align-items: center; display: flex; justify-content: center; + margin-left: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledRecordInlineCellPlaceholder = styled.div` + height: 24px; `; export const RecordBoardCard = () => { const { recordId } = useContext(RecordBoardCardContext); - const { updateOneRecord } = useContext(RecordBoardContext); + const { updateOneRecord, objectMetadataItem } = + useContext(RecordBoardContext); const { - getObjectSingularNameState, getIsCompactModeActiveState, isRecordBoardCardSelectedFamilyState, getVisibleFieldDefinitionsState, } = useRecordBoardStates(); const isCompactModeActive = useRecoilValue(getIsCompactModeActiveState()); - const objectNameSingular = useRecoilValue(getObjectSingularNameState()); - const [isCardInCompactMode, setIsCardInCompactMode] = - useState(isCompactModeActive); + + const [isCardInCompactMode, setIsCardInCompactMode] = useState(true); const [isCurrentCardSelected, setIsCurrentCardSelected] = useRecoilState( isRecordBoardCardSelectedFamilyState(recordId), @@ -190,13 +196,21 @@ export const RecordBoardCard = () => { return [updateEntity, { loading: false }]; }; - if (!objectNameSingular || !record) { + const scrollWrapperRef = useContext(ScrollWrapperContext); + + const { ref: cardRef, inView } = useInView({ + root: scrollWrapperRef.current, + rootMargin: '1000px', + }); + + if (!record) { return null; } return ( { @@ -204,7 +218,10 @@ export const RecordBoardCard = () => { }} > - + {isCompactModeActive && ( { - + {visibleBoardCardFieldDefinitions.map((fieldDefinition) => ( { hotkeyScope: InlineCellHotkeyScope.InlineCell, }} > - + {inView ? ( + + ) : ( + + )} ))} diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx index f8f0b9c8dea..af763fa60eb 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx @@ -18,7 +18,7 @@ export const RecordBoardCardDraggableContainer = ({ {...draggableProvided?.dragHandleProps} // eslint-disable-next-line react/jsx-props-no-spreading {...draggableProvided?.draggableProps} - className="entity-board-card" + className="record-board-card" data-selectable-id={recordId} data-select-disable > diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx index 5a048c78f6a..70660329bdc 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx @@ -60,6 +60,7 @@ export const RecordBoardColumn = ({ columnDefinition: columnDefinition, isFirstColumn: isFirstColumn, isLastColumn: isLastColumn, + recordCount: recordIds.length, }} > diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx index d9bc202be9a..96d14a011bd 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx @@ -1,12 +1,14 @@ -import React from 'react'; +import React, { useContext } from 'react'; import styled from '@emotion/styled'; -import { DroppableProvided } from '@hello-pangea/dnd'; +import { Draggable, DroppableProvided } from '@hello-pangea/dnd'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { RecordBoardColumnCardsMemo } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnCardsMemo'; - -const StyledPlaceholder = styled.div` - min-height: 1px; -`; +import { RecordBoardColumnFetchMoreLoader } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader'; +import { RecordBoardColumnNewButton } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton'; +import { RecordBoardColumnNewOpportunityButton } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunityButton'; +import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; const StyledColumnCardsContainer = styled.div` display: flex; @@ -14,6 +16,10 @@ const StyledColumnCardsContainer = styled.div` flex-direction: column; `; +const StyledNewButtonContainer = styled.div` + padding-bottom: ${({ theme }) => theme.spacing(4)}; +`; + type RecordBoardColumnCardsContainerProps = { recordIds: string[]; droppableProvided: DroppableProvided; @@ -23,6 +29,9 @@ export const RecordBoardColumnCardsContainer = ({ recordIds, droppableProvided, }: RecordBoardColumnCardsContainerProps) => { + const { columnDefinition } = useContext(RecordBoardColumnContext); + const { objectMetadataItem } = useContext(RecordBoardContext); + return ( - {droppableProvided?.placeholder} + + + {(draggableProvided) => ( + + + {objectMetadataItem.nameSingular === + CoreObjectNameSingular.Opportunity ? ( + + ) : ( + + )} + + + )} + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx new file mode 100644 index 00000000000..98ca2e8666b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx @@ -0,0 +1,36 @@ +import { useInView } from 'react-intersection-observer'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; + +import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; +import { grayScale } from '@/ui/theme/constants/colors'; + +const StyledText = styled.div` + align-items: center; + box-shadow: none; + color: ${grayScale.gray40}; + display: flex; + height: 32px; + margin-left: ${({ theme }) => theme.spacing(8)}; + padding-left: ${({ theme }) => theme.spacing(2)}; +`; + +export const RecordBoardColumnFetchMoreLoader = () => { + const { getIsFetchingRecordState, getOnFetchMoreVisibilityChangeState } = + useRecordBoardStates(); + const isFetchingRecords = useRecoilValue(getIsFetchingRecordState()); + + const onFetchMoreVisibilityChange = useRecoilValue( + getOnFetchMoreVisibilityChangeState(), + ); + + const { ref } = useInView({ + onChange: onFetchMoreVisibilityChange, + }); + + return ( + + {isFetchingRecords && Loading more...} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx index 31d47a39a34..d1e36a3ecc6 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx @@ -46,7 +46,9 @@ export const RecordBoardColumnHeader = () => { const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false); const [isHeaderHovered, setIsHeaderHovered] = useState(false); - const { columnDefinition } = useContext(RecordBoardColumnContext); + const { columnDefinition, recordCount } = useContext( + RecordBoardColumnContext, + ); const { setHotkeyScopeAndMemorizePreviousScope, @@ -66,7 +68,6 @@ export const RecordBoardColumnHeader = () => { }; const boardColumnTotal = 0; - const cardIds = []; return ( <> @@ -81,7 +82,7 @@ export const RecordBoardColumnHeader = () => { /> {!!boardColumnTotal && ${boardColumnTotal}} {!isHeaderHovered && ( - {cardIds.length} + {recordCount} )} {isHeaderHovered && ( diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton.tsx new file mode 100644 index 00000000000..deac52c76d3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton.tsx @@ -0,0 +1,44 @@ +import { useContext } from 'react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; +import { IconPlus } from '@/ui/display/icon/index'; + +const StyledButton = styled.button` + align-items: center; + align-self: baseline; + background-color: ${({ theme }) => theme.background.primary}; + border: none; + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${({ theme }) => theme.font.color.tertiary}; + cursor: pointer; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + padding: ${({ theme }) => theme.spacing(1)}; + + &:hover { + background-color: ${({ theme }) => theme.background.tertiary}; + } +`; + +export const RecordBoardColumnNewButton = () => { + const theme = useTheme(); + const { columnDefinition } = useContext(RecordBoardColumnContext); + const { createOneRecord, selectFieldMetadataItem } = + useContext(RecordBoardContext); + + const onNewClick = () => { + createOneRecord({ + [selectFieldMetadataItem.name]: columnDefinition.value, + }); + }; + + return ( + + + New + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunityButton.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunityButton.tsx new file mode 100644 index 00000000000..9cac6da4ff7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunityButton.tsx @@ -0,0 +1,90 @@ +import { useCallback, useContext, useState } from 'react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; +import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect'; +import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; +import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; +import { IconPlus } from '@/ui/display/icon'; +import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; + +const StyledButton = styled.button` + align-items: center; + align-self: baseline; + background-color: ${({ theme }) => theme.background.primary}; + border: none; + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${({ theme }) => theme.font.color.tertiary}; + cursor: pointer; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + padding: ${({ theme }) => theme.spacing(1)}; + + &:hover { + background-color: ${({ theme }) => theme.background.tertiary}; + } +`; + +export const RecordBoardColumnNewOpportunityButton = () => { + const [isCreatingCard, setIsCreatingCard] = useState(false); + + const theme = useTheme(); + const { columnDefinition } = useContext(RecordBoardColumnContext); + const { createOneRecord, selectFieldMetadataItem } = + useContext(RecordBoardContext); + + const { + goBackToPreviousHotkeyScope, + setHotkeyScopeAndMemorizePreviousScope, + } = usePreviousHotkeyScope(); + + const handleEntitySelect = (company?: EntityForSelect) => { + setIsCreatingCard(false); + goBackToPreviousHotkeyScope(); + + if (!company) { + return; + } + + createOneRecord({ + name: company.name, + companyId: company.id, + [selectFieldMetadataItem.name]: columnDefinition.value, + }); + }; + + const handleNewClick = useCallback(() => { + setIsCreatingCard(true); + setHotkeyScopeAndMemorizePreviousScope( + RelationPickerHotkeyScope.RelationPicker, + ); + }, [setIsCreatingCard, setHotkeyScopeAndMemorizePreviousScope]); + + const handleCancel = () => { + goBackToPreviousHotkeyScope(); + setIsCreatingCard(false); + }; + + return ( + <> + {isCreatingCard ? ( + + ) : ( + + + New + + )} + > + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts index 7016dcce81d..8a9ced3eb00 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts @@ -6,6 +6,7 @@ type RecordBoardColumnContextProps = { columnDefinition: RecordBoardColumnDefinition; isFirstColumn: boolean; isLastColumn: boolean; + recordCount: number; }; export const RecordBoardColumnContext = diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap.ts new file mode 100644 index 00000000000..83c35a4be90 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap.ts @@ -0,0 +1,7 @@ +import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; + +export const isRecordBoardFetchingRecordsStateScopeMap = + createStateScopeMap({ + key: 'isRecordBoardFetchingRecordsStateScopeMap', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap.ts new file mode 100644 index 00000000000..fb5da620aa7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap.ts @@ -0,0 +1,7 @@ +import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; + +export const onRecordBoardFetchMoreVisibilityChangeStateScopeMap = + createStateScopeMap<(visbility: boolean) => void>({ + key: 'onRecordBoardFetchMoreVisibilityChangeStateScopeMap', + defaultValue: () => {}, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/constants/RatingValues.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/constants/RatingValues.ts new file mode 100644 index 00000000000..9443fcbd105 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/constants/RatingValues.ts @@ -0,0 +1,7 @@ +export const RATING_VALUES = [ + 'RATING_1', + 'RATING_2', + 'RATING_3', + 'RATING_4', + 'RATING_5', +] as const; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/CurrencyFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/CurrencyFieldDisplay.tsx index c7988a1d484..75e4030e51b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/CurrencyFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/CurrencyFieldDisplay.tsx @@ -8,7 +8,7 @@ export const CurrencyFieldDisplay = () => { return ( ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RatingFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RatingFieldInput.tsx index 300276b73c7..447be4f0631 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RatingFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RatingFieldInput.tsx @@ -11,14 +11,6 @@ export type RatingFieldInputProps = { readonly?: boolean; }; -export const RATING_VALUES = [ - 'RATING_1', - 'RATING_2', - 'RATING_3', - 'RATING_4', - 'RATING_5', -] as const; - export const RatingFieldInput = ({ onSubmit, readonly, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts index 36e180652b2..c4aa0b30851 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts @@ -1,3 +1,5 @@ +import { Key } from 'ts-key-enum'; + import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { isDefined } from '~/utils/isDefined'; @@ -41,7 +43,7 @@ export const useRegisterInputEvents = ({ ); useScopedHotkeys( - 'esc', + [Key.Escape], () => { onEscape?.(inputValue); }, diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts index 634df5dfe55..1f0d498eac9 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts @@ -1,4 +1,4 @@ -import { RATING_VALUES } from '@/object-record/record-field/meta-types/input/components/RatingFieldInput'; +import { RATING_VALUES } from '@/object-record/record-field/meta-types/constants/RatingValues'; import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; import { ThemeColor } from '@/ui/theme/constants/colors'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRatingValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRatingValue.ts index 484ecb3fbc5..3fc8459a08d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRatingValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRatingValue.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; -import { RATING_VALUES } from '../../meta-types/input/components/RatingFieldInput'; +import { RATING_VALUES } from '@/object-record/record-field/meta-types/constants/RatingValues'; + import { FieldRatingValue } from '../FieldMetadata'; const ratingSchema = z.string().pipe(z.enum(RATING_VALUES)); diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts index bca25224dae..ee11e822cbf 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts @@ -4,6 +4,7 @@ import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldIn import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency'; import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue'; +import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; import { computeEmptyDraftValue } from '@/object-record/record-field/utils/computeEmptyDraftValue'; import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty'; @@ -21,18 +22,20 @@ export const computeDraftValueFromFieldValue = ({ // than the intputDraftValue type as string can be typed anywhere if (isFieldCurrency(fieldDefinition)) { - if (isFieldValueEmpty({ fieldValue, fieldDefinition })) { + if ( + isFieldValueEmpty({ fieldValue, fieldDefinition }) || + !isFieldCurrencyValue(fieldValue) + ) { return computeEmptyDraftValue({ fieldDefinition }); } - if (isFieldCurrencyValue(fieldValue)) { - return { - amount: fieldValue?.amountMicros - ? fieldValue.amountMicros / 1000000 - : '', - currenyCode: CurrencyCode.USD, - } as unknown as FieldInputDraftValue; - } + return { + amount: fieldValue?.amountMicros ? fieldValue.amountMicros / 1000000 : '', + currenyCode: CurrencyCode.USD, + } as unknown as FieldInputDraftValue; + } + if (isFieldRelation(fieldDefinition)) { + return computeEmptyDraftValue({ fieldDefinition }); } return fieldValue as FieldInputDraftValue; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/computeEmptyDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/computeEmptyDraftValue.ts index 5a680ff13ad..2af0ea25c18 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/computeEmptyDraftValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/computeEmptyDraftValue.ts @@ -8,6 +8,7 @@ import { isFieldEmail } from '@/object-record/record-field/types/guards/isFieldE import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; +import { isFieldRelationValue } from '@/object-record/record-field/types/guards/isFieldRelationValue'; import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText'; import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid'; @@ -24,7 +25,8 @@ export const computeEmptyDraftValue = ({ isFieldText(fieldDefinition) || isFieldDateTime(fieldDefinition) || isFieldNumber(fieldDefinition) || - isFieldEmail(fieldDefinition) + isFieldEmail(fieldDefinition) || + isFieldRelationValue(fieldDefinition) ) { return '' as FieldInputDraftValue; } diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx index 80f8ad7c662..2fd30265fc3 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx @@ -1,4 +1,4 @@ -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; @@ -6,6 +6,7 @@ import { RecordBoardActionBar } from '@/object-record/record-board/action-bar/co import { RecordBoard } from '@/object-record/record-board/components/RecordBoard'; import { RecordBoardContextMenu } from '@/object-record/record-board/context-menu/components/RecordBoardContextMenu'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; type RecordIndexBoardContainerProps = { recordBoardId: string; @@ -18,16 +19,27 @@ export const RecordIndexBoardContainer = ({ recordBoardId, objectNameSingular, }: RecordIndexBoardContainerProps) => { - const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular }); + const { objectMetadataItem } = useObjectMetadataItemOnly({ + objectNameSingular, + }); + + const selectFieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.type === FieldMetadataType.Select, + ); const { deleteOneRecord } = useDeleteOneRecord({ objectNameSingular }); const { updateOneRecord } = useUpdateOneRecord({ objectNameSingular }); const { createOneRecord } = useCreateOneRecord({ objectNameSingular }); + if (!selectFieldMetadataItem) { + return; + } + return ( { - const { objectMetadataItem } = useObjectMetadataItem({ + const { objectMetadataItem } = useObjectMetadataItemOnly({ objectNameSingular, }); - useLoadRecordIndexBoard(objectNameSingular, recordBoardId); + const { + setColumns, + setObjectSingularName, + getSelectedRecordIdsSelector, + setFieldDefinitions, + getOnFetchMoreVisibilityChangeState, + } = useRecordBoard(recordBoardId); + + const { fetchMoreRecords, loading } = useLoadRecordIndexBoard({ + objectNameSingular, + recordBoardId, + viewBarId, + }); + + const setOnFetchMoreVisibilityChange = useSetRecoilState( + getOnFetchMoreVisibilityChangeState(), + ); + + useEffect(() => { + setOnFetchMoreVisibilityChange(() => () => { + if (!loading) { + fetchMoreRecords?.(); + } + }); + }, [fetchMoreRecords, loading, setOnFetchMoreVisibilityChange]); const navigate = useNavigate(); @@ -31,9 +57,7 @@ export const RecordIndexBoardContainerEffect = ({ navigate(`/settings/objects/${objectMetadataItem.namePlural}`); }, [navigate, objectMetadataItem.namePlural]); - const { setColumns, setObjectSingularName, getSelectedRecordIdsSelector } = - useRecordBoard(recordBoardId); - const { resetRecordSelection } = useResetBoardRecordSelection(recordBoardId); + const { resetRecordSelection } = useRecordBoardSelection(recordBoardId); useEffect(() => { setObjectSingularName(objectNameSingular); @@ -53,6 +77,14 @@ export const RecordIndexBoardContainerEffect = ({ setColumns, ]); + const recordIndexFieldDefinitions = useRecoilValue( + recordIndexFieldDefinitionsState, + ); + + useEffect(() => { + setFieldDefinitions(recordIndexFieldDefinitions); + }, [objectMetadataItem, setFieldDefinitions, recordIndexFieldDefinitions]); + const selectedRecordIds = useRecoilValue(getSelectedRecordIdsSelector()); const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({ diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 9c35cccadc2..35a6ef792a7 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -1,8 +1,7 @@ import { useState } from 'react'; import styled from '@emotion/styled'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilCallback, useSetRecoilState } from 'recoil'; -import { useSpreadsheetCompanyImport } from '@/companies/hooks/useSpreadsheetCompanyImport'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; @@ -11,19 +10,20 @@ import { RecordIndexBoardContainerEffect } from '@/object-record/record-index/co import { RecordIndexTableContainer } from '@/object-record/record-index/components/RecordIndexTableContainer'; import { RecordIndexTableContainerEffect } from '@/object-record/record-index/components/RecordIndexTableContainerEffect'; import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect'; +import { RecordIndexOptionsDropdown } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdown'; +import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; -import { TableOptionsDropdownId } from '@/object-record/record-table/constants/TableOptionsDropdownId'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; -import { TableOptionsDropdown } from '@/object-record/record-table/options/components/TableOptionsDropdown'; -import { useSpreadsheetPersonImport } from '@/people/hooks/useSpreadsheetPersonImport'; import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider'; import { ViewBar } from '@/views/components/ViewBar'; +import { ViewField } from '@/views/types/ViewField'; import { ViewType } from '@/views/types/ViewType'; import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions'; import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; const StyledContainer = styled.div` display: flex; @@ -60,26 +60,38 @@ export const RecordIndexContainer = ({ const { columnDefinitions } = useColumnDefinitionsFromFieldMetadata(objectMetadataItem); - const setRecordIndexFieldDefinitions = useSetRecoilState( - recordIndexFieldDefinitionsState, - ); const setRecordIndexFilters = useSetRecoilState(recordIndexFiltersState); const setRecordIndexSorts = useSetRecoilState(recordIndexSortsState); - const { openPersonSpreadsheetImport } = useSpreadsheetPersonImport(); - const { openCompanySpreadsheetImport } = useSpreadsheetCompanyImport(); - const { setTableFilters, setTableSorts, setTableColumns } = useRecordTable({ recordTableId: recordIndexId, }); - const handleImport = () => { - const openImport = - objectNamePlural === 'companies' - ? openCompanySpreadsheetImport - : openPersonSpreadsheetImport; - openImport(); - }; + const onViewFieldsChange = useRecoilCallback( + ({ set, snapshot }) => + (viewFields: ViewField[]) => { + setTableColumns( + mapViewFieldsToColumnDefinitions(viewFields, columnDefinitions), + ); + + const existingRecordIndexFieldDefinitions = snapshot + .getLoadable(recordIndexFieldDefinitionsState) + .getValue(); + + const newFieldDefinitions = mapViewFieldsToColumnDefinitions( + viewFields, + columnDefinitions, + ); + if ( + !isDeeplyEqual( + existingRecordIndexFieldDefinitions, + newFieldDefinitions, + ) + ) + set(recordIndexFieldDefinitionsState, newFieldDefinitions); + }, + [columnDefinitions, setTableColumns], + ); return ( @@ -87,24 +99,14 @@ export const RecordIndexContainer = ({ } - optionsDropdownScopeId={TableOptionsDropdownId} - onViewFieldsChange={(viewFields) => { - setTableColumns( - mapViewFieldsToColumnDefinitions(viewFields, columnDefinitions), - ); - setRecordIndexFieldDefinitions( - mapViewFieldsToColumnDefinitions(viewFields, columnDefinitions), - ); - }} + optionsDropdownScopeId={RECORD_INDEX_OPTIONS_DROPDOWN_ID} + onViewFieldsChange={onViewFieldsChange} onViewFiltersChange={(viewFilters) => { setTableFilters(mapViewFiltersToFilters(viewFilters)); setRecordIndexFilters(mapViewFiltersToFilters(viewFilters)); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts index f7a394f8ed5..208c48d6117 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts @@ -10,11 +10,19 @@ import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/s import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; +import { useViewBar } from '@/views/hooks/useViewBar'; -export const useLoadRecordIndexBoard = ( - objectNameSingular: string, - recordBoardId: string, -) => { +type UseLoadRecordIndexBoardProps = { + objectNameSingular: string; + viewBarId: string; + recordBoardId: string; +}; + +export const useLoadRecordIndexBoard = ({ + objectNameSingular, + viewBarId, + recordBoardId, +}: UseLoadRecordIndexBoardProps) => { const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); @@ -47,6 +55,10 @@ export const useLoadRecordIndexBoard = ( orderBy, }); + const { setEntityCountInCurrentView } = useViewBar({ + viewBarId, + }); + useEffect(() => { setRecordIdsInBoard(records); }, [records, setRecordIdsInBoard]); @@ -55,6 +67,10 @@ export const useLoadRecordIndexBoard = ( setRecordsInStore(records); }, [records, setRecordsInStore]); + useEffect(() => { + setEntityCountInCurrentView(records.length); + }, [records.length, setEntityCountInCurrentView]); + return { records, loading, diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx new file mode 100644 index 00000000000..8d420f42cd7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx @@ -0,0 +1,38 @@ +import { RecordIndexOptionsDropdownButton } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdownButton'; +import { RecordIndexOptionsDropdownContent } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdownContent'; +import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; +import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { useViewBar } from '@/views/hooks/useViewBar'; +import { ViewType } from '@/views/types/ViewType'; + +type RecordIndexOptionsDropdownProps = { + viewType: ViewType; + objectNameSingular: string; + recordIndexId: string; +}; + +export const RecordIndexOptionsDropdown = ({ + recordIndexId, + objectNameSingular, + viewType, +}: RecordIndexOptionsDropdownProps) => { + const { setViewEditMode } = useViewBar(); + + return ( + } + dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }} + dropdownOffset={{ y: 8 }} + dropdownComponents={ + + } + onClickOutside={() => setViewEditMode('none')} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownButton.tsx similarity index 64% rename from packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownButton.tsx rename to packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownButton.tsx index 11736f506ad..6c47f815fd2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownButton.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownButton.tsx @@ -1,10 +1,10 @@ -import { TableOptionsDropdownId } from '@/object-record/record-table/constants/TableOptionsDropdownId'; +import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -export const TableOptionsDropdownButton = () => { +export const RecordIndexOptionsDropdownButton = () => { const { isDropdownOpen, toggleDropdown } = useDropdown( - TableOptionsDropdownId, + RECORD_INDEX_OPTIONS_DROPDOWN_ID, ); return ( diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx new file mode 100644 index 00000000000..8c1f855970b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx @@ -0,0 +1,198 @@ +import { useRef, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { Key } from 'ts-key-enum'; + +import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; +import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard'; +import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable'; +import { useRecordIndexOptionsImport } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsImport'; +import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; +import { + IconBaselineDensitySmall, + IconChevronLeft, + IconFileImport, + IconTag, +} from '@/ui/display/icon'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; +import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { MenuItemToggle } from '@/ui/navigation/menu-item/components/MenuItemToggle'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; +import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates'; +import { useViewBar } from '@/views/hooks/useViewBar'; +import { ViewType } from '@/views/types/ViewType'; + +type RecordIndexOptionsMenu = 'fields'; + +type RecordIndexOptionsDropdownContentProps = { + recordIndexId: string; + objectNameSingular: string; + viewType: ViewType; +}; + +export const RecordIndexOptionsDropdownContent = ({ + viewType, + recordIndexId, + objectNameSingular, +}: RecordIndexOptionsDropdownContentProps) => { + const { setViewEditMode, handleViewNameSubmit } = useViewBar({ + viewBarId: recordIndexId, + }); + const { viewEditModeState, currentViewSelector } = useViewScopedStates(); + + const viewEditMode = useRecoilValue(viewEditModeState); + const currentView = useRecoilValue(currentViewSelector); + const { closeDropdown } = useDropdown(RECORD_INDEX_OPTIONS_DROPDOWN_ID); + + const [currentMenu, setCurrentMenu] = useState< + RecordIndexOptionsMenu | undefined + >(undefined); + + const resetMenu = () => setCurrentMenu(undefined); + + const viewEditInputRef = useRef(null); + + const handleSelectMenu = (option: RecordIndexOptionsMenu) => { + setCurrentMenu(option); + }; + + useScopedHotkeys( + [Key.Escape], + () => { + closeDropdown(); + }, + TableOptionsHotkeyScope.Dropdown, + ); + + useScopedHotkeys( + Key.Enter, + () => { + const name = viewEditInputRef.current?.value; + handleViewNameSubmit(name); + resetMenu(); + setViewEditMode('none'); + closeDropdown(); + }, + TableOptionsHotkeyScope.Dropdown, + ); + + const { + handleColumnVisibilityChange, + handleReorderColumns, + visibleTableColumns, + hiddenTableColumns, + } = useRecordIndexOptionsForTable(recordIndexId); + + const { + visibleBoardFields, + hiddenBoardFields, + handleReorderBoardFields, + handleBoardFieldVisibilityChange, + isCompactModeActive, + setIsCompactModeActive, + } = useRecordIndexOptionsForBoard({ + objectNameSingular, + recordBoardId: recordIndexId, + viewBarId: recordIndexId, + }); + + const visibleRecordFields = + viewType === ViewType.Kanban ? visibleBoardFields : visibleTableColumns; + + const hiddenRecordFields = + viewType === ViewType.Kanban ? hiddenBoardFields : hiddenTableColumns; + + const handleReorderFields = + viewType === ViewType.Kanban + ? handleReorderBoardFields + : handleReorderColumns; + + const handleChangeFieldVisibility = + viewType === ViewType.Kanban + ? handleBoardFieldVisibilityChange + : handleColumnVisibilityChange; + + const { handleImport } = useRecordIndexOptionsImport({ objectNameSingular }); + + return ( + <> + {!currentMenu && ( + <> + + + + handleSelectMenu('fields')} + LeftIcon={IconTag} + text="Fields" + /> + {handleImport && ( + handleImport()} + LeftIcon={IconFileImport} + text="Import" + /> + )} + + > + )} + {currentMenu === 'fields' && ( + <> + + Fields + + + + {hiddenRecordFields.length > 0 && ( + <> + + + > + )} + > + )} + {viewType === ViewType.Kanban && ( + <> + + + + + > + )} + > + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexBoardOptionsDropdownId.ts b/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexBoardOptionsDropdownId.ts new file mode 100644 index 00000000000..2ccb636a130 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexBoardOptionsDropdownId.ts @@ -0,0 +1,2 @@ +export const RECORD_INDEX_BOARD_OPTIONS_DROPDOWN_ID = + 'record-index-table-options-dropdown-id'; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexOptionsDropdownId.ts b/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexOptionsDropdownId.ts new file mode 100644 index 00000000000..ec21fa258b8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexOptionsDropdownId.ts @@ -0,0 +1,2 @@ +export const RECORD_INDEX_OPTIONS_DROPDOWN_ID = + 'record-index-options-dropdown-id'; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts new file mode 100644 index 00000000000..d4e5d9f73d2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts @@ -0,0 +1,175 @@ +import { useCallback, useMemo } from 'react'; +import { OnDragEndResponder } from '@hello-pangea/dnd'; +import { useRecoilState } from 'recoil'; + +import { mapBoardFieldDefinitionsToViewFields } from '@/companies/utils/mapBoardFieldDefinitionsToViewFields'; +import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; +import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; +import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; +import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; +import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; +import { filterAvailableTableColumns } from '@/object-record/utils/filterAvailableTableColumns'; +import { useViewFields } from '@/views/hooks/internal/useViewFields'; + +type useRecordIndexOptionsForBoardParams = { + objectNameSingular: string; + recordBoardId: string; + viewBarId: string; +}; + +export const useRecordIndexOptionsForBoard = ({ + objectNameSingular, + recordBoardId, + viewBarId, +}: useRecordIndexOptionsForBoardParams) => { + const [recordIndexFieldDefinitions, setRecordIndexFieldDefinitions] = + useRecoilState(recordIndexFieldDefinitionsState); + + const { persistViewFields } = useViewFields(viewBarId); + const { getIsCompactModeActiveState } = useRecordBoard(recordBoardId); + + const [isCompactModeActive, setIsCompactModeActive] = useRecoilState( + getIsCompactModeActiveState(), + ); + + const { objectMetadataItem } = useObjectMetadataItemOnly({ + objectNameSingular, + }); + + const { columnDefinitions: availableColumnDefinitions } = + useColumnDefinitionsFromFieldMetadata(objectMetadataItem); + + // Todo replace this with label identifier logic + const columnDefinitions = availableColumnDefinitions + .filter( + (columnDefinition) => columnDefinition.metadata.fieldName !== 'name', + ) + .filter(filterAvailableTableColumns); + + const visibleBoardFields = useMemo( + () => + columnDefinitions.filter((columnDefinition) => { + return recordIndexFieldDefinitions.some( + (existingRecordFieldDefinition) => { + return ( + columnDefinition.fieldMetadataId === + existingRecordFieldDefinition.fieldMetadataId && + existingRecordFieldDefinition.isVisible + ); + }, + ); + }), + [columnDefinitions, recordIndexFieldDefinitions], + ); + + const hiddenBoardFields = useMemo( + () => + columnDefinitions.filter((columnDefinition) => { + return !recordIndexFieldDefinitions.some( + (existingRecordFieldDefinition) => { + return ( + columnDefinition.fieldMetadataId === + existingRecordFieldDefinition.fieldMetadataId && + existingRecordFieldDefinition.isVisible + ); + }, + ); + }), + [columnDefinitions, recordIndexFieldDefinitions], + ); + + const handleReorderBoardFields: OnDragEndResponder = useCallback( + (result) => { + if ( + !result.destination || + result.destination.index === 1 || + result.source.index === 1 + ) { + return; + } + + const reorderFields = [...recordIndexFieldDefinitions]; + const [removed] = reorderFields.splice(result.source.index - 1, 1); + reorderFields.splice(result.destination.index - 1, 0, removed); + + const updatedFields = reorderFields.map((field, index) => ({ + ...field, + position: index, + })); + + setRecordIndexFieldDefinitions(updatedFields); + persistViewFields(mapBoardFieldDefinitionsToViewFields(updatedFields)); + }, + [ + persistViewFields, + recordIndexFieldDefinitions, + setRecordIndexFieldDefinitions, + ], + ); + + // Todo : this seems over complex and should at least be extracted to an util with unit test. + // Let's refactor this as we introduce the new viewBar + const handleBoardFieldVisibilityChange = useCallback( + async ( + updatedFieldDefinition: Omit< + ColumnDefinition, + 'size' | 'position' + >, + ) => { + const isNewViewField = !recordIndexFieldDefinitions.some( + (fieldDefinition) => + fieldDefinition.fieldMetadataId === + updatedFieldDefinition.fieldMetadataId, + ); + + let updatedFieldsDefinitions: ColumnDefinition[]; + + if (isNewViewField) { + const correspondingFieldDefinition = columnDefinitions.find( + (availableTableColumn) => + availableTableColumn.fieldMetadataId === + updatedFieldDefinition.fieldMetadataId, + ); + if (!correspondingFieldDefinition) return; + + updatedFieldsDefinitions = [ + ...recordIndexFieldDefinitions, + { ...correspondingFieldDefinition, isVisible: true }, + ]; + } else { + updatedFieldsDefinitions = recordIndexFieldDefinitions.map( + (exitingFieldDefinition) => + exitingFieldDefinition.fieldMetadataId === + updatedFieldDefinition.fieldMetadataId + ? { + ...exitingFieldDefinition, + isVisible: !exitingFieldDefinition.isVisible, + } + : exitingFieldDefinition, + ); + } + + setRecordIndexFieldDefinitions(updatedFieldsDefinitions); + + persistViewFields( + mapBoardFieldDefinitionsToViewFields(updatedFieldsDefinitions), + ); + }, + [ + recordIndexFieldDefinitions, + setRecordIndexFieldDefinitions, + persistViewFields, + columnDefinitions, + ], + ); + + return { + handleReorderBoardFields, + handleBoardFieldVisibilityChange, + visibleBoardFields, + hiddenBoardFields, + isCompactModeActive, + setIsCompactModeActive, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts new file mode 100644 index 00000000000..f095e5ca112 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; +import { OnDragEndResponder } from '@hello-pangea/dnd'; +import { useRecoilValue } from 'recoil'; + +import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; +import { useTableColumns } from '@/object-record/record-table/hooks/useTableColumns'; + +export const useRecordIndexOptionsForTable = (recordTableId: string) => { + const { getHiddenTableColumnsSelector, getVisibleTableColumnsSelector } = + useRecordTableStates(recordTableId); + + const hiddenTableColumns = useRecoilValue(getHiddenTableColumnsSelector()); + const visibleTableColumns = useRecoilValue(getVisibleTableColumnsSelector()); + + const { handleColumnVisibilityChange, handleColumnReorder } = useTableColumns( + { recordTableId: recordTableId }, + ); + + const handleReorderColumns: OnDragEndResponder = useCallback( + (result) => { + if ( + !result.destination || + result.destination.index === 1 || + result.source.index === 1 + ) { + return; + } + + const reorderFields = [...visibleTableColumns]; + const [removed] = reorderFields.splice(result.source.index - 1, 1); + reorderFields.splice(result.destination.index - 1, 0, removed); + + handleColumnReorder(reorderFields); + }, + [visibleTableColumns, handleColumnReorder], + ); + + return { + handleReorderColumns, + handleColumnVisibilityChange, + visibleTableColumns, + hiddenTableColumns, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsImport.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsImport.ts new file mode 100644 index 00000000000..6acb8b37661 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsImport.ts @@ -0,0 +1,23 @@ +import { useSpreadsheetCompanyImport } from '@/companies/hooks/useSpreadsheetCompanyImport'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useSpreadsheetPersonImport } from '@/people/hooks/useSpreadsheetPersonImport'; + +type useRecordIndexOptionsImportParams = { + objectNameSingular: string; +}; + +export const useRecordIndexOptionsImport = ({ + objectNameSingular, +}: useRecordIndexOptionsImportParams) => { + const { openPersonSpreadsheetImport } = useSpreadsheetPersonImport(); + const { openCompanySpreadsheetImport } = useSpreadsheetCompanyImport(); + + const handleImport = + CoreObjectNameSingular.Company === objectNameSingular + ? openCompanySpreadsheetImport + : CoreObjectNameSingular.Person === objectNameSingular + ? openPersonSpreadsheetImport + : undefined; + + return { handleImport }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx b/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx index 662a3723e67..d19315ab490 100644 --- a/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx @@ -1,6 +1,6 @@ import { useCallback, useContext } from 'react'; import { Link } from 'react-router-dom'; -import { css } from '@emotion/react'; +import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import qs from 'qs'; import { useRecoilValue } from 'recoil'; @@ -20,6 +20,7 @@ import { RelationPickerScope } from '@/object-record/relation-picker/scopes/Rela import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { IconForbid, IconPlus } from '@/ui/display/icon'; +import { useIcons } from '@/ui/display/icon/hooks/useIcons'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { Card } from '@/ui/layout/card/components/Card'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -79,7 +80,22 @@ const StyledLink = styled(Link)` } `; +const StyledCardNoContent = styled.div` + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${({ theme }) => theme.font.color.light}; + + align-items: center; + justify-content: center; + gap: ${({ theme }) => theme.spacing(2)}; + display: flex; + height: ${({ theme }) => theme.spacing(10)}; + padding: ${({ theme }) => theme.spacing(0, 2)}; +`; + export const RecordRelationFieldCardSection = () => { + const theme = useTheme(); + const { entityId, fieldDefinition } = useContext(FieldContext); const { fieldName, @@ -160,17 +176,21 @@ export const RecordRelationFieldCardSection = () => { relationObjectMetadataItem.namePlural }?${qs.stringify(filterQueryParams)}`; + const { getIcon } = useIcons(); + const Icon = getIcon(relationObjectMetadataItem.icon); + return ( {fieldDefinition.label} {parseFieldRelationType(relationFieldMetadataItem) === - 'TO_ONE_OBJECT' && ( - - All ({relationRecords.length}) - - )} + 'TO_ONE_OBJECT' && + relationRecords.length > 0 && ( + + All ({relationRecords.length}) + + )} { /> + {relationRecords.length === 0 && ( + + + No {relationObjectMetadataItem.labelSingular} + + )} {!!relationRecords.length && ( {relationRecords.slice(0, 5).map((relationRecord, index) => ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyFetchMoreLoader.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyFetchMoreLoader.tsx index 611dfc9fa46..bbd4f911372 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyFetchMoreLoader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyFetchMoreLoader.tsx @@ -1,21 +1,33 @@ +import { useInView } from 'react-intersection-observer'; +import styled from '@emotion/styled'; import { useRecoilCallback, useRecoilValue } from 'recoil'; import { useLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLoadRecordIndexTable'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState'; -import { FetchMoreLoader } from '@/ui/utilities/loading-state/components/FetchMoreLoader'; +import { grayScale } from '@/ui/theme/constants/colors'; type RecordTableBodyFetchMoreLoaderProps = { objectNameSingular: string; }; +const StyledText = styled.div` + align-items: center; + box-shadow: none; + color: ${grayScale.gray40}; + display: flex; + height: 32px; + margin-left: ${({ theme }) => theme.spacing(8)}; + padding-left: ${({ theme }) => theme.spacing(2)}; +`; + export const RecordTableBodyFetchMoreLoader = ({ objectNameSingular, }: RecordTableBodyFetchMoreLoaderProps) => { const { queryStateIdentifier } = useLoadRecordIndexTable(objectNameSingular); const { setRecordTableLastRowVisible } = useRecordTable(); - const isFetchingMoreObjects = useRecoilValue( + const isFetchingMoreRecords = useRecoilValue( isFetchingMoreRecordsFamilyState(queryStateIdentifier), ); @@ -26,10 +38,20 @@ export const RecordTableBodyFetchMoreLoader = ({ [setRecordTableLastRowVisible], ); + const { ref: tbodyRef } = useInView({ + onChange: onLastRowVisible, + }); + return ( - + + {isFetchingMoreRecords && ( + + + Loading more... + + + + )} + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx index 6f74a8aa34f..cb8be2bbca7 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx @@ -1,3 +1,5 @@ +import { Key } from 'ts-key-enum'; + import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; @@ -28,7 +30,7 @@ export const RecordTableInternalEffect = ({ }); useScopedHotkeys( - 'escape', + [Key.Escape], () => { resetTableRowSelection(); }, diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx index 9fd2b1dc9f0..3d1782acd71 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx @@ -18,8 +18,8 @@ type RecordTableRowProps = { rowIndex: number; }; -const StyledPlaceholder = styled.td` - height: 30px; +const StyledTd = styled.td` + background-color: ${({ theme }) => theme.background.primary}; `; export const RecordTableRow = ({ recordId, rowIndex }: RecordTableRowProps) => { @@ -52,31 +52,27 @@ export const RecordTableRow = ({ recordId, rowIndex }: RecordTableRowProps) => { data-testid={`row-id-${recordId}`} data-selectable-id={recordId} > - {inView ? ( - <> - - - - {[...visibleTableColumns] - .sort((columnA, columnB) => columnA.position - columnB.position) - .map((column, columnIndex) => { - return ( - - - - ); - })} - - > - ) : ( - - )} + + + + {[...visibleTableColumns] + .sort((columnA, columnB) => columnA.position - columnB.position) + .map((column, columnIndex) => { + return inView ? ( + + + + ) : ( + + ); + })} + ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx index 079ba4cbbed..e60fd4ba8d0 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx @@ -80,6 +80,8 @@ export const RecordTableWithWrappers = ({ const { deleteOneRecord } = useDeleteOneRecord({ objectNameSingular }); + const objectLabel = foundObjectMetadataItem?.nameSingular; + return ( @@ -112,16 +114,15 @@ export const RecordTableWithWrappers = ({ - Add your first {foundObjectMetadataItem?.namePlural} + Add your first {objectLabel} - Use our API or add your first{' '} - {foundObjectMetadataItem?.namePlural} manually + Use our API or add your first {objectLabel} manually diff --git a/packages/twenty-front/src/modules/object-record/record-table/constants/TableOptionsDropdownId.ts b/packages/twenty-front/src/modules/object-record/record-table/constants/TableOptionsDropdownId.ts deleted file mode 100644 index 57628d11976..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/constants/TableOptionsDropdownId.ts +++ /dev/null @@ -1,2 +0,0 @@ -// We should either apply the constant all caps case or maybe define a more general enum to store those ids ? -export const TableOptionsDropdownId = 'table-options-dropdown-id'; diff --git a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdown.tsx b/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdown.tsx deleted file mode 100644 index 874268a2f0f..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdown.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { useViewBar } from '@/views/hooks/useViewBar'; - -import { TableOptionsDropdownId } from '../../constants/TableOptionsDropdownId'; -import { TableOptionsHotkeyScope } from '../../types/TableOptionsHotkeyScope'; - -import { TableOptionsDropdownButton } from './TableOptionsDropdownButton'; -import { TableOptionsDropdownContent } from './TableOptionsDropdownContent'; - -export const TableOptionsDropdown = ({ - onImport, - recordTableId, -}: { - onImport?: () => void; - recordTableId: string; -}) => { - const { setViewEditMode } = useViewBar(); - - return ( - } - dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }} - dropdownOffset={{ y: 8 }} - dropdownComponents={ - - } - onClickOutside={() => setViewEditMode('none')} - /> - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownContent.tsx deleted file mode 100644 index 94bb6eb15f6..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownContent.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { useCallback, useRef, useState } from 'react'; -import { OnDragEndResponder } from '@hello-pangea/dnd'; -import { useRecoilValue } from 'recoil'; -import { Key } from 'ts-key-enum'; - -import { TableOptionsDropdownId } from '@/object-record/record-table/constants/TableOptionsDropdownId'; -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; -import { IconChevronLeft, IconFileImport, IconTag } from '@/ui/display/icon'; -import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; -import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput'; -import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; -import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; -import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates'; -import { useViewBar } from '@/views/hooks/useViewBar'; - -import { useTableColumns } from '../../hooks/useTableColumns'; -import { TableOptionsHotkeyScope } from '../../types/TableOptionsHotkeyScope'; - -type TableOptionsMenu = 'fields'; - -export const TableOptionsDropdownContent = ({ - onImport, - recordTableId, -}: { - onImport?: () => void; - recordTableId: string; -}) => { - const { setViewEditMode, handleViewNameSubmit } = useViewBar(); - const { viewEditModeState, currentViewSelector } = useViewScopedStates(); - - const viewEditMode = useRecoilValue(viewEditModeState); - const currentView = useRecoilValue(currentViewSelector); - const { closeDropdown } = useDropdown(TableOptionsDropdownId); - - const [currentMenu, setCurrentMenu] = useState( - undefined, - ); - - const viewEditInputRef = useRef(null); - - const { getHiddenTableColumnsSelector, getVisibleTableColumnsSelector } = - useRecordTableStates(recordTableId); - - const hiddenTableColumns = useRecoilValue(getHiddenTableColumnsSelector()); - const visibleTableColumns = useRecoilValue(getVisibleTableColumnsSelector()); - - const { handleColumnVisibilityChange, handleColumnReorder } = useTableColumns( - { recordTableId }, - ); - - const handleSelectMenu = (option: TableOptionsMenu) => { - const name = viewEditInputRef.current?.value; - handleViewNameSubmit(name); - setCurrentMenu(option); - }; - - const handleReorderField: OnDragEndResponder = useCallback( - (result) => { - if ( - !result.destination || - result.destination.index === 1 || - result.source.index === 1 - ) { - return; - } - - const reorderFields = [...visibleTableColumns]; - const [removed] = reorderFields.splice(result.source.index - 1, 1); - reorderFields.splice(result.destination.index - 1, 0, removed); - - handleColumnReorder(reorderFields); - }, - [visibleTableColumns, handleColumnReorder], - ); - - const resetMenu = () => setCurrentMenu(undefined); - - useScopedHotkeys( - Key.Escape, - () => { - closeDropdown(); - }, - TableOptionsHotkeyScope.Dropdown, - ); - - useScopedHotkeys( - Key.Enter, - () => { - const name = viewEditInputRef.current?.value; - handleViewNameSubmit(name); - resetMenu(); - setViewEditMode('none'); - closeDropdown(); - }, - TableOptionsHotkeyScope.Dropdown, - ); - - return ( - <> - {!currentMenu && ( - <> - - - - handleSelectMenu('fields')} - LeftIcon={IconTag} - text="Fields" - /> - {onImport && ( - - )} - - > - )} - {currentMenu === 'fields' && ( - <> - - Fields - - - - {hiddenTableColumns.length > 0 && ( - <> - - - > - )} - > - )} - > - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx index e798f78ea6e..21136c4d53c 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx @@ -43,7 +43,7 @@ export const MultipleObjectRecordSelectItem = ({ avatar={ { onCancel?.(); }, diff --git a/packages/twenty-front/src/modules/object-record/select/components/MultipleRecordSelectDropdown.tsx b/packages/twenty-front/src/modules/object-record/select/components/MultipleRecordSelectDropdown.tsx index 15914204d25..763bd595db7 100644 --- a/packages/twenty-front/src/modules/object-record/select/components/MultipleRecordSelectDropdown.tsx +++ b/packages/twenty-front/src/modules/object-record/select/components/MultipleRecordSelectDropdown.tsx @@ -69,7 +69,7 @@ export const MultipleRecordSelectDropdown = ({ avatar={ (([fieldName, fieldValue]) => { - const fieldDefinition = objectMetadataItem.fields.find( + const fieldMetadataItem = objectMetadataItem.fields.find( (field) => field.name === fieldName, ); - if (!fieldDefinition) return undefined; + if (!fieldMetadataItem) return undefined; if ( - fieldDefinition.type === FieldMetadataType.Relation && + fieldMetadataItem.type === FieldMetadataType.Relation && isFieldRelationValue(fieldValue) ) { - const relationIdFieldName = `${fieldDefinition.name}Id`; - const relationIdFieldDefinition = objectMetadataItem.fields.find( + const relationIdFieldName = `${fieldMetadataItem.name}Id`; + const relationIdFieldMetadataItem = objectMetadataItem.fields.find( (field) => field.name === relationIdFieldName, ); - return relationIdFieldDefinition + return relationIdFieldMetadataItem ? [relationIdFieldName, fieldValue?.id ?? null] : undefined; } diff --git a/packages/twenty-front/src/modules/pipeline/hooks/__mocks__/usePipelineSteps.ts b/packages/twenty-front/src/modules/pipeline/hooks/__mocks__/usePipelineSteps.ts index 7035b73aedc..13e74159659 100644 --- a/packages/twenty-front/src/modules/pipeline/hooks/__mocks__/usePipelineSteps.ts +++ b/packages/twenty-front/src/modules/pipeline/hooks/__mocks__/usePipelineSteps.ts @@ -34,7 +34,7 @@ export const currentPipelineId = 'f088c8c9-05d2-4276-b065-b863cc7d0b33'; const data = { color: 'yellow', - id: 'columnId', + id: mockId, position: 1, name: 'Column Title', }; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountEmailsSkeletonCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountEmailsSkeletonCard.tsx new file mode 100644 index 00000000000..b30fbbec56d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountEmailsSkeletonCard.tsx @@ -0,0 +1,10 @@ +import styled from '@emotion/styled'; + +import { Card } from '@/ui/layout/card/components/Card'; + +const StyledCard = styled(Card)` + background-color: ${({ theme }) => theme.background.secondary}; + height: 40px; +`; + +export const SettingsAccountEmailsSkeletonCard = () => ; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsEmailsSyncSection.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsEmailsSyncSection.tsx index ba6a3a0a46d..198e474203d 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsEmailsSyncSection.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsEmailsSyncSection.tsx @@ -4,6 +4,7 @@ import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; import { MessageChannel } from '@/accounts/types/MessageChannel'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { SettingsAccountEmailsSkeletonCard } from '@/settings/accounts/components/SettingsAccountEmailsSkeletonCard'; import { H2Title } from '@/ui/display/typography/components/H2Title'; import { Section } from '@/ui/layout/section/components/Section'; @@ -13,23 +14,25 @@ import { SettingsAccountsEmptyStateCard } from './SettingsAccountsEmptyStateCard export const SettingsAccountsEmailsSyncSection = () => { const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); - const accounts = useFindManyRecords({ - objectNameSingular: 'connectedAccount', - filter: { - accountOwnerId: { - eq: currentWorkspaceMember?.id, + const { records: accounts, loading: accountsLoading } = + useFindManyRecords({ + objectNameSingular: 'connectedAccount', + filter: { + accountOwnerId: { + eq: currentWorkspaceMember?.id, + }, }, - }, - }).records; + }); - const messageChannels = useFindManyRecords({ - objectNameSingular: 'messageChannel', - filter: { - connectedAccountId: { - in: accounts.map((account) => account.id), + const { records: messageChannels, loading: messageChannelsLoading } = + useFindManyRecords({ + objectNameSingular: 'messageChannel', + filter: { + connectedAccountId: { + in: accounts.map((account) => account.id), + }, }, - }, - }).records; + }); const messageChannelsWithSyncedEmails = messageChannels.map( (messageChannel) => ({ @@ -38,13 +41,18 @@ export const SettingsAccountsEmailsSyncSection = () => { }), ); + const loading = accountsLoading || messageChannelsLoading; + return ( - {accounts.length ? ( + + {loading ? ( + + ) : accounts.length ? ( diff --git a/packages/twenty-front/src/modules/settings/data-model/components/__stories__/SettingsObjectFieldPreview.stories.tsx b/packages/twenty-front/src/modules/settings/data-model/components/__stories__/SettingsObjectFieldPreview.stories.tsx index 0dd1c8d7ae7..4a942727d8c 100644 --- a/packages/twenty-front/src/modules/settings/data-model/components/__stories__/SettingsObjectFieldPreview.stories.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/components/__stories__/SettingsObjectFieldPreview.stories.tsx @@ -4,6 +4,7 @@ import { Meta, StoryObj } from '@storybook/react'; import { Field, FieldMetadataType } from '~/generated-metadata/graphql'; import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; +import { RecordStoreDecorator } from '~/testing/decorators/RecordStoreDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { graphqlMocks } from '~/testing/graphqlMocks'; import { @@ -17,6 +18,7 @@ const meta: Meta = { title: 'Modules/Settings/DataModel/SettingsObjectFieldPreview', component: SettingsObjectFieldPreview, decorators: [ + RecordStoreDecorator, ComponentDecorator, ObjectMetadataItemsDecorator, SnackBarDecorator, @@ -28,6 +30,23 @@ const meta: Meta = { objectMetadataId: mockedCompaniesMetadata.node.id, }, parameters: { + records: [ + { + id: `${mockedCompaniesMetadata.node.id}-field-form`, + domainName: 'Test', + idealCustomerProfile: true, + annualRecurringRevenue: { + amountMicros: 1000000, + currency: 'USD', + }, + updatedAt: '2021-08-05T14:00:00.000Z', + linkedinLink: { + label: 'LinkedIn', + url: 'https://linkedin.com', + }, + employees: 100, + }, + ], msw: graphqlMocks, }, }; diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx index bec44f3b5e5..242f8a6a63f 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx @@ -1,9 +1,9 @@ -import { ReactNode } from 'react'; +import { ReactNode, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { useRelationMetadata } from '@/object-metadata/hooks/useRelationMetadata'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug'; import { FieldIdentifierType } from '@/settings/data-model/types/FieldIdentifierType'; @@ -53,9 +53,13 @@ export const SettingsObjectFieldItemTableRow = ({ const fieldDataTypeIsSupported = fieldMetadataItem.type in settingsFieldMetadataTypes; - const { relationObjectMetadataItem, relationType } = useRelationMetadata({ - fieldMetadataItem, - }); + const getRelationMetadata = useGetRelationMetadata(); + + const { relationObjectMetadataItem, relationType } = + useMemo( + () => getRelationMetadata({ fieldMetadataItem }), + [fieldMetadataItem, getRelationMetadata], + ) ?? {}; if (!fieldDataTypeIsSupported) return null; diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx index 4726fecca9b..941647853c6 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx +++ b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx @@ -1,10 +1,11 @@ import styled from '@emotion/styled'; +import { RecordIndexOptionsDropdown } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdown'; +import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; import { RecordTableWithWrappers } from '@/object-record/record-table/components/RecordTableWithWrappers'; -import { TableOptionsDropdownId } from '@/object-record/record-table/constants/TableOptionsDropdownId'; -import { TableOptionsDropdown } from '@/object-record/record-table/options/components/TableOptionsDropdown'; import { SignInBackgroundMockContainerEffect } from '@/sign-in-background-mock/components/SignInBackgroundMockContainerEffect'; import { ViewBar } from '@/views/components/ViewBar'; +import { ViewType } from '@/views/types/ViewType'; const StyledContainer = styled.div` display: flex; @@ -16,7 +17,7 @@ const StyledContainer = styled.div` export const SignInBackgroundMockContainer = () => { const objectNamePlural = 'companies'; const objectNameSingular = 'company'; - const recordTableId = 'sign-up-mock-record-table-id'; + const recordIndexId = 'sign-up-mock-record-table-id'; const viewBarId = 'companies-mock'; return ( @@ -24,18 +25,22 @@ export const SignInBackgroundMockContainer = () => { + } - optionsDropdownScopeId={TableOptionsDropdownId} + optionsDropdownScopeId={RECORD_INDEX_OPTIONS_DROPDOWN_ID} /> {}} updateRecordMutation={() => {}} diff --git a/packages/twenty-front/src/modules/ui/display/chip/components/EntityChip.tsx b/packages/twenty-front/src/modules/ui/display/chip/components/EntityChip.tsx index e06337cc79e..9d64cba0754 100644 --- a/packages/twenty-front/src/modules/ui/display/chip/components/EntityChip.tsx +++ b/packages/twenty-front/src/modules/ui/display/chip/components/EntityChip.tsx @@ -64,7 +64,7 @@ export const EntityChip = ({ ) : ( theme.spacing(2)}; + width: ${({ theme }) => theme.spacing(6)}; + height: ${({ theme }) => theme.spacing(3)}; + border-radius: ${({ theme }) => theme.border.radius.pill}; + border: 1px solid ${({ theme }) => theme.font.color.tertiary}; + overflow: hidden; +`; + +const StyledLoader = styled(motion.div)` + background-color: ${({ theme }) => theme.font.color.tertiary}; + border-radius: ${({ theme }) => theme.border.radius.pill}; + height: 8px; + width: 8px; +`; + +export const Loader = () => ( + + + +); diff --git a/packages/twenty-front/src/modules/ui/field/input/components/DoubleTextInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/DoubleTextInput.tsx index 9333403956a..3a5b387aa0d 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/DoubleTextInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/DoubleTextInput.tsx @@ -94,7 +94,7 @@ export const DoubleTextInput = ({ ); useScopedHotkeys( - Key.Escape, + [Key.Escape], () => { onEscape({ firstValue: firstInternalValue, diff --git a/packages/twenty-front/src/modules/ui/field/input/components/RatingInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/RatingInput.tsx index 2da9519e8ce..ce16119264b 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/RatingInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/RatingInput.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { RATING_VALUES } from '@/object-record/record-field/meta-types/input/components/RatingFieldInput'; +import { RATING_VALUES } from '@/object-record/record-field/meta-types/constants/RatingValues'; import { FieldRatingValue } from '@/object-record/record-field/types/FieldMetadata'; import { IconTwentyStarFilled } from '@/ui/display/icon/components/IconTwentyStarFilled'; diff --git a/packages/twenty-front/src/modules/ui/field/input/components/TextAreaInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/TextAreaInput.tsx index dd14285bbed..7a354a1383f 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/TextAreaInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/TextAreaInput.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, useRef, useState } from 'react'; +import { ChangeEvent, useEffect, useRef, useState } from 'react'; import TextareaAutosize from 'react-textarea-autosize'; import styled from '@emotion/styled'; @@ -54,6 +54,15 @@ export const TextAreaInput = ({ const wrapperRef = useRef(null); + useEffect(() => { + if (wrapperRef.current) { + wrapperRef.current.setSelectionRange( + wrapperRef.current.value.length, + wrapperRef.current.value.length, + ); + } + }, []); + useRegisterInputEvents({ inputRef: wrapperRef, inputValue: internalText, diff --git a/packages/twenty-front/src/modules/ui/input/button/components/Button.tsx b/packages/twenty-front/src/modules/ui/input/button/components/Button.tsx index 47c80c8020b..0248812b1f4 100644 --- a/packages/twenty-front/src/modules/ui/input/button/components/Button.tsx +++ b/packages/twenty-front/src/modules/ui/input/button/components/Button.tsx @@ -125,7 +125,7 @@ const StyledButton = styled.button< border-color: ${variant === 'secondary' ? !disabled && focus ? theme.color.blue - : theme.background.transparent.light + : theme.background.transparent.medium : focus ? theme.color.blue : 'transparent'}; diff --git a/packages/twenty-front/src/modules/ui/input/button/components/IconButton.tsx b/packages/twenty-front/src/modules/ui/input/button/components/IconButton.tsx index 05f9a0c8c14..38a34b0cfb8 100644 --- a/packages/twenty-front/src/modules/ui/input/button/components/IconButton.tsx +++ b/packages/twenty-front/src/modules/ui/input/button/components/IconButton.tsx @@ -129,7 +129,7 @@ const StyledButton = styled.button< ? `0 0 0 3px ${theme.accent.tertiary}` : 'none'}; color: ${!disabled - ? theme.font.color.secondary + ? theme.font.color.tertiary : theme.font.color.extraLight}; &:hover { background: ${!disabled diff --git a/packages/twenty-front/src/modules/ui/input/components/AutosizeTextInput.tsx b/packages/twenty-front/src/modules/ui/input/components/AutosizeTextInput.tsx index c347c8158b8..95d40a4d031 100644 --- a/packages/twenty-front/src/modules/ui/input/components/AutosizeTextInput.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/AutosizeTextInput.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { HotkeysEvent } from 'react-hotkeys-hook/dist/types'; import TextareaAutosize from 'react-textarea-autosize'; import styled from '@emotion/styled'; +import { Key } from 'ts-key-enum'; import { IconArrowRight } from '@/ui/display/icon/index'; import { Button } from '@/ui/input/button/components/Button'; @@ -151,7 +152,7 @@ export const AutosizeTextInput = ({ ); useScopedHotkeys( - 'esc', + Key.Escape, (event: KeyboardEvent) => { if (!isFocused) { return; diff --git a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/components/AnimatedPlaceholder.tsx b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/components/AnimatedPlaceholder.tsx index 0c8a6691abc..6bb58a61c70 100644 --- a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/components/AnimatedPlaceholder.tsx +++ b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/components/AnimatedPlaceholder.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import styled from '@emotion/styled'; import { motion, useMotionValue, useTransform } from 'framer-motion'; +import { animate } from 'framer-motion'; import { Background, @@ -56,12 +57,27 @@ const AnimatedPlaceholder = ({ type }: AnimatedPlaceholderProps) => { y.set(clientY); }; + const handleLeave = () => { + animate(x, window.innerWidth / 2, { + type: 'spring', + stiffness: 100, + damping: 10, + }); + animate(y, window.innerHeight / 2, { + type: 'spring', + stiffness: 100, + damping: 10, + }); + }; + window.addEventListener('mousemove', handleMove); window.addEventListener('touchmove', handleMove); + window.document.addEventListener('mouseleave', handleLeave); return () => { window.removeEventListener('mousemove', handleMove); window.removeEventListener('touchmove', handleMove); + window.document.removeEventListener('mouseleave', handleLeave); }; }, [x, y]); diff --git a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled.tsx b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled.tsx index 98702693bc4..bbc1dac1e26 100644 --- a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled.tsx +++ b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled.tsx @@ -9,7 +9,6 @@ export const StyledEmptyContainer = styled.div` gap: ${({ theme }) => theme.spacing(6)}; justify-content: center; text-align: center; - margin: ${({ theme }) => theme.spacing(16)} 0px; `; export const StyledEmptyTextContainer = styled.div` @@ -26,7 +25,6 @@ export const StyledEmptyTitle = styled.div` color: ${({ theme }) => theme.font.color.primary}; font-size: ${({ theme }) => theme.font.size.lg}; font-weight: ${({ theme }) => theme.font.weight.semiBold}; - line-height: ${({ theme }) => theme.text.lineHeight.lg}; `; export const StyledEmptySubTitle = styled.div` diff --git a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/AnimatedImages.ts b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/AnimatedImages.ts index 5185a6683d8..00e4836b187 100644 --- a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/AnimatedImages.ts +++ b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/AnimatedImages.ts @@ -5,6 +5,8 @@ export const Background: Record = { noTask: '/images/placeholders/background/no_task_bg.png', errorIndex: '/images/placeholders/background/error_index_bg.png', emptyTimeline: '/images/placeholders/background/empty_timeline_bg.png', + loadingMessages: '/images/placeholders/background/loading_messages_bg.png', + emptyInbox: '/images/placeholders/background/empty_inbox_bg.png', error404: '/images/placeholders/background/404_bg.png', error500: '/images/placeholders/background/500_bg.png', }; @@ -16,6 +18,8 @@ export const MovingImage: Record = { noTask: '/images/placeholders/moving-image/no_task.png', errorIndex: '/images/placeholders/moving-image/error_index.png', emptyTimeline: '/images/placeholders/moving-image/empty_timeline.png', + loadingMessages: '/images/placeholders/moving-image/loading_messages.png', + emptyInbox: '/images/placeholders/moving-image/empty_inbox.png', error404: '/images/placeholders/moving-image/404.png', error500: '/images/placeholders/moving-image/500.png', }; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx index 57f1bc0b424..1169b1587a2 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx @@ -94,7 +94,7 @@ export const Dropdown = ({ }); useScopedHotkeys( - Key.Escape, + [Key.Escape], () => { closeDropdown(); }, diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenu.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenu.tsx index bad0843525c..cee91270190 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenu.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenu.tsx @@ -4,9 +4,8 @@ const StyledDropdownMenu = styled.div<{ disableBlur?: boolean; width?: `${string}px` | 'auto' | number; }>` - backdrop-filter: ${({ disableBlur }) => - disableBlur ? 'none' : 'blur(20px)'}; - background: ${({ theme }) => theme.background.secondary}; + backdrop-filter: ${({ disableBlur }) => (disableBlur ? 'none' : 'blur(8px)')}; + background: ${({ theme }) => theme.background.transparent.secondary}; border: 1px solid ${({ theme }) => theme.border.color.medium}; border-radius: ${({ theme }) => theme.border.radius.md}; diff --git a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx index 7fbf374d81e..b2dd429a249 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import { ReactNode, useMemo } from 'react'; import { css, Global, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { motion } from 'framer-motion'; @@ -15,9 +15,11 @@ import { MobileNavigationBar } from '@/navigation/components/MobileNavigationBar import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage'; import { objectSettingsWidth } from '@/settings/data-model/constants/objectSettings'; import { SignInBackgroundMockPage } from '@/sign-in-background-mock/components/SignInBackgroundMockPage'; +import { AppPath } from '@/types/AppPath'; import { desktopNavDrawerWidths } from '@/ui/navigation/navigation-drawer/constants'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useScreenSize } from '@/ui/utilities/screen-size/hooks/useScreenSize'; +import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; const StyledLayout = styled.div` background: ${({ theme }) => theme.background.noisy}; @@ -71,6 +73,15 @@ export const DefaultLayout = ({ children }: DefaultLayoutProps) => { const isSettingsPage = useIsSettingsPage(); const theme = useTheme(); const widowsWidth = useScreenSize().width; + const isMatchingLocation = useIsMatchingLocation(); + + const showAuthModal = useMemo(() => { + return ( + (onboardingStatus && onboardingStatus !== OnboardingStatus.Completed) || + isMatchingLocation(AppPath.ResetPassword) + ); + }, [isMatchingLocation, onboardingStatus]); + return ( <> { > - {onboardingStatus && - onboardingStatus !== OnboardingStatus.Completed ? ( + {showAuthModal ? ( <> diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx index a8c70dfdd5c..8d5419819bf 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx @@ -66,7 +66,10 @@ export const RightDrawer = () => { useScopedHotkeys( [Key.Escape], - () => closeRightDrawer(), + + () => { + closeRightDrawer(); + }, RightDrawerHotkeyScope.RightDrawer, [setIsRightDrawerOpen], ); @@ -83,11 +86,23 @@ export const RightDrawer = () => { return <>>; } + const variants = { + fullScreen: { + width: '100%', + }, + normal: { + width: rightDrawerWidth, + }, + closed: { + width: 0, + }, + }; + return ( diff --git a/packages/twenty-front/src/modules/ui/navigation/action-bar/components/ActionBar.tsx b/packages/twenty-front/src/modules/ui/navigation/action-bar/components/ActionBar.tsx index d5d1996b3b4..bf5c2586a0a 100644 --- a/packages/twenty-front/src/modules/ui/navigation/action-bar/components/ActionBar.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/action-bar/components/ActionBar.tsx @@ -5,8 +5,6 @@ import { useRecoilValue } from 'recoil'; import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState'; import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState'; -import { actionBarOpenState } from '../states/actionBarIsOpenState'; - import { ActionBarItem } from './ActionBarItem'; const StyledContainerActionBar = styled.div` @@ -30,12 +28,11 @@ const StyledContainerActionBar = styled.div` `; export const ActionBar = () => { - const actionBarOpen = useRecoilValue(actionBarOpenState); const contextMenuIsOpen = useRecoilValue(contextMenuIsOpenState); const actionBarEntries = useRecoilValue(actionBarEntriesState); const wrapperRef = useRef(null); - if (!actionBarOpen || contextMenuIsOpen) { + if (contextMenuIsOpen) { return null; } diff --git a/packages/twenty-front/src/modules/ui/navigation/context-menu/components/ContextMenu.tsx b/packages/twenty-front/src/modules/ui/navigation/context-menu/components/ContextMenu.tsx index 0c1ee250bc7..8a3a1cfd0e8 100644 --- a/packages/twenty-front/src/modules/ui/navigation/context-menu/components/ContextMenu.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/context-menu/components/ContextMenu.tsx @@ -4,7 +4,6 @@ import { useRecoilValue, useSetRecoilState } from 'recoil'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { actionBarOpenState } from '@/ui/navigation/action-bar/states/actionBarIsOpenState'; import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; @@ -42,14 +41,12 @@ export const ContextMenu = () => { const contextMenuIsOpen = useRecoilValue(contextMenuIsOpenState); const contextMenuEntries = useRecoilValue(contextMenuEntriesState); const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState); - const setActionBarOpenState = useSetRecoilState(actionBarOpenState); const wrapperRef = useRef(null); useListenClickOutside({ refs: [wrapperRef], callback: () => { setContextMenuOpenState(false); - setActionBarOpenState(true); }, }); diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/__stories__/NavigationDrawer.stories.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/__stories__/NavigationDrawer.stories.tsx index e33dfe1e928..c7d04d18ff7 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/__stories__/NavigationDrawer.stories.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/__stories__/NavigationDrawer.stories.tsx @@ -114,7 +114,7 @@ export const Submenu: Story = { /> diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useScopedHotkeyCallback.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useScopedHotkeyCallback.ts index 7ec2ad71859..74ea22e95b9 100644 --- a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useScopedHotkeyCallback.ts +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useScopedHotkeyCallback.ts @@ -5,7 +5,7 @@ import { logDebug } from '~/utils/logDebug'; import { internalHotkeysEnabledScopesState } from '../states/internal/internalHotkeysEnabledScopesState'; -const DEBUG_HOTKEY_SCOPE = true; +const DEBUG_HOTKEY_SCOPE = false; export const useScopedHotkeyCallback = () => useRecoilCallback( diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/types/AppHotkeyScope.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/types/AppHotkeyScope.ts index cd063563e5d..9fc1a460ec6 100644 --- a/packages/twenty-front/src/modules/ui/utilities/hotkey/types/AppHotkeyScope.ts +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/types/AppHotkeyScope.ts @@ -4,4 +4,5 @@ export enum AppHotkeyScope { CommandMenu = 'command-menu', CommandMenuOpen = 'command-menu-open', KeyboardShortcutMenu = 'keyboard-shortcut-menu', + KeyboardShortcutMenuOpen = 'keyboard-shortcut-menu-open', } diff --git a/packages/twenty-front/src/modules/users/components/Avatar.tsx b/packages/twenty-front/src/modules/users/components/Avatar.tsx index 12dc109fd54..9097c3ddcea 100644 --- a/packages/twenty-front/src/modules/users/components/Avatar.tsx +++ b/packages/twenty-front/src/modules/users/components/Avatar.tsx @@ -16,21 +16,24 @@ export type AvatarProps = { className?: string; size?: AvatarSize; placeholder: string | undefined; - colorId?: string; + entityId?: string; type?: Nullable; + color?: string; + backgroundColor?: string; onClick?: () => void; }; -const StyledAvatar = styled.div` +export const StyledAvatar = styled.div< + AvatarProps & { color: string; backgroundColor: string } +>` align-items: center; - background-color: ${({ avatarUrl, colorId }) => - !isNonEmptyString(avatarUrl) ? stringToHslColor(colorId, 75, 85) : 'none'}; + background-color: ${({ backgroundColor }) => backgroundColor}; ${({ avatarUrl }) => isNonEmptyString(avatarUrl) ? `background-image: url(${avatarUrl});` : ''} background-position: center; background-size: cover; border-radius: ${(props) => (props.type === 'rounded' ? '50%' : '2px')}; - color: ${({ colorId }) => stringToHslColor(colorId, 75, 25)}; + color: ${({ color }) => color}; cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')}; display: flex; @@ -95,9 +98,11 @@ export const Avatar = ({ className, size = 'md', placeholder, - colorId = placeholder, + entityId = placeholder, onClick, type = 'squared', + color, + backgroundColor, }: AvatarProps) => { const noAvatarUrl = !isNonEmptyString(avatarUrl); const [isInvalidAvatarUrl, setIsInvalidAvatarUrl] = useState(false); @@ -114,6 +119,13 @@ export const Avatar = ({ } }, [avatarUrl]); + const fixedColor = color ?? stringToHslColor(entityId ?? '', 75, 25); + const fixedBackgroundColor = + backgroundColor ?? + (!isNonEmptyString(avatarUrl) + ? stringToHslColor(entityId ?? '', 75, 85) + : 'none'); + return ( {(noAvatarUrl || isInvalidAvatarUrl) && placeholder?.[0]?.toLocaleUpperCase()} diff --git a/packages/twenty-front/src/modules/views/hooks/useViewBar.ts b/packages/twenty-front/src/modules/views/hooks/useViewBar.ts index a4b63545797..2111217a2e5 100644 --- a/packages/twenty-front/src/modules/views/hooks/useViewBar.ts +++ b/packages/twenty-front/src/modules/views/hooks/useViewBar.ts @@ -127,8 +127,9 @@ export const useViewBar = (props?: UseViewProps) => { if (!isDeeplyEqual(savedViewFields, queriedViewFields)) { set(currentViewFieldsState, queriedViewFields); set(savedViewFieldsState, queriedViewFields); - onViewFieldsChange?.(queriedViewFields); } + + onViewFieldsChange?.(queriedViewFields); }, [scopeId], ); diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceMemberCard.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceMemberCard.tsx index 3c5e3acab9b..c6529442a6e 100644 --- a/packages/twenty-front/src/modules/workspace/components/WorkspaceMemberCard.tsx +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceMemberCard.tsx @@ -40,7 +40,7 @@ export const WorkspaceMemberCard = ({ ; export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByText('Page not found'); + await canvas.findByText('Off the beaten path'); }, }; diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx index 853b5345326..e8f1a681e6f 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx @@ -1,9 +1,9 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings'; -import { useRelationMetadata } from '@/object-metadata/hooks/useRelationMetadata'; import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug'; import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; @@ -43,11 +43,21 @@ export const SettingsObjectFieldEdit = () => { metadataField.isActive && getFieldSlug(metadataField) === fieldSlug, ); + const getRelationMetadata = useGetRelationMetadata(); const { relationFieldMetadataItem, relationObjectMetadataItem, relationType, - } = useRelationMetadata({ fieldMetadataItem: activeMetadataField }); + } = + useMemo( + () => + activeMetadataField + ? getRelationMetadata({ + fieldMetadataItem: activeMetadataField, + }) + : null, + [activeMetadataField, getRelationMetadata], + ) ?? {}; const { formValues, diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx index 463249886ce..d7bb1aedf5e 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx @@ -23,7 +23,6 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer' import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { View } from '@/views/types/View'; import { ViewType } from '@/views/types/ViewType'; -import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { FieldMetadataType } from '~/generated-metadata/graphql'; export const SettingsObjectNewFieldStep2 = () => { @@ -41,10 +40,6 @@ export const SettingsObjectNewFieldStep2 = () => { findActiveObjectMetadataItemBySlug(objectSlug); const { createMetadataField } = useFieldMetadataItem(); - const isRatingFieldTypeEnabled = useIsFeatureEnabled( - 'IS_RATING_FIELD_TYPE_ENABLED', - ); - const { formValues, handleFormChange, @@ -265,10 +260,6 @@ export const SettingsObjectNewFieldStep2 = () => { FieldMetadataType.Uuid, ]; - if (!isRatingFieldTypeEnabled) { - excludedFieldTypes.push(FieldMetadataType.Rating); - } - return ( diff --git a/packages/twenty-front/src/testing/decorators/RecordStoreDecorator.tsx b/packages/twenty-front/src/testing/decorators/RecordStoreDecorator.tsx new file mode 100644 index 00000000000..96fcda4ab44 --- /dev/null +++ b/packages/twenty-front/src/testing/decorators/RecordStoreDecorator.tsx @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; +import { Decorator } from '@storybook/react'; + +import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; + +export const RecordStoreDecorator: Decorator = (Story, context) => { + const { records } = context.parameters; + + const { setRecords } = useSetRecordInStore(); + + useEffect(() => { + setRecords(records); + }); + + return ; +}; diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index 88ed5893351..169008165c9 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -1,6 +1,6 @@ { "name": "twenty-server", - "version": "0.2.3", + "version": "0.3.0", "description": "", "author": "", "private": true, @@ -57,8 +57,9 @@ "@ptc-org/nestjs-query-typeorm": "4.2.1-alpha.2", "@react-email/components": "0.0.12", "@react-email/render": "0.0.10", - "@sentry/node": "^7.66.0", + "@sentry/node": "^7.98.0", "@sentry/profiling-node": "^1.3.4", + "@sentry/tracing": "^7.98.0", "axios": "^1.6.2", "bcrypt": "^5.1.1", "bullmq": "^4.14.0", @@ -78,6 +79,7 @@ "lodash.isempty": "^4.4.0", "lodash.isobject": "^3.0.2", "lodash.kebabcase": "^4.1.1", + "lodash.omit": "^4.5.0", "lodash.snakecase": "^4.1.1", "lodash.upperfirst": "^4.3.1", "mailparser": "^3.6.5", @@ -101,6 +103,7 @@ "devDependencies": { "@types/lodash.isempty": "^4.4.7", "@types/lodash.isobject": "^3.0.7", + "@types/lodash.omit": "^4.5.9", "@types/lodash.snakecase": "^4.1.7", "@types/lodash.upperfirst": "^4.3.7", "@types/react": "^18.2.39", @@ -120,4 +123,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/twenty-server/src/command.module.ts b/packages/twenty-server/src/command.module.ts index 9765e70b51d..a99241a8d63 100644 --- a/packages/twenty-server/src/command.module.ts +++ b/packages/twenty-server/src/command.module.ts @@ -10,6 +10,7 @@ import { WorkspaceHealthCommandModule } from 'src/workspace/workspace-health/com import { AppModule } from './app.module'; import { WorkspaceSyncMetadataCommandsModule } from './workspace/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module'; +import { WorkspaceMigrationRunnerCommandsModule } from './workspace/workspace-migration-runner/commands/workspace-sync-metadata-commands.module'; @Module({ imports: [ @@ -21,6 +22,7 @@ import { WorkspaceSyncMetadataCommandsModule } from './workspace/workspace-sync- StopCleanInactiveWorkspacesCronCommand, CleanInactiveWorkspacesCommand, WorkspaceHealthCommandModule, + WorkspaceMigrationRunnerCommandsModule, ], }) export class CommandModule {} diff --git a/packages/twenty-server/src/command.ts b/packages/twenty-server/src/command.ts index ffee901dea4..bbc07bec9d5 100644 --- a/packages/twenty-server/src/command.ts +++ b/packages/twenty-server/src/command.ts @@ -2,8 +2,34 @@ import { CommandFactory } from 'nest-commander'; import { CommandModule } from './command.module'; +import { LoggerService } from './integrations/logger/logger.service'; +import { ExceptionHandlerService } from './integrations/exception-handler/exception-handler.service'; +import { filterException } from './filters/utils/global-exception-handler.util'; + async function bootstrap() { - // TODO: inject our own logger service to handle the output (Sentry, etc.) - await CommandFactory.run(CommandModule, ['warn', 'error', 'log']); + const errorHandler = (err: Error) => { + loggerService.error(err?.message, err?.name); + + if (filterException(err)) { + return; + } + + exceptionHandlerService.captureExceptions([err]); + }; + + const app = await CommandFactory.createWithoutRunning(CommandModule, { + bufferLogs: true, + errorHandler, + serviceErrorHandler: errorHandler, + }); + const loggerService = app.get(LoggerService); + const exceptionHandlerService = app.get(ExceptionHandlerService); + + // Inject our logger + app.useLogger(loggerService); + + await CommandFactory.runApplication(app); + + app.close(); } bootstrap(); diff --git a/packages/twenty-server/src/core/analytics/analytics.resolver.ts b/packages/twenty-server/src/core/analytics/analytics.resolver.ts index c912dd52ca4..880708b3c18 100644 --- a/packages/twenty-server/src/core/analytics/analytics.resolver.ts +++ b/packages/twenty-server/src/core/analytics/analytics.resolver.ts @@ -2,8 +2,8 @@ import { Resolver, Mutation, Args } from '@nestjs/graphql'; import { UseGuards } from '@nestjs/common'; import { OptionalJwtAuthGuard } from 'src/guards/optional-jwt.auth.guard'; -import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; -import { AuthUser } from 'src/decorators/auth-user.decorator'; +import { AuthWorkspace } from 'src/decorators/auth/auth-workspace.decorator'; +import { AuthUser } from 'src/decorators/auth/auth-user.decorator'; import { Workspace } from 'src/core/workspace/workspace.entity'; import { User } from 'src/core/user/user.entity'; diff --git a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory.ts b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory.ts index df8360cb9fd..2fa25abfe1e 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory.ts +++ b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory.ts @@ -40,7 +40,7 @@ export class ApiRestQueryBuilderFactory { objectMetadataItems: ObjectMetadataEntity[]; objectMetadataItem: ObjectMetadataEntity; }> { - const workspace = await this.tokenService.validateToken(request); + const { workspace } = await this.tokenService.validateToken(request); const objectMetadataItems = await this.objectMetadataService.findManyWithinWorkspace(workspace.id); diff --git a/packages/twenty-server/src/core/auth/auth.resolver.ts b/packages/twenty-server/src/core/auth/auth.resolver.ts index e42b59d3530..91fd56667ae 100644 --- a/packages/twenty-server/src/core/auth/auth.resolver.ts +++ b/packages/twenty-server/src/core/auth/auth.resolver.ts @@ -11,10 +11,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; -import { AuthUser } from 'src/decorators/auth-user.decorator'; +import { AuthUser } from 'src/decorators/auth/auth-user.decorator'; import { assert } from 'src/utils/assert'; import { Workspace } from 'src/core/workspace/workspace.entity'; -import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; +import { AuthWorkspace } from 'src/decorators/auth/auth-workspace.decorator'; import { User } from 'src/core/user/user.entity'; import { ApiKeyTokenInput } from 'src/core/auth/dto/api-key-token.input'; import { ValidatePasswordResetToken } from 'src/core/auth/dto/validate-password-reset-token.entity'; diff --git a/packages/twenty-server/src/core/auth/services/token.service.ts b/packages/twenty-server/src/core/auth/services/token.service.ts index 8fbc41bfb0e..96a87d7bf24 100644 --- a/packages/twenty-server/src/core/auth/services/token.service.ts +++ b/packages/twenty-server/src/core/auth/services/token.service.ts @@ -192,7 +192,10 @@ export class TokenService { return !!token; } - async validateToken(request: Request): Promise { + async validateToken(request: Request): Promise<{ + user?: User; + workspace: Workspace; + }> { const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); if (!token) { @@ -203,11 +206,11 @@ export class TokenService { this.environmentService.getAccessTokenSecret(), ); - const { workspace } = await this.jwtStrategy.validate( + const { user, workspace } = await this.jwtStrategy.validate( decoded as JwtPayload, ); - return workspace; + return { user, workspace }; } async verifyLoginToken(loginToken: string): Promise { diff --git a/packages/twenty-server/src/core/feature-flag/feature-flag.entity.ts b/packages/twenty-server/src/core/feature-flag/feature-flag.entity.ts index 91664dacf0f..5e579a452ab 100644 --- a/packages/twenty-server/src/core/feature-flag/feature-flag.entity.ts +++ b/packages/twenty-server/src/core/feature-flag/feature-flag.entity.ts @@ -15,7 +15,6 @@ import { Workspace } from 'src/core/workspace/workspace.entity'; export enum FeatureFlagKeys { IsMessagingEnabled = 'IS_MESSAGING_ENABLED', - IsRatingFieldTypeEnabled = 'IS_RATING_FIELD_TYPE_ENABLED', IsWorkspaceCleanable = 'IS_WORKSPACE_CLEANABLE', IsNewRecordBoardEnabled = 'IS_NEW_RECORD_BOARD_ENABLED', } diff --git a/packages/twenty-server/src/core/feature-flag/interfaces/feature-flag-map.interface.ts b/packages/twenty-server/src/core/feature-flag/interfaces/feature-flag-map.interface.ts new file mode 100644 index 00000000000..9ec6c9b73e3 --- /dev/null +++ b/packages/twenty-server/src/core/feature-flag/interfaces/feature-flag-map.interface.ts @@ -0,0 +1,3 @@ +import { FeatureFlagKeys } from 'src/core/feature-flag/feature-flag.entity'; + +export type FeatureFlagMap = Record<`${FeatureFlagKeys}`, boolean>; diff --git a/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts b/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts index ef0199cb976..edb5a06d365 100644 --- a/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts +++ b/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts @@ -13,7 +13,7 @@ import { Max } from 'class-validator'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { Workspace } from 'src/core/workspace/workspace.entity'; -import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; +import { AuthWorkspace } from 'src/decorators/auth/auth-workspace.decorator'; import { TimelineMessagingService } from 'src/core/messaging/timeline-messaging.service'; import { TIMELINE_THREADS_MAX_PAGE_SIZE } from 'src/core/messaging/constants/messaging.constants'; import { TimelineThreadsWithTotal } from 'src/core/messaging/dtos/timeline-threads-with-total.dto'; diff --git a/packages/twenty-server/src/core/open-api/open-api.service.ts b/packages/twenty-server/src/core/open-api/open-api.service.ts index 80409e429cd..c9426c0ee05 100644 --- a/packages/twenty-server/src/core/open-api/open-api.service.ts +++ b/packages/twenty-server/src/core/open-api/open-api.service.ts @@ -30,7 +30,7 @@ export class OpenApiService { let objectMetadataItems; try { - const workspace = await this.tokenService.validateToken(request); + const { workspace } = await this.tokenService.validateToken(request); objectMetadataItems = await this.objectMetadataService.findManyWithinWorkspace(workspace.id); diff --git a/packages/twenty-server/src/core/user/user.resolver.ts b/packages/twenty-server/src/core/user/user.resolver.ts index 680efdc3a73..d936d68d671 100644 --- a/packages/twenty-server/src/core/user/user.resolver.ts +++ b/packages/twenty-server/src/core/user/user.resolver.ts @@ -15,7 +15,7 @@ import { FileUpload, GraphQLUpload } from 'graphql-upload'; import { SupportDriver } from 'src/integrations/environment/interfaces/support.interface'; import { FileFolder } from 'src/core/file/interfaces/file-folder.interface'; -import { AuthUser } from 'src/decorators/auth-user.decorator'; +import { AuthUser } from 'src/decorators/auth/auth-user.decorator'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; import { FileUploadService } from 'src/core/file/services/file-upload.service'; diff --git a/packages/twenty-server/src/core/workspace/workspace.resolver.ts b/packages/twenty-server/src/core/workspace/workspace.resolver.ts index 383097f803b..00e415b29bc 100644 --- a/packages/twenty-server/src/core/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/core/workspace/workspace.resolver.ts @@ -7,7 +7,7 @@ import { FileFolder } from 'src/core/file/interfaces/file-folder.interface'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; import { FileUploadService } from 'src/core/file/services/file-upload.service'; -import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; +import { AuthWorkspace } from 'src/decorators/auth/auth-workspace.decorator'; import { assert } from 'src/utils/assert'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { UpdateWorkspaceInput } from 'src/core/workspace/dtos/update-workspace-input'; diff --git a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts index 24c4e90e8b8..b2aa57ecd58 100644 --- a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts +++ b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts @@ -11,7 +11,7 @@ import { seedWorkspaceMember } from 'src/database/typeorm-seeds/workspace/worksp import { seedPeople } from 'src/database/typeorm-seeds/workspace/people'; import { seedCoreSchema } from 'src/database/typeorm-seeds/core'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; -import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync.metadata.service'; +import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync-metadata.service'; import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service'; @@ -62,8 +62,10 @@ export class DataSeedWorkspaceCommand extends CommandRunner { ); await this.workspaceSyncMetadataService.syncStandardObjectsAndFieldsMetadata( - dataSourceMetadata.id, - this.workspaceId, + { + workspaceId: this.workspaceId, + dataSourceId: dataSourceMetadata.id, + }, ); } catch (error) { console.error(error); diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/demo/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/demo/feature-flags.ts index 26cda441952..b0bffd176d6 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/demo/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/demo/feature-flags.ts @@ -16,7 +16,7 @@ export const seedFeatureFlags = async ( .orIgnore() .values([ { - key: FeatureFlagKeys.IsRatingFieldTypeEnabled, + key: FeatureFlagKeys.IsNewRecordBoardEnabled, workspaceId: workspaceId, value: false, }, diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index c496562a670..3d992d6fa7f 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -21,12 +21,12 @@ export const seedFeatureFlags = async ( value: true, }, { - key: FeatureFlagKeys.IsRatingFieldTypeEnabled, + key: FeatureFlagKeys.IsWorkspaceCleanable, workspaceId: workspaceId, value: true, }, { - key: FeatureFlagKeys.IsWorkspaceCleanable, + key: FeatureFlagKeys.IsNewRecordBoardEnabled, workspaceId: workspaceId, value: true, }, diff --git a/packages/twenty-server/src/database/typeorm-seeds/workspace/opportunity.ts b/packages/twenty-server/src/database/typeorm-seeds/workspace/opportunity.ts index 6a3626e011d..13b968e5569 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/workspace/opportunity.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/workspace/opportunity.ts @@ -17,6 +17,7 @@ export const seedOpportunity = async ( 'closeDate', 'probability', 'stage', + 'recordPosition', 'pipelineStepId', 'pointOfContactId', 'companyId', @@ -31,6 +32,7 @@ export const seedOpportunity = async ( closeDate: new Date(), probability: 0.5, stage: 'new', + recordPosition: 0, pipelineStepId: '6edf4ead-006a-46e1-9c6d-228f1d0143c9', pointOfContactId: '86083141-1c0e-494c-a1b6-85b1c6fefaa5', companyId: 'fe256b39-3ec3-4fe3-8997-b76aa0bfa408', @@ -43,6 +45,7 @@ export const seedOpportunity = async ( closeDate: new Date(), probability: 0.5, stage: 'meeting', + recordPosition: 0, pipelineStepId: 'd8361722-03fb-4e65-bd4f-ec9e52e5ec0a', pointOfContactId: '93c72d2e-f517-42fd-80ae-14173b3b70ae', companyId: '118995f3-5d81-46d6-bf83-f7fd33ea6102', @@ -55,6 +58,7 @@ export const seedOpportunity = async ( closeDate: new Date(), probability: 0.5, stage: 'proposal', + recordPosition: 0, pipelineStepId: '30b14887-d592-427d-bd97-6e670158db02', pointOfContactId: '9b324a88-6784-4449-afdf-dc62cb8702f2', companyId: '460b6fb1-ed89-413a-b31a-962986e67bb4', @@ -67,6 +71,7 @@ export const seedOpportunity = async ( closeDate: new Date(), probability: 0.5, stage: 'proposal', + recordPosition: 1, pipelineStepId: '30b14887-d592-427d-bd97-6e670158db02', pointOfContactId: '98406e26-80f1-4dff-b570-a74942528de3', companyId: '460b6fb1-ed89-413a-b31a-962986e67bb4', diff --git a/packages/twenty-server/src/decorators/user-ability.decorator.ts b/packages/twenty-server/src/decorators/ability/user-ability.decorator.ts similarity index 100% rename from packages/twenty-server/src/decorators/user-ability.decorator.ts rename to packages/twenty-server/src/decorators/ability/user-ability.decorator.ts diff --git a/packages/twenty-server/src/decorators/auth-user.decorator.ts b/packages/twenty-server/src/decorators/auth/auth-user.decorator.ts similarity index 100% rename from packages/twenty-server/src/decorators/auth-user.decorator.ts rename to packages/twenty-server/src/decorators/auth/auth-user.decorator.ts diff --git a/packages/twenty-server/src/decorators/auth-workspace.decorator.ts b/packages/twenty-server/src/decorators/auth/auth-workspace.decorator.ts similarity index 100% rename from packages/twenty-server/src/decorators/auth-workspace.decorator.ts rename to packages/twenty-server/src/decorators/auth/auth-workspace.decorator.ts diff --git a/packages/twenty-server/src/filters/utils/global-exception-handler.util.ts b/packages/twenty-server/src/filters/utils/global-exception-handler.util.ts index 4c4d05fd67a..d57619fe0fb 100644 --- a/packages/twenty-server/src/filters/utils/global-exception-handler.util.ts +++ b/packages/twenty-server/src/filters/utils/global-exception-handler.util.ts @@ -1,5 +1,7 @@ import { HttpException } from '@nestjs/common'; +import { ExceptionHandlerUser } from 'src/integrations/exception-handler/interfaces/exception-handler-user.interface'; + import { AuthenticationError, BaseGraphQLError, @@ -21,20 +23,31 @@ const graphQLPredefinedExceptions = { export const handleExceptionAndConvertToGraphQLError = ( exception: Error, exceptionHandlerService: ExceptionHandlerService, + user?: ExceptionHandlerUser, ): BaseGraphQLError => { - handleException(exception, exceptionHandlerService); + handleException(exception, exceptionHandlerService, user); return convertExceptionToGraphQLError(exception); }; +export const filterException = (exception: Error): boolean => { + if (exception instanceof HttpException && exception.getStatus() < 500) { + return true; + } + + return false; +}; + export const handleException = ( exception: Error, exceptionHandlerService: ExceptionHandlerService, + user?: ExceptionHandlerUser, ): void => { - if (exception instanceof HttpException && exception.getStatus() < 500) { + if (filterException(exception)) { return; } - exceptionHandlerService.captureException(exception); + + exceptionHandlerService.captureExceptions([exception], { user }); }; export const convertExceptionToGraphQLError = ( @@ -52,9 +65,9 @@ export const convertHttpExceptionToGraphql = (exception: HttpException) => { let error: BaseGraphQLError; if (status in graphQLPredefinedExceptions) { - error = new graphQLPredefinedExceptions[exception.getStatus()]( - exception.message, - ); + const message = exception.getResponse()['message'] ?? exception.message; + + error = new graphQLPredefinedExceptions[exception.getStatus()](message); } else { error = new BaseGraphQLError( 'Internal Server Error', @@ -62,8 +75,11 @@ export const convertHttpExceptionToGraphql = (exception: HttpException) => { ); } - error.stack = exception.stack; - error.extensions['response'] = exception.getResponse(); + // Only show the stack trace in development mode + if (process.env.NODE_ENV === 'development') { + error.stack = exception.stack; + error.extensions['response'] = exception.getResponse(); + } return error; }; diff --git a/packages/twenty-server/src/graphql-config.service.ts b/packages/twenty-server/src/graphql-config.service.ts index aa543c8e4ce..46d62c1a829 100644 --- a/packages/twenty-server/src/graphql-config.service.ts +++ b/packages/twenty-server/src/graphql-config.service.ts @@ -9,11 +9,7 @@ import { import { GraphQLSchema, GraphQLError } from 'graphql'; import GraphQLJSON from 'graphql-type-json'; import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; -import { - GraphQLSchemaWithContext, - YogaInitialContext, - maskError, -} from 'graphql-yoga'; +import { GraphQLSchemaWithContext, YogaInitialContext } from 'graphql-yoga'; import { TokenService } from 'src/core/auth/services/token.service'; import { CoreModule } from 'src/core/core.module'; @@ -24,6 +20,9 @@ import { handleExceptionAndConvertToGraphQLError } from 'src/filters/utils/globa import { renderApolloPlayground } from 'src/workspace/utils/render-apollo-playground.util'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { useExceptionHandler } from './integrations/exception-handler/hooks/use-exception-handler.hook'; +import { User } from './core/user/user.entity'; + @Injectable() export class GraphQLConfigService implements GqlOptionsFactory> @@ -36,33 +35,24 @@ export class GraphQLConfigService ) {} createGqlOptions(): YogaDriverConfig { - const exceptionHandlerService = this.exceptionHandlerService; const isDebugMode = this.environmentService.isDebugMode(); const config: YogaDriverConfig = { context: ({ req }) => ({ req }), autoSchemaFile: true, include: [CoreModule], - maskedErrors: { - maskError(error: GraphQLError, message, isDev) { - if (error.originalError) { - return handleExceptionAndConvertToGraphQLError( - error.originalError, - exceptionHandlerService, - ); - } - - return maskError(error, message, isDev); - }, - }, conditionalSchema: async (context) => { + let user: User | undefined; + try { if (!this.tokenService.isTokenPresent(context.req)) { return new GraphQLSchema({}); } - const workspace = await this.tokenService.validateToken(context.req); + const data = await this.tokenService.validateToken(context.req); - return await this.createSchema(context, workspace); + user = data.user; + + return await this.createSchema(context, data.workspace); } catch (error) { if (error instanceof UnauthorizedException) { throw new GraphQLError('Unauthenticated', { @@ -92,11 +82,22 @@ export class GraphQLConfigService throw handleExceptionAndConvertToGraphQLError( error, this.exceptionHandlerService, + user + ? { + id: user.id, + email: user.email, + } + : undefined, ); } }, resolvers: { JSON: GraphQLJSON }, - plugins: [], + plugins: [ + useExceptionHandler({ + exceptionHandlerService: this.exceptionHandlerService, + tokenService: this.tokenService, + }), + ], }; if (isDebugMode) { diff --git a/packages/twenty-server/src/integrations/environment/environment.validation.ts b/packages/twenty-server/src/integrations/environment/environment.validation.ts index c712632f2a2..ba2c3d59b81 100644 --- a/packages/twenty-server/src/integrations/environment/environment.validation.ts +++ b/packages/twenty-server/src/integrations/environment/environment.validation.ts @@ -64,7 +64,11 @@ export class EnvironmentVariables { PORT: number; // Database - @IsUrl({ protocols: ['postgres'], require_tld: false, allow_underscores: true }) + @IsUrl({ + protocols: ['postgres'], + require_tld: false, + allow_underscores: true, + }) PG_DATABASE_URL: string; // Frontend URL diff --git a/packages/twenty-server/src/integrations/exception-handler/drivers/console.driver.ts b/packages/twenty-server/src/integrations/exception-handler/drivers/console.driver.ts index 4dfbf4d1720..cd38ef827a0 100644 --- a/packages/twenty-server/src/integrations/exception-handler/drivers/console.driver.ts +++ b/packages/twenty-server/src/integrations/exception-handler/drivers/console.driver.ts @@ -1,16 +1,26 @@ +import { ExceptionHandlerUser } from 'src/integrations/exception-handler/interfaces/exception-handler-user.interface'; +import { ExceptionHandlerOptions } from 'src/integrations/exception-handler/interfaces/exception-handler-options.interface'; + import { ExceptionHandlerDriverInterface } from 'src/integrations/exception-handler/interfaces'; export class ExceptionHandlerConsoleDriver implements ExceptionHandlerDriverInterface { - captureException(exception: unknown) { + captureExceptions( + exceptions: ReadonlyArray, + options?: ExceptionHandlerOptions, + ) { console.group('Exception Captured'); - console.error(exception); + console.info(options); + console.error(exceptions); console.groupEnd(); + + return []; } - captureMessage(message: string): void { + captureMessage(message: string, user?: ExceptionHandlerUser): void { console.group('Message Captured'); + console.info(user); console.info(message); console.groupEnd(); } diff --git a/packages/twenty-server/src/integrations/exception-handler/drivers/sentry.driver.ts b/packages/twenty-server/src/integrations/exception-handler/drivers/sentry.driver.ts index b274910e85b..a843fb27b9c 100644 --- a/packages/twenty-server/src/integrations/exception-handler/drivers/sentry.driver.ts +++ b/packages/twenty-server/src/integrations/exception-handler/drivers/sentry.driver.ts @@ -1,6 +1,9 @@ import * as Sentry from '@sentry/node'; import { ProfilingIntegration } from '@sentry/profiling-node'; +import { ExceptionHandlerUser } from 'src/integrations/exception-handler/interfaces/exception-handler-user.interface'; +import { ExceptionHandlerOptions } from 'src/integrations/exception-handler/interfaces/exception-handler-options.interface'; + import { ExceptionHandlerDriverInterface, ExceptionHandlerSentryDriverFactoryOptions, @@ -30,11 +33,78 @@ export class ExceptionHandlerSentryDriver }); } - captureException(exception: Error) { - Sentry.captureException(exception); + captureExceptions( + exceptions: ReadonlyArray, + options?: ExceptionHandlerOptions, + ) { + const eventIds: string[] = []; + + Sentry.withScope((scope) => { + if (options?.operation) { + scope.setTag('operation', options.operation.name); + scope.setTag('operationName', options.operation.name); + } + + if (options?.document) { + scope.setExtra('document', options.document); + } + + if (options?.user) { + scope.setUser({ + id: options.user.id, + ip_address: options.user.ipAddress, + email: options.user.email, + username: options.user.username, + }); + } + + for (const exception of exceptions) { + const errorPath = (exception.path ?? []) + .map((v: string | number) => (typeof v === 'number' ? '$index' : v)) + .join(' > '); + + if (errorPath) { + scope.addBreadcrumb({ + category: 'execution-path', + message: errorPath, + level: 'debug', + }); + } + + const eventId = Sentry.captureException(exception, { + fingerprint: [ + 'graphql', + errorPath, + options?.operation?.name, + options?.operation?.type, + ], + contexts: { + GraphQL: { + operationName: options?.operation?.name, + operationType: options?.operation?.type, + }, + }, + }); + + eventIds.push(eventId); + } + }); + + return eventIds; } - captureMessage(message: string) { - Sentry.captureMessage(message); + captureMessage(message: string, user?: ExceptionHandlerUser) { + Sentry.captureMessage(message, (scope) => { + if (user) { + scope.setUser({ + id: user.id, + ip_address: user.ipAddress, + email: user.email, + username: user.username, + }); + } + + return scope; + }); } } diff --git a/packages/twenty-server/src/integrations/exception-handler/exception-handler.service.ts b/packages/twenty-server/src/integrations/exception-handler/exception-handler.service.ts index d1a14e3c4f5..3dc02cad828 100644 --- a/packages/twenty-server/src/integrations/exception-handler/exception-handler.service.ts +++ b/packages/twenty-server/src/integrations/exception-handler/exception-handler.service.ts @@ -1,8 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ExceptionHandlerDriverInterface } from 'src/integrations/exception-handler/interfaces'; +import { ExceptionHandlerOptions } from 'src/integrations/exception-handler/interfaces/exception-handler-options.interface'; -import { EXCEPTION_HANDLER_DRIVER } from './exception-handler.constants'; +import { ExceptionHandlerDriverInterface } from 'src/integrations/exception-handler/interfaces'; +import { EXCEPTION_HANDLER_DRIVER } from 'src/integrations/exception-handler/exception-handler.constants'; @Injectable() export class ExceptionHandlerService { @@ -11,7 +12,10 @@ export class ExceptionHandlerService { private driver: ExceptionHandlerDriverInterface, ) {} - captureException(exception: unknown) { - this.driver.captureException(exception); + captureExceptions( + exceptions: ReadonlyArray, + options?: ExceptionHandlerOptions, + ): string[] { + return this.driver.captureExceptions(exceptions, options); } } diff --git a/packages/twenty-server/src/integrations/exception-handler/hooks/use-exception-handler.hook.ts b/packages/twenty-server/src/integrations/exception-handler/hooks/use-exception-handler.hook.ts new file mode 100644 index 00000000000..df39db28ee7 --- /dev/null +++ b/packages/twenty-server/src/integrations/exception-handler/hooks/use-exception-handler.hook.ts @@ -0,0 +1,150 @@ +import { GraphQLError, Kind, OperationDefinitionNode, print } from 'graphql'; +import * as express from 'express'; +import { + getDocumentString, + handleStreamOrSingleExecutionResult, + OnExecuteDoneHookResultOnNextHook, + Plugin, +} from '@envelop/core'; + +import { ExceptionHandlerUser } from 'src/integrations/exception-handler/interfaces/exception-handler-user.interface'; + +import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service'; +import { TokenService } from 'src/core/auth/services/token.service'; +import { + convertExceptionToGraphQLError, + filterException, +} from 'src/filters/utils/global-exception-handler.util'; + +export type ExceptionHandlerPluginOptions = { + /** + * The driver to use to handle exceptions. + */ + exceptionHandlerService: ExceptionHandlerService; + /** + * The token service to use to get the token from the request. + */ + tokenService: TokenService; + /** + * The key of the event id in the error's extension. `null` to disable. + * @default exceptionEventId + */ + eventIdKey?: string | null; +}; + +export const useExceptionHandler = < + PluginContext extends Record = object, +>( + options: ExceptionHandlerPluginOptions, +): Plugin => { + const exceptionHandlerService = options.exceptionHandlerService; + const tokenService = options.tokenService; + const eventIdKey = options.eventIdKey === null ? null : 'exceptionEventId'; + + function addEventId( + err: GraphQLError, + eventId: string | undefined | null, + ): GraphQLError { + if (eventIdKey !== null && eventId) { + err.extensions[eventIdKey] = eventId; + } + + return err; + } + + return { + async onExecute({ args }) { + const rootOperation = args.document.definitions.find( + (o) => o.kind === Kind.OPERATION_DEFINITION, + ) as OperationDefinitionNode; + const operationType = rootOperation.operation; + const document = getDocumentString(args.document, print); + const request = args.contextValue.req as express.Request; + const opName = + args.operationName || + rootOperation.name?.value || + 'Anonymous Operation'; + let user: ExceptionHandlerUser | undefined; + + if (tokenService.isTokenPresent(request)) { + try { + const data = await tokenService.validateToken(request); + + user = { + id: data.user?.id, + email: data.user?.email, + }; + } catch {} + } + + return { + onExecuteDone(payload) { + const handleResult: OnExecuteDoneHookResultOnNextHook = ({ + result, + setResult, + }) => { + if (result.errors && result.errors.length > 0) { + const exceptions = result.errors.reduce<{ + filtered: any[]; + unfiltered: any[]; + }>( + (acc, err) => { + // Filter out exceptions that we don't want to be captured by exception handler + if (filterException(err?.originalError ?? err)) { + acc.filtered.push(err); + } else { + acc.unfiltered.push(err); + } + + return acc; + }, + { + filtered: [], + unfiltered: [], + }, + ); + + if (exceptions.unfiltered.length > 0) { + const eventIds = exceptionHandlerService.captureExceptions( + exceptions.unfiltered, + { + operation: { + name: opName, + type: operationType, + }, + document, + user, + }, + ); + + exceptions.unfiltered.map((err, i) => + addEventId(err, eventIds?.[i]), + ); + } + + const concatenatedErrors = [ + ...exceptions.filtered, + ...exceptions.unfiltered, + ]; + const errors = concatenatedErrors.map((err) => { + // Properly convert errors to GraphQLErrors + const graphQLError = convertExceptionToGraphQLError( + err.originalError, + ); + + return graphQLError; + }); + + setResult({ + ...result, + errors, + }); + } + }; + + return handleStreamOrSingleExecutionResult(payload, handleResult); + }, + }; + }, + }; +}; diff --git a/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-driver.interface.ts b/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-driver.interface.ts index f1e23ac28cf..4c8af5c208e 100644 --- a/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-driver.interface.ts +++ b/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-driver.interface.ts @@ -1,4 +1,10 @@ +import { ExceptionHandlerOptions } from './exception-handler-options.interface'; +import { ExceptionHandlerUser } from './exception-handler-user.interface'; + export interface ExceptionHandlerDriverInterface { - captureException(exception: unknown): void; - captureMessage(message: string): void; + captureExceptions( + exceptions: ReadonlyArray, + options?: ExceptionHandlerOptions, + ): string[]; + captureMessage(message: string, user?: ExceptionHandlerUser): void; } diff --git a/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-options.interface.ts b/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-options.interface.ts new file mode 100644 index 00000000000..7cb267d4b88 --- /dev/null +++ b/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-options.interface.ts @@ -0,0 +1,12 @@ +import { OperationTypeNode } from 'graphql'; + +import { ExceptionHandlerUser } from './exception-handler-user.interface'; + +export interface ExceptionHandlerOptions { + operation?: { + type: OperationTypeNode; + name: string; + }; + document?: string; + user?: ExceptionHandlerUser; +} diff --git a/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-user.interface.ts b/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-user.interface.ts new file mode 100644 index 00000000000..eec20ddfe18 --- /dev/null +++ b/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-user.interface.ts @@ -0,0 +1,6 @@ +export interface ExceptionHandlerUser { + id?: string; + ipAddress?: string; + email?: string; + username?: string; +} diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index 56effa3931e..ecccb07e8bf 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -5,6 +5,7 @@ import * as bodyParser from 'body-parser'; import { graphqlUploadExpress } from 'graphql-upload'; import bytes from 'bytes'; import { useContainer } from 'class-validator'; +import '@sentry/tracing'; import { AppModule } from './app.module'; @@ -15,14 +16,16 @@ import { EnvironmentService } from './integrations/environment/environment.servi const bootstrap = async () => { const app = await NestFactory.create(AppModule, { cors: true, - logger: process.env.DEBUG_MODE - ? ['error', 'warn', 'log', 'verbose', 'debug'] - : ['error', 'warn', 'log'], + bufferLogs: true, }); + const logger = app.get(LoggerService); // Apply class-validator container so that we can use injection in validators useContainer(app.select(AppModule), { fallbackOnErrors: true }); + // Use our logger + app.useLogger(logger); + // Apply validation pipes globally app.useGlobalPipes( new ValidationPipe({ diff --git a/packages/twenty-server/src/metadata/field-metadata/field-metadata.resolver.ts b/packages/twenty-server/src/metadata/field-metadata/field-metadata.resolver.ts index 3b712968e9d..91b7dd61300 100644 --- a/packages/twenty-server/src/metadata/field-metadata/field-metadata.resolver.ts +++ b/packages/twenty-server/src/metadata/field-metadata/field-metadata.resolver.ts @@ -2,7 +2,7 @@ import { UseGuards } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { Workspace } from 'src/core/workspace/workspace.entity'; -import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; +import { AuthWorkspace } from 'src/decorators/auth/auth-workspace.decorator'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { CreateOneFieldMetadataInput } from 'src/metadata/field-metadata/dtos/create-field.input'; import { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadata.dto'; diff --git a/packages/twenty-server/src/metadata/field-metadata/field-metadata.service.ts b/packages/twenty-server/src/metadata/field-metadata/field-metadata.service.ts index 7b5bba5d732..723a8f99811 100644 --- a/packages/twenty-server/src/metadata/field-metadata/field-metadata.service.ts +++ b/packages/twenty-server/src/metadata/field-metadata/field-metadata.service.ts @@ -24,6 +24,7 @@ import { DataSourceService } from 'src/metadata/data-source/data-source.service' import { UpdateFieldInput } from 'src/metadata/field-metadata/dtos/update-field.input'; import { WorkspaceMigrationFactory } from 'src/metadata/workspace-migration/workspace-migration.factory'; import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util'; +import { generateMigrationName } from 'src/metadata/workspace-migration/utils/generate-migration-name.util'; import { FieldMetadataEntity, @@ -111,6 +112,7 @@ export class FieldMetadataService extends TypeOrmQueryService => { const config: YogaDriverConfig = { context: ({ req }) => ({ req }), @@ -21,20 +21,13 @@ export const metadataModuleFactory = async ( return renderApolloPlayground({ path: 'metadata' }); }, resolvers: { JSON: GraphQLJSON }, - plugins: [], + plugins: [ + useExceptionHandler({ + exceptionHandlerService, + tokenService, + }), + ], path: '/metadata', - maskedErrors: { - maskError(error: GraphQLError, message, isDev) { - if (error.originalError) { - return handleExceptionAndConvertToGraphQLError( - error.originalError, - exceptionHandlerService, - ); - } - - return maskError(error, message, isDev); - }, - }, }; if (environmentService.isDebugMode()) { diff --git a/packages/twenty-server/src/metadata/metadata.module.ts b/packages/twenty-server/src/metadata/metadata.module.ts index 27692ccffc8..ff7fac4c377 100644 --- a/packages/twenty-server/src/metadata/metadata.module.ts +++ b/packages/twenty-server/src/metadata/metadata.module.ts @@ -6,8 +6,10 @@ import { YogaDriverConfig, YogaDriver } from '@graphql-yoga/nestjs'; import { WorkspaceMigrationRunnerModule } from 'src/workspace/workspace-migration-runner/workspace-migration-runner.module'; import { WorkspaceMigrationModule } from 'src/metadata/workspace-migration/workspace-migration.module'; import { metadataModuleFactory } from 'src/metadata/metadata.module-factory'; -import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service'; +import { TokenService } from 'src/core/auth/services/token.service'; +import { AuthModule } from 'src/core/auth/auth.module'; import { DataSourceModule } from './data-source/data-source.module'; import { FieldMetadataModule } from './field-metadata/field-metadata.module'; @@ -18,7 +20,8 @@ import { RelationMetadataModule } from './relation-metadata/relation-metadata.mo GraphQLModule.forRootAsync({ driver: YogaDriver, useFactory: metadataModuleFactory, - inject: [EnvironmentService, ExceptionHandlerService], + imports: [AuthModule], + inject: [EnvironmentService, ExceptionHandlerService, TokenService], }), DataSourceModule, FieldMetadataModule, diff --git a/packages/twenty-server/src/metadata/object-metadata/object-metadata.resolver.ts b/packages/twenty-server/src/metadata/object-metadata/object-metadata.resolver.ts index 54f72ce3a4c..bb5f050f8dd 100644 --- a/packages/twenty-server/src/metadata/object-metadata/object-metadata.resolver.ts +++ b/packages/twenty-server/src/metadata/object-metadata/object-metadata.resolver.ts @@ -2,7 +2,7 @@ import { UseGuards } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { Workspace } from 'src/core/workspace/workspace.entity'; -import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; +import { AuthWorkspace } from 'src/decorators/auth/auth-workspace.decorator'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { ObjectMetadataDTO } from 'src/metadata/object-metadata/dtos/object-metadata.dto'; import { DeleteOneObjectInput } from 'src/metadata/object-metadata/dtos/delete-object.input'; diff --git a/packages/twenty-server/src/metadata/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/metadata/object-metadata/object-metadata.service.ts index 0fe97a590ac..fe49c81d747 100644 --- a/packages/twenty-server/src/metadata/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/metadata/object-metadata/object-metadata.service.ts @@ -35,6 +35,7 @@ import { } from 'src/workspace/utils/compute-object-target-table.util'; import { DeleteOneObjectInput } from 'src/metadata/object-metadata/dtos/delete-object.input'; import { RelationToDelete } from 'src/metadata/relation-metadata/types/relation-to-delete'; +import { generateMigrationName } from 'src/metadata/workspace-migration/utils/generate-migration-name.util'; import { ObjectMetadataEntity } from './object-metadata.entity'; @@ -152,6 +153,9 @@ export class ObjectMetadataService extends TypeOrmQueryService { - const jobClassName = getJobClassName(jobData.name); - const job: MessageQueueJob = app - .select(JobsModule) - .get(jobClassName, { strict: true }); - - await job.handle(jobData.data); + try { + const app = await NestFactory.createApplicationContext(QueueWorkerModule, { + bufferLogs: true, }); + + loggerService = app.get(LoggerService); + exceptionHandlerService = app.get(ExceptionHandlerService); + + // Inject our logger + app.useLogger(loggerService!); + + for (const queueName of Object.values(MessageQueue)) { + const messageQueueService: MessageQueueService = app.get(queueName); + + await messageQueueService.work(async (jobData: MessageQueueJobData) => { + const jobClassName = getJobClassName(jobData.name); + const job: MessageQueueJob = app + .select(JobsModule) + .get(jobClassName, { strict: true }); + + await job.handle(jobData.data); + }); + } + } catch (err) { + loggerService?.error(err?.message, err?.name); + + if (!filterException(err)) { + exceptionHandlerService?.captureExceptions([err]); + } + + throw err; } } bootstrap(); diff --git a/packages/twenty-server/src/utils/typed-reflect.ts b/packages/twenty-server/src/utils/typed-reflect.ts index 610644769c0..ed33e200fd3 100644 --- a/packages/twenty-server/src/utils/typed-reflect.ts +++ b/packages/twenty-server/src/utils/typed-reflect.ts @@ -7,8 +7,8 @@ import { ReflectRelationMetadata } from 'src/workspace/workspace-sync-metadata/i export interface ReflectMetadataTypeMap { objectMetadata: ReflectObjectMetadata; - fieldMetadata: ReflectFieldMetadata; - relationMetadata: ReflectRelationMetadata[]; + fieldMetadataMap: ReflectFieldMetadata; + relationMetadataCollection: ReflectRelationMetadata[]; gate: GateDecoratorParams; isNullable: true; isSystem: true; diff --git a/packages/twenty-server/src/workspace/messaging/commands/fetch-workspace-messages-commands.module.ts b/packages/twenty-server/src/workspace/messaging/commands/fetch-workspace-messages-commands.module.ts index b8b7fce8a2c..bad683a35ea 100644 --- a/packages/twenty-server/src/workspace/messaging/commands/fetch-workspace-messages-commands.module.ts +++ b/packages/twenty-server/src/workspace/messaging/commands/fetch-workspace-messages-commands.module.ts @@ -6,18 +6,15 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { DataSourceModule } from 'src/metadata/data-source/data-source.module'; import { GmailFullSyncCommand } from 'src/workspace/messaging/commands/gmail-full-sync.command'; import { GmailPartialSyncCommand } from 'src/workspace/messaging/commands/gmail-partial-sync.command'; -import { MessagingUtilsService } from 'src/workspace/messaging/services/messaging-utils.service'; +import { ConnectedAccountModule } from 'src/workspace/messaging/connected-account/connected-account.module'; @Module({ imports: [ DataSourceModule, TypeORMModule, TypeOrmModule.forFeature([FeatureFlagEntity], 'core'), + ConnectedAccountModule, ], - providers: [ - GmailFullSyncCommand, - GmailPartialSyncCommand, - MessagingUtilsService, - ], + providers: [GmailFullSyncCommand, GmailPartialSyncCommand], }) export class FetchWorkspaceMessagesCommandsModule {} diff --git a/packages/twenty-server/src/workspace/messaging/commands/gmail-full-sync.command.ts b/packages/twenty-server/src/workspace/messaging/commands/gmail-full-sync.command.ts index 24a5c131cf2..81837964ccc 100644 --- a/packages/twenty-server/src/workspace/messaging/commands/gmail-full-sync.command.ts +++ b/packages/twenty-server/src/workspace/messaging/commands/gmail-full-sync.command.ts @@ -8,13 +8,13 @@ import { FeatureFlagEntity, FeatureFlagKeys, } from 'src/core/feature-flag/feature-flag.entity'; -import { MessagingUtilsService } from 'src/workspace/messaging/services/messaging-utils.service'; import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service'; import { GmailFullSyncJobData, GmailFullSyncJob, } from 'src/workspace/messaging/jobs/gmail-full-sync.job'; +import { ConnectedAccountService } from 'src/workspace/messaging/connected-account/connected-account.service'; interface GmailFullSyncOptions { workspaceId: string; @@ -26,11 +26,11 @@ interface GmailFullSyncOptions { }) export class GmailFullSyncCommand extends CommandRunner { constructor( - private readonly utils: MessagingUtilsService, @InjectRepository(FeatureFlagEntity, 'core') private readonly featureFlagRepository: Repository, @Inject(MessageQueue.messagingQueue) private readonly messageQueueService: MessageQueueService, + private readonly connectedAccountService: ConnectedAccountService, ) { super(); } @@ -64,13 +64,8 @@ export class GmailFullSyncCommand extends CommandRunner { } private async fetchWorkspaceMessages(workspaceId: string): Promise { - const { workspaceDataSource, dataSourceMetadata } = - await this.utils.getDataSourceMetadataWorkspaceMetadata(workspaceId); - - const connectedAccounts = await this.utils.getConnectedAccounts( - dataSourceMetadata, - workspaceDataSource, - ); + const connectedAccounts = + await this.connectedAccountService.getAll(workspaceId); for (const connectedAccount of connectedAccounts) { await this.messageQueueService.add( diff --git a/packages/twenty-server/src/workspace/messaging/commands/gmail-partial-sync.command.ts b/packages/twenty-server/src/workspace/messaging/commands/gmail-partial-sync.command.ts index 1119d263b92..ddadd8c2543 100644 --- a/packages/twenty-server/src/workspace/messaging/commands/gmail-partial-sync.command.ts +++ b/packages/twenty-server/src/workspace/messaging/commands/gmail-partial-sync.command.ts @@ -8,13 +8,13 @@ import { FeatureFlagEntity, FeatureFlagKeys, } from 'src/core/feature-flag/feature-flag.entity'; -import { MessagingUtilsService } from 'src/workspace/messaging/services/messaging-utils.service'; import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service'; import { GmailPartialSyncJob, GmailPartialSyncJobData, } from 'src/workspace/messaging/jobs/gmail-partial-sync.job'; +import { ConnectedAccountService } from 'src/workspace/messaging/connected-account/connected-account.service'; interface GmailPartialSyncOptions { workspaceId: string; @@ -26,11 +26,11 @@ interface GmailPartialSyncOptions { }) export class GmailPartialSyncCommand extends CommandRunner { constructor( - private readonly utils: MessagingUtilsService, @InjectRepository(FeatureFlagEntity, 'core') private readonly featureFlagRepository: Repository, @Inject(MessageQueue.messagingQueue) private readonly messageQueueService: MessageQueueService, + private readonly connectedAccountService: ConnectedAccountService, ) { super(); } @@ -64,13 +64,8 @@ export class GmailPartialSyncCommand extends CommandRunner { } private async fetchWorkspaceMessages(workspaceId: string): Promise { - const { workspaceDataSource, dataSourceMetadata } = - await this.utils.getDataSourceMetadataWorkspaceMetadata(workspaceId); - - const connectedAccounts = await this.utils.getConnectedAccounts( - dataSourceMetadata, - workspaceDataSource, - ); + const connectedAccounts = + await this.connectedAccountService.getAll(workspaceId); for (const connectedAccount of connectedAccounts) { await this.messageQueueService.add( diff --git a/packages/twenty-server/src/workspace/messaging/connected-account/connected-account.module.ts b/packages/twenty-server/src/workspace/messaging/connected-account/connected-account.module.ts new file mode 100644 index 00000000000..376c7c6fede --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/connected-account/connected-account.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { ConnectedAccountService } from 'src/workspace/messaging/connected-account/connected-account.service'; +import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module'; + +@Module({ + imports: [WorkspaceDataSourceModule], + providers: [ConnectedAccountService], + exports: [ConnectedAccountService], +}) +export class ConnectedAccountModule {} diff --git a/packages/twenty-server/src/workspace/messaging/connected-account/connected-account.service.ts b/packages/twenty-server/src/workspace/messaging/connected-account/connected-account.service.ts new file mode 100644 index 00000000000..accc8090258 --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/connected-account/connected-account.service.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; + +import { EntityManager } from 'typeorm'; + +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; +import { ConnectedAccountObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/connected-account.object-metadata'; +import { ObjectRecord } from 'src/workspace/workspace-sync-metadata/types/object-record'; + +@Injectable() +export class ConnectedAccountService { + constructor( + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + ) {} + + public async getAll( + workspaceId: string, + transactionManager?: EntityManager, + ): Promise[]> { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + return await this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."connectedAccount" WHERE "provider" = 'google'`, + [], + workspaceId, + transactionManager, + ); + } + + public async getByIdOrFail( + connectedAccountId: string, + workspaceId: string, + transactionManager?: EntityManager, + ): Promise> { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + const connectedAccounts = + await this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."connectedAccount" WHERE "id" = $1 LIMIT 1`, + [connectedAccountId], + workspaceId, + transactionManager, + ); + + if (!connectedAccounts || connectedAccounts.length === 0) { + throw new Error('No connected account found'); + } + + return connectedAccounts[0]; + } + + public async updateLastSyncHistoryId( + historyId: string, + connectedAccountId: string, + workspaceId: string, + transactionManager?: EntityManager, + ) { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + await this.workspaceDataSourceService.executeRawQuery( + `UPDATE ${dataSourceSchema}."connectedAccount" SET "lastSyncHistoryId" = $1 WHERE "id" = $2`, + [historyId, connectedAccountId], + workspaceId, + transactionManager, + ); + } + + public async updateAccessToken( + accessToken: string, + connectedAccountId: string, + workspaceId: string, + transactionManager?: EntityManager, + ) { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + await this.workspaceDataSourceService.executeRawQuery( + `UPDATE ${dataSourceSchema}."connectedAccount" SET "accessToken" = $1 WHERE "id" = $2`, + [accessToken, connectedAccountId], + workspaceId, + transactionManager, + ); + } +} diff --git a/packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-assocation.module.ts b/packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-assocation.module.ts new file mode 100644 index 00000000000..6a6b22d9737 --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-assocation.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { MessageChannelMessageAssociationService } from 'src/workspace/messaging/message-channel-message-association/message-channel-message-association.service'; +import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module'; + +@Module({ + imports: [WorkspaceDataSourceModule], + providers: [MessageChannelMessageAssociationService], + exports: [MessageChannelMessageAssociationService], +}) +export class MessageChannelMessageAssociationModule {} diff --git a/packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-association.service.ts b/packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-association.service.ts new file mode 100644 index 00000000000..cf2406c323b --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-association.service.ts @@ -0,0 +1,141 @@ +import { Injectable } from '@nestjs/common'; + +import { EntityManager } from 'typeorm'; + +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; +import { MessageChannelMessageAssociationObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/message-channel-message-association.object-metadata'; +import { ObjectRecord } from 'src/workspace/workspace-sync-metadata/types/object-record'; + +@Injectable() +export class MessageChannelMessageAssociationService { + constructor( + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + ) {} + + public async getByMessageExternalIdsAndMessageChannelId( + messageExternalIds: string[], + messageChannelId: string, + workspaceId: string, + transactionManager?: EntityManager, + ): Promise[]> { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + return await this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."messageChannelMessageAssociation" + WHERE "messageExternalId" = ANY($1) AND "messageChannelId" = $2`, + [messageExternalIds, messageChannelId], + workspaceId, + transactionManager, + ); + } + + public async countByMessageExternalIdsAndMessageChannelId( + messageExternalIds: string[], + messageChannelId: string, + workspaceId: string, + transactionManager?: EntityManager, + ): Promise { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + const result = await this.workspaceDataSourceService.executeRawQuery( + `SELECT COUNT(*) FROM ${dataSourceSchema}."messageChannelMessageAssociation" + WHERE "messageExternalId" = ANY($1) AND "messageChannelId" = $2`, + [messageExternalIds, messageChannelId], + workspaceId, + transactionManager, + ); + + return result[0]?.count; + } + + public async deleteByMessageExternalIdsAndMessageChannelId( + messageExternalIds: string[], + messageChannelId: string, + workspaceId: string, + transactionManager?: EntityManager, + ) { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + await this.workspaceDataSourceService.executeRawQuery( + `DELETE FROM ${dataSourceSchema}."messageChannelMessageAssociation" WHERE "messageExternalId" = ANY($1) AND "messageChannelId" = $2`, + [messageExternalIds, messageChannelId], + workspaceId, + transactionManager, + ); + } + + public async deleteByIds( + ids: string[], + workspaceId: string, + transactionManager?: EntityManager, + ) { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + await this.workspaceDataSourceService.executeRawQuery( + `DELETE FROM ${dataSourceSchema}."messageChannelMessageAssociation" WHERE "id" = ANY($1)`, + [ids], + workspaceId, + transactionManager, + ); + } + + public async getByMessageThreadExternalIds( + messageThreadExternalIds: string[], + workspaceId: string, + transactionManager?: EntityManager, + ): Promise[]> { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + return await this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."messageChannelMessageAssociation" + WHERE "messageThreadExternalId" = ANY($1)`, + [messageThreadExternalIds], + workspaceId, + transactionManager, + ); + } + + public async getFirstByMessageThreadExternalId( + messageThreadExternalId: string, + workspaceId: string, + transactionManager?: EntityManager, + ): Promise | null> { + const existingMessageChannelMessageAssociations = + await this.getByMessageThreadExternalIds( + [messageThreadExternalId], + workspaceId, + transactionManager, + ); + + if ( + !existingMessageChannelMessageAssociations || + existingMessageChannelMessageAssociations.length === 0 + ) { + return null; + } + + return existingMessageChannelMessageAssociations[0]; + } + + public async getByMessageIds( + messageIds: string[], + workspaceId: string, + transactionManager?: EntityManager, + ): Promise[]> { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + return await this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."messageChannelMessageAssociation" + WHERE "messageId" = ANY($1)`, + [messageIds], + workspaceId, + transactionManager, + ); + } +} diff --git a/packages/twenty-server/src/workspace/messaging/message-channel/message-channel.module.ts b/packages/twenty-server/src/workspace/messaging/message-channel/message-channel.module.ts new file mode 100644 index 00000000000..ef8d77674b8 --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message-channel/message-channel.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { MessageChannelService } from 'src/workspace/messaging/message-channel/message-channel.service'; +import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module'; + +@Module({ + imports: [WorkspaceDataSourceModule], + providers: [MessageChannelService], + exports: [MessageChannelService], +}) +export class MessageChannelModule {} diff --git a/packages/twenty-server/src/workspace/messaging/message-channel/message-channel.service.ts b/packages/twenty-server/src/workspace/messaging/message-channel/message-channel.service.ts new file mode 100644 index 00000000000..b9ece9fa279 --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message-channel/message-channel.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; + +import { EntityManager } from 'typeorm'; + +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; +import { MessageChannelObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/message-channel.object-metadata'; +import { ObjectRecord } from 'src/workspace/workspace-sync-metadata/types/object-record'; + +@Injectable() +export class MessageChannelService { + constructor( + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + ) {} + + public async getByConnectedAccountId( + connectedAccountId: string, + workspaceId: string, + transactionManager?: EntityManager, + ): Promise[]> { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + return await this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."messageChannel" WHERE "connectedAccountId" = $1 AND "type" = 'email' LIMIT 1`, + [connectedAccountId], + workspaceId, + transactionManager, + ); + } + + public async getFirstByConnectedAccountIdOrFail( + connectedAccountId: string, + workspaceId: string, + ): Promise> { + const messageChannels = await this.getByConnectedAccountId( + connectedAccountId, + workspaceId, + ); + + if (!messageChannels || messageChannels.length === 0) { + throw new Error('No message channel found'); + } + + return messageChannels[0]; + } +} diff --git a/packages/twenty-server/src/workspace/messaging/message-thread/message-thread.module.ts b/packages/twenty-server/src/workspace/messaging/message-thread/message-thread.module.ts new file mode 100644 index 00000000000..8ee9ec9030e --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message-thread/message-thread.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { MessageThreadService } from 'src/workspace/messaging/message-thread/message-thread.service'; +import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module'; + +@Module({ + imports: [WorkspaceDataSourceModule], + providers: [MessageThreadService], + exports: [MessageThreadService], +}) +export class MessageThreadModule {} diff --git a/packages/twenty-server/src/workspace/messaging/message-thread/message-thread.service.ts b/packages/twenty-server/src/workspace/messaging/message-thread/message-thread.service.ts new file mode 100644 index 00000000000..f25fa16b3cb --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message-thread/message-thread.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; + +import { EntityManager } from 'typeorm'; + +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; + +@Injectable() +export class MessageThreadService { + constructor( + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + ) {} + + public async deleteByIds( + messageThreadIds: string[], + workspaceId: string, + transactionManager?: EntityManager, + ): Promise { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + await this.workspaceDataSourceService.executeRawQuery( + `DELETE FROM ${dataSourceSchema}."messageThread" WHERE id = ANY($1)`, + [messageThreadIds], + workspaceId, + transactionManager, + ); + } +} diff --git a/packages/twenty-server/src/workspace/messaging/message/message.module.ts b/packages/twenty-server/src/workspace/messaging/message/message.module.ts new file mode 100644 index 00000000000..b9380587f6b --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message/message.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { MessageService } from 'src/workspace/messaging/message/message.service'; +import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module'; + +@Module({ + imports: [WorkspaceDataSourceModule], + providers: [MessageService], + exports: [MessageService], +}) +export class MessageModule {} diff --git a/packages/twenty-server/src/workspace/messaging/message/message.service.ts b/packages/twenty-server/src/workspace/messaging/message/message.service.ts new file mode 100644 index 00000000000..cfa4a21e828 --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message/message.service.ts @@ -0,0 +1,84 @@ +import { Injectable } from '@nestjs/common'; + +import { EntityManager } from 'typeorm'; + +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; +import { MessageObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/message.object-metadata'; +import { ObjectRecord } from 'src/workspace/workspace-sync-metadata/types/object-record'; + +@Injectable() +export class MessageService { + constructor( + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + ) {} + + public async getFirstByHeaderMessageId( + headerMessageId: string, + workspaceId: string, + transactionManager?: EntityManager, + ): Promise | null> { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + const messages = await this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."message" WHERE "headerMessageId" = $1 LIMIT 1`, + [headerMessageId], + workspaceId, + transactionManager, + ); + + if (!messages || messages.length === 0) { + return null; + } + + return messages[0]; + } + + public async getByIds( + messageIds: string[], + workspaceId: string, + transactionManager?: EntityManager, + ): Promise[]> { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + return await this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."message" WHERE "id" = ANY($1)`, + [messageIds], + workspaceId, + transactionManager, + ); + } + + public async deleteByIds( + messageIds: string[], + workspaceId: string, + transactionManager?: EntityManager, + ): Promise { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + await this.workspaceDataSourceService.executeRawQuery( + `DELETE FROM ${dataSourceSchema}."message" WHERE "id" = ANY($1)`, + [messageIds], + workspaceId, + transactionManager, + ); + } + + public async getByMessageThreadIds( + messageThreadIds: string[], + workspaceId: string, + transactionManager?: EntityManager, + ): Promise[]> { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + return await this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."message" WHERE "messageThreadId" = ANY($1)`, + [messageThreadIds], + workspaceId, + transactionManager, + ); + } +} diff --git a/packages/twenty-server/src/workspace/messaging/services/fetch-messages-by-batches.service.ts b/packages/twenty-server/src/workspace/messaging/services/fetch-messages-by-batches.service.ts index c7768682601..66006a906da 100644 --- a/packages/twenty-server/src/workspace/messaging/services/fetch-messages-by-batches.service.ts +++ b/packages/twenty-server/src/workspace/messaging/services/fetch-messages-by-batches.service.ts @@ -7,9 +7,9 @@ import planer from 'planer'; import { GmailMessage, Participant, -} from 'src/workspace/messaging/types/gmailMessage'; -import { MessageQuery } from 'src/workspace/messaging/types/messageOrThreadQuery'; -import { GmailMessageParsedResponse } from 'src/workspace/messaging/types/gmailMessageParsedResponse'; +} from 'src/workspace/messaging/types/gmail-message'; +import { MessageQuery } from 'src/workspace/messaging/types/message-or-thread-query'; +import { GmailMessageParsedResponse } from 'src/workspace/messaging/types/gmail-message-parsed-response'; @Injectable() export class FetchMessagesByBatchesService { diff --git a/packages/twenty-server/src/workspace/messaging/services/fetch-workspace-messages.module.ts b/packages/twenty-server/src/workspace/messaging/services/fetch-workspace-messages.module.ts index 7238712fa41..65ed671db98 100644 --- a/packages/twenty-server/src/workspace/messaging/services/fetch-workspace-messages.module.ts +++ b/packages/twenty-server/src/workspace/messaging/services/fetch-workspace-messages.module.ts @@ -1,17 +1,29 @@ import { Module } from '@nestjs/common'; -import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { EnvironmentModule } from 'src/integrations/environment/environment.module'; -import { DataSourceModule } from 'src/metadata/data-source/data-source.module'; +import { ConnectedAccountModule } from 'src/workspace/messaging/connected-account/connected-account.module'; +import { MessageChannelMessageAssociationModule } from 'src/workspace/messaging/message-channel-message-association/message-channel-message-assocation.module'; +import { MessageChannelModule } from 'src/workspace/messaging/message-channel/message-channel.module'; +import { MessageThreadModule } from 'src/workspace/messaging/message-thread/message-thread.module'; +import { MessageModule } from 'src/workspace/messaging/message/message.module'; import { GmailClientProvider } from 'src/workspace/messaging/providers/gmail/gmail-client.provider'; import { FetchMessagesByBatchesService } from 'src/workspace/messaging/services/fetch-messages-by-batches.service'; import { GmailFullSyncService } from 'src/workspace/messaging/services/gmail-full-sync.service'; import { GmailPartialSyncService } from 'src/workspace/messaging/services/gmail-partial-sync.service'; import { GmailRefreshAccessTokenService } from 'src/workspace/messaging/services/gmail-refresh-access-token.service'; import { MessagingUtilsService } from 'src/workspace/messaging/services/messaging-utils.service'; +import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module'; @Module({ - imports: [TypeORMModule, DataSourceModule, EnvironmentModule], + imports: [ + EnvironmentModule, + WorkspaceDataSourceModule, + ConnectedAccountModule, + MessageChannelModule, + MessageChannelMessageAssociationModule, + MessageModule, + MessageThreadModule, + ], providers: [ GmailFullSyncService, GmailPartialSyncService, diff --git a/packages/twenty-server/src/workspace/messaging/services/gmail-full-sync.service.ts b/packages/twenty-server/src/workspace/messaging/services/gmail-full-sync.service.ts index bfdf62f22f6..41ed2598c06 100644 --- a/packages/twenty-server/src/workspace/messaging/services/gmail-full-sync.service.ts +++ b/packages/twenty-server/src/workspace/messaging/services/gmail-full-sync.service.ts @@ -9,6 +9,10 @@ import { GmailFullSyncJobData, GmailFullSyncJob, } from 'src/workspace/messaging/jobs/gmail-full-sync.job'; +import { ConnectedAccountService } from 'src/workspace/messaging/connected-account/connected-account.service'; +import { MessageChannelService } from 'src/workspace/messaging/message-channel/message-channel.service'; +import { MessageChannelMessageAssociationService } from 'src/workspace/messaging/message-channel-message-association/message-channel-message-association.service'; +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; @Injectable() export class GmailFullSyncService { @@ -18,6 +22,10 @@ export class GmailFullSyncService { private readonly utils: MessagingUtilsService, @Inject(MessageQueue.messagingQueue) private readonly messageQueueService: MessageQueueService, + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + private readonly connectedAccountService: ConnectedAccountService, + private readonly messageChannelService: MessageChannelService, + private readonly messageChannelMessageAssociationService: MessageChannelMessageAssociationService, ) {} public async fetchConnectedAccountThreads( @@ -25,13 +33,14 @@ export class GmailFullSyncService { connectedAccountId: string, nextPageToken?: string, ): Promise { - const { workspaceDataSource, dataSourceMetadata } = - await this.utils.getDataSourceMetadataWorkspaceMetadata(workspaceId); + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); - const connectedAccount = await this.utils.getConnectedAcountByIdOrFail( + const connectedAccount = await this.connectedAccountService.getByIdOrFail( connectedAccountId, - dataSourceMetadata, - workspaceDataSource, + workspaceId, ); const accessToken = connectedAccount.accessToken; @@ -41,18 +50,13 @@ export class GmailFullSyncService { throw new Error('No refresh token found'); } - const gmailMessageChannel = await workspaceDataSource?.query( - `SELECT * FROM ${dataSourceMetadata.schema}."messageChannel" WHERE "connectedAccountId" = $1 AND "type" = 'email' LIMIT 1`, - [connectedAccountId], - ); - - if (!gmailMessageChannel.length) { - throw new Error( - `No gmail message channel found for connected account ${connectedAccountId}`, + const gmailMessageChannel = + await this.messageChannelService.getFirstByConnectedAccountIdOrFail( + connectedAccountId, + workspaceId, ); - } - const gmailMessageChannelId = gmailMessageChannel[0].id; + const gmailMessageChannelId = gmailMessageChannel.id; const gmailClient = await this.gmailClientProvider.getGmailClient(refreshToken); @@ -74,11 +78,10 @@ export class GmailFullSyncService { } const existingMessageChannelMessageAssociations = - await this.utils.getMessageChannelMessageAssociations( + await this.messageChannelMessageAssociationService.getByMessageExternalIdsAndMessageChannelId( messageExternalIds, gmailMessageChannelId, - dataSourceMetadata, - workspaceDataSource, + workspaceId, ); const existingMessageChannelMessageAssociationsExternalIds = @@ -113,6 +116,7 @@ export class GmailFullSyncService { workspaceDataSource, connectedAccount, gmailMessageChannelId, + workspaceId, ); if (errors.length) throw new Error('Error fetching messages'); @@ -125,11 +129,16 @@ export class GmailFullSyncService { if (!historyId) throw new Error('No history id found'); - await this.utils.saveLastSyncHistoryId( + await this.connectedAccountService.updateLastSyncHistoryId( historyId, connectedAccount.id, - dataSourceMetadata, - workspaceDataSource, + workspaceId, + ); + + console.log( + `gmail full-sync for workspace ${workspaceId} and account ${connectedAccountId} ${ + nextPageToken ? `and ${nextPageToken} pageToken` : '' + }done.`, ); if (messages.data.nextPageToken) { diff --git a/packages/twenty-server/src/workspace/messaging/services/gmail-partial-sync.service.ts b/packages/twenty-server/src/workspace/messaging/services/gmail-partial-sync.service.ts index 1f1c1f29fca..40a9b9bd981 100644 --- a/packages/twenty-server/src/workspace/messaging/services/gmail-partial-sync.service.ts +++ b/packages/twenty-server/src/workspace/messaging/services/gmail-partial-sync.service.ts @@ -11,6 +11,9 @@ import { GmailFullSyncJob, GmailFullSyncJobData, } from 'src/workspace/messaging/jobs/gmail-full-sync.job'; +import { ConnectedAccountService } from 'src/workspace/messaging/connected-account/connected-account.service'; +import { MessageChannelService } from 'src/workspace/messaging/message-channel/message-channel.service'; +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; @Injectable() export class GmailPartialSyncService { @@ -20,63 +23,30 @@ export class GmailPartialSyncService { private readonly utils: MessagingUtilsService, @Inject(MessageQueue.messagingQueue) private readonly messageQueueService: MessageQueueService, + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + private readonly connectedAccountService: ConnectedAccountService, + private readonly messageChannelService: MessageChannelService, ) {} - private async getHistory( - workspaceId: string, - connectedAccountId: string, - lastSyncHistoryId: string, - maxResults: number, - ) { - const { workspaceDataSource, dataSourceMetadata } = - await this.utils.getDataSourceMetadataWorkspaceMetadata(workspaceId); - - const connectedAccount = await this.utils.getConnectedAcountByIdOrFail( - connectedAccountId, - dataSourceMetadata, - workspaceDataSource, - ); - - const gmailClient = await this.gmailClientProvider.getGmailClient( - connectedAccount.refreshToken, - ); - - const history = await gmailClient.users.history.list({ - userId: 'me', - startHistoryId: lastSyncHistoryId, - historyTypes: ['messageAdded', 'messageDeleted'], - maxResults, - }); - - return history.data; - } - public async fetchConnectedAccountThreads( workspaceId: string, connectedAccountId: string, maxResults = 500, ): Promise { - const { workspaceDataSource, dataSourceMetadata } = - await this.utils.getDataSourceMetadataWorkspaceMetadata(workspaceId); + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); - const connectedAccount = await this.utils.getConnectedAcountByIdOrFail( + const connectedAccount = await this.connectedAccountService.getByIdOrFail( connectedAccountId, - dataSourceMetadata, - workspaceDataSource, + workspaceId, ); const lastSyncHistoryId = connectedAccount.lastSyncHistoryId; if (!lastSyncHistoryId) { - // Fall back to full sync - await this.messageQueueService.add( - GmailFullSyncJob.name, - { workspaceId, connectedAccountId }, - { - id: `${workspaceId}-${connectedAccount.id}`, - retryLimit: 2, - }, - ); + await this.fallbackToFullSync(workspaceId, connectedAccountId); return; } @@ -88,46 +58,35 @@ export class GmailPartialSyncService { throw new Error('No refresh token found'); } - const history = await this.getHistory( - workspaceId, - connectedAccountId, + const { history, error } = await this.getHistoryFromGmail( + refreshToken, lastSyncHistoryId, maxResults, ); - const historyId = history.historyId; + if (error && error.code === 404) { + await this.fallbackToFullSync(workspaceId, connectedAccountId); - if (!historyId) { + return; + } + + const newHistoryId = history?.historyId; + + if (!newHistoryId) { throw new Error('No history id found'); } - if (historyId === lastSyncHistoryId) { + if (newHistoryId === lastSyncHistoryId) { return; } - if (!history.history) { - await this.utils.saveLastSyncHistoryId( - historyId, + const gmailMessageChannel = + await this.messageChannelService.getFirstByConnectedAccountIdOrFail( connectedAccountId, - dataSourceMetadata, - workspaceDataSource, + workspaceId, ); - return; - } - - const gmailMessageChannel = await workspaceDataSource?.query( - `SELECT * FROM ${dataSourceMetadata.schema}."messageChannel" WHERE "connectedAccountId" = $1 AND "type" = 'email' LIMIT 1`, - [connectedAccountId], - ); - - if (!gmailMessageChannel.length) { - throw new Error( - `No gmail message channel found for connected account ${connectedAccountId}`, - ); - } - - const gmailMessageChannelId = gmailMessageChannel[0].id; + const gmailMessageChannelId = gmailMessageChannel.id; const { messagesAdded, messagesDeleted } = await this.getMessageIdsFromHistory(history); @@ -141,28 +100,32 @@ export class GmailPartialSyncService { accessToken, ); - await this.utils.saveMessages( - messagesToSave, - dataSourceMetadata, - workspaceDataSource, - connectedAccount, - gmailMessageChannelId, - ); + if (messagesToSave.length !== 0) { + await this.utils.saveMessages( + messagesToSave, + dataSourceMetadata, + workspaceDataSource, + connectedAccount, + gmailMessageChannelId, + workspaceId, + ); + } - await this.utils.deleteMessageChannelMessageAssociations( - messagesDeleted, - gmailMessageChannelId, - dataSourceMetadata, - workspaceDataSource, - ); + if (messagesDeleted.length !== 0) { + await this.utils.deleteMessages( + workspaceDataSource, + messagesDeleted, + gmailMessageChannelId, + workspaceId, + ); + } if (errors.length) throw new Error('Error fetching messages'); - await this.utils.saveLastSyncHistoryId( - historyId, + await this.connectedAccountService.updateLastSyncHistoryId( + newHistoryId, connectedAccount.id, - dataSourceMetadata, - workspaceDataSource, + workspaceId, ); } @@ -211,4 +174,49 @@ export class GmailPartialSyncService { messagesDeleted: uniqueMessagesDeleted, }; } + + private async getHistoryFromGmail( + refreshToken: string, + lastSyncHistoryId: string, + maxResults: number, + ): Promise<{ + history?: gmail_v1.Schema$ListHistoryResponse; + error?: any; + }> { + const gmailClient = + await this.gmailClientProvider.getGmailClient(refreshToken); + + try { + const history = await gmailClient.users.history.list({ + userId: 'me', + startHistoryId: lastSyncHistoryId, + historyTypes: ['messageAdded', 'messageDeleted'], + maxResults, + }); + + return { history: history.data }; + } catch (error) { + const errorData = error?.response?.data?.error; + + if (errorData) { + return { error: errorData }; + } + + throw error; + } + } + + private async fallbackToFullSync( + workspaceId: string, + connectedAccountId: string, + ) { + await this.messageQueueService.add( + GmailFullSyncJob.name, + { workspaceId, connectedAccountId }, + { + id: `${workspaceId}-${connectedAccountId}`, + retryLimit: 2, + }, + ); + } } diff --git a/packages/twenty-server/src/workspace/messaging/services/gmail-refresh-access-token.service.ts b/packages/twenty-server/src/workspace/messaging/services/gmail-refresh-access-token.service.ts index cd08d00cbc0..99fb83e46ea 100644 --- a/packages/twenty-server/src/workspace/messaging/services/gmail-refresh-access-token.service.ts +++ b/packages/twenty-server/src/workspace/messaging/services/gmail-refresh-access-token.service.ts @@ -2,44 +2,26 @@ import { Injectable } from '@nestjs/common'; import axios from 'axios'; -import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; -import { DataSourceService } from 'src/metadata/data-source/data-source.service'; +import { ConnectedAccountService } from 'src/workspace/messaging/connected-account/connected-account.service'; @Injectable() export class GmailRefreshAccessTokenService { constructor( private readonly environmentService: EnvironmentService, - private readonly dataSourceService: DataSourceService, - private readonly typeORMService: TypeORMService, + private readonly connectedAccountService: ConnectedAccountService, ) {} async refreshAndSaveAccessToken( workspaceId: string, connectedAccountId: string, ): Promise { - const dataSourceMetadata = - await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( - workspaceId, - ); - - const workspaceDataSource = - await this.typeORMService.connectToDataSource(dataSourceMetadata); - - if (!workspaceDataSource) { - throw new Error('No workspace data source found'); - } - - const connectedAccounts = await workspaceDataSource?.query( - `SELECT * FROM ${dataSourceMetadata.schema}."connectedAccount" WHERE "provider" = 'google' AND "id" = $1`, - [connectedAccountId], + const connectedAccount = await this.connectedAccountService.getByIdOrFail( + connectedAccountId, + workspaceId, ); - if (!connectedAccounts || connectedAccounts.length === 0) { - throw new Error('No connected account found'); - } - - const refreshToken = connectedAccounts[0]?.refreshToken; + const refreshToken = connectedAccount.refreshToken; if (!refreshToken) { throw new Error('No refresh token found'); @@ -47,9 +29,10 @@ export class GmailRefreshAccessTokenService { const accessToken = await this.refreshAccessToken(refreshToken); - await workspaceDataSource?.query( - `UPDATE ${dataSourceMetadata.schema}."connectedAccount" SET "accessToken" = $1 WHERE "id" = $2`, - [accessToken, connectedAccounts[0].id], + await this.connectedAccountService.updateAccessToken( + accessToken, + connectedAccountId, + workspaceId, ); } diff --git a/packages/twenty-server/src/workspace/messaging/services/messaging-utils.service.ts b/packages/twenty-server/src/workspace/messaging/services/messaging-utils.service.ts index 722414a14b7..328b81d80f7 100644 --- a/packages/twenty-server/src/workspace/messaging/services/messaging-utils.service.ts +++ b/packages/twenty-server/src/workspace/messaging/services/messaging-utils.service.ts @@ -3,20 +3,24 @@ import { Injectable } from '@nestjs/common'; import { EntityManager, DataSource } from 'typeorm'; import { v4 } from 'uuid'; -import { TypeORMService } from 'src/database/typeorm/typeorm.service'; -import { DataSourceService } from 'src/metadata/data-source/data-source.service'; import { DataSourceEntity } from 'src/metadata/data-source/data-source.entity'; import { GmailMessage, Participant, -} from 'src/workspace/messaging/types/gmailMessage'; -import { MessageQuery } from 'src/workspace/messaging/types/messageOrThreadQuery'; +} from 'src/workspace/messaging/types/gmail-message'; +import { MessageQuery } from 'src/workspace/messaging/types/message-or-thread-query'; +import { MessageChannelMessageAssociationService } from 'src/workspace/messaging/message-channel-message-association/message-channel-message-association.service'; +import { MessageService } from 'src/workspace/messaging/message/message.service'; +import { MessageThreadService } from 'src/workspace/messaging/message-thread/message-thread.service'; +import { ObjectRecord } from 'src/workspace/workspace-sync-metadata/types/object-record'; +import { ConnectedAccountObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/connected-account.object-metadata'; @Injectable() export class MessagingUtilsService { constructor( - private readonly dataSourceService: DataSourceService, - private readonly typeORMService: TypeORMService, + private readonly messageChannelMessageAssociationService: MessageChannelMessageAssociationService, + private readonly messageService: MessageService, + private readonly messageThreadService: MessageThreadService, ) {} public createQueriesFromMessageIds( @@ -31,18 +35,21 @@ export class MessagingUtilsService { messages: GmailMessage[], dataSourceMetadata: DataSourceEntity, workspaceDataSource: DataSource, - connectedAccount, + connectedAccount: ObjectRecord, gmailMessageChannelId: string, + workspaceId: string, ) { for (const message of messages) { - await workspaceDataSource?.transaction(async (manager) => { - const existingMessageChannelMessageAssociations = await manager.query( - `SELECT COUNT(*) FROM ${dataSourceMetadata.schema}."messageChannelMessageAssociation" - WHERE "messageExternalId" = $1 AND "messageChannelId" = $2`, - [message.externalId, gmailMessageChannelId], - ); + await workspaceDataSource?.transaction(async (manager: EntityManager) => { + const existingMessageChannelMessageAssociationsCount = + await this.messageChannelMessageAssociationService.countByMessageExternalIdsAndMessageChannelId( + [message.externalId], + gmailMessageChannelId, + workspaceId, + manager, + ); - if (existingMessageChannelMessageAssociations[0]?.count > 0) { + if (existingMessageChannelMessageAssociationsCount > 0) { return; } @@ -50,7 +57,8 @@ export class MessagingUtilsService { await this.saveMessageThreadOrReturnExistingMessageThread( message.messageThreadExternalId, dataSourceMetadata, - workspaceDataSource, + workspaceId, + manager, ); const savedOrExistingMessageId = @@ -59,6 +67,7 @@ export class MessagingUtilsService { savedOrExistingMessageThreadId, connectedAccount, dataSourceMetadata, + workspaceId, manager, ); @@ -79,15 +88,16 @@ export class MessagingUtilsService { private async saveMessageOrReturnExistingMessage( message: GmailMessage, messageThreadId: string, - connectedAccount, + connectedAccount: ObjectRecord, dataSourceMetadata: DataSourceEntity, + workspaceId: string, manager: EntityManager, ): Promise { - const existingMessages = await manager.query( - `SELECT "message"."id" FROM ${dataSourceMetadata.schema}."message" WHERE ${dataSourceMetadata.schema}."message"."headerMessageId" = $1 LIMIT 1`, - [message.headerMessageId], + const existingMessage = await this.messageService.getFirstByHeaderMessageId( + message.headerMessageId, + workspaceId, ); - const existingMessageId: string = existingMessages[0]?.id; + const existingMessageId = existingMessage?.id; if (existingMessageId) { return Promise.resolve(existingMessageId); @@ -127,14 +137,18 @@ export class MessagingUtilsService { private async saveMessageThreadOrReturnExistingMessageThread( messageThreadExternalId: string, dataSourceMetadata: DataSourceEntity, - workspaceDataSource: DataSource, + workspaceId: string, + manager: EntityManager, ) { - const existingMessageThreads = await workspaceDataSource?.query( - `SELECT "messageChannelMessageAssociation"."messageThreadId" FROM ${dataSourceMetadata.schema}."messageChannelMessageAssociation" WHERE "messageThreadExternalId" = $1 LIMIT 1`, - [messageThreadExternalId], - ); + const existingMessageChannelMessageAssociationByMessageThreadExternalId = + await this.messageChannelMessageAssociationService.getFirstByMessageThreadExternalId( + messageThreadExternalId, + workspaceId, + manager, + ); - const existingMessageThread = existingMessageThreads[0]?.messageThreadId; + const existingMessageThread = + existingMessageChannelMessageAssociationByMessageThreadExternalId?.messageThreadId; if (existingMessageThread) { return Promise.resolve(existingMessageThread); @@ -142,7 +156,7 @@ export class MessagingUtilsService { const newMessageThreadId = v4(); - await workspaceDataSource?.query( + await manager.query( `INSERT INTO ${dataSourceMetadata.schema}."messageThread" ("id") VALUES ($1)`, [newMessageThreadId], ); @@ -190,95 +204,92 @@ export class MessagingUtilsService { } } - public async deleteMessageChannelMessageAssociations( - messageExternalIds: string[], - connectedAccountId: string, - dataSourceMetadata: DataSourceEntity, + public async deleteMessages( workspaceDataSource: DataSource, - ) { - await workspaceDataSource?.query( - `DELETE FROM ${dataSourceMetadata.schema}."messageChannelMessageAssociation" WHERE "messageExternalId" = ANY($1) AND "messageChannelId" = $2`, - [messageExternalIds, connectedAccountId], - ); - } - - public async getConnectedAccounts( - dataSourceMetadata: DataSourceEntity, - workspaceDataSource: DataSource, - ): Promise { - const connectedAccounts = await workspaceDataSource?.query( - `SELECT * FROM ${dataSourceMetadata.schema}."connectedAccount" WHERE "provider" = 'google'`, - ); - - return connectedAccounts; - } - - public async getConnectedAcountByIdOrFail( - connectedAccountId: string, - dataSourceMetadata: DataSourceEntity, - workspaceDataSource: DataSource, - ): Promise { - const connectedAccounts = await workspaceDataSource?.query( - `SELECT * FROM ${dataSourceMetadata.schema}."connectedAccount" WHERE "id" = $1`, - [connectedAccountId], - ); - - if (!connectedAccounts || connectedAccounts.length === 0) { - throw new Error('No connected account found'); - } - - return connectedAccounts[0]; - } - - public async getDataSourceMetadataWorkspaceMetadata( - workspaceId: string, - ): Promise<{ - dataSourceMetadata: DataSourceEntity; - workspaceDataSource: DataSource; - }> { - const dataSourceMetadata = - await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( - workspaceId, - ); - - const workspaceDataSource = - await this.typeORMService.connectToDataSource(dataSourceMetadata); - - if (!workspaceDataSource) { - throw new Error('No workspace data source found'); - } - - return { - dataSourceMetadata, - workspaceDataSource, - }; - } - - public async saveLastSyncHistoryId( - historyId: string, - connectedAccountId: string, - dataSourceMetadata: DataSourceEntity, - workspaceDataSource: DataSource, - ) { - await workspaceDataSource?.query( - `UPDATE ${dataSourceMetadata.schema}."connectedAccount" SET "lastSyncHistoryId" = $1 WHERE "id" = $2`, - [historyId, connectedAccountId], - ); - } - - public async getMessageChannelMessageAssociations( - messageExternalIds: string[], + messagesDeletedMessageExternalIds: string[], gmailMessageChannelId: string, - dataSourceMetadata: DataSourceEntity, - workspaceDataSource: DataSource, + workspaceId: string, ) { - const existingMessageChannelMessageAssociation = - await workspaceDataSource?.query( - `SELECT * FROM ${dataSourceMetadata.schema}."messageChannelMessageAssociation" - WHERE "messageExternalId" = ANY($1) AND "messageChannelId" = $2`, - [messageExternalIds, gmailMessageChannelId], + await workspaceDataSource?.transaction(async (manager: EntityManager) => { + const messageChannelMessageAssociationsToDelete = + await this.messageChannelMessageAssociationService.getByMessageExternalIdsAndMessageChannelId( + messagesDeletedMessageExternalIds, + gmailMessageChannelId, + workspaceId, + manager, + ); + + const messageChannelMessageAssociationIdsToDeleteIds = + messageChannelMessageAssociationsToDelete.map( + (messageChannelMessageAssociationToDelete) => + messageChannelMessageAssociationToDelete.id, + ); + + await this.messageChannelMessageAssociationService.deleteByIds( + messageChannelMessageAssociationIdsToDeleteIds, + workspaceId, + manager, ); - return existingMessageChannelMessageAssociation; + const messageIdsFromMessageChannelMessageAssociationsToDelete = + messageChannelMessageAssociationsToDelete.map( + (messageChannelMessageAssociationToDelete) => + messageChannelMessageAssociationToDelete.messageId, + ); + + const messageChannelMessageAssociationByMessageIds = + await this.messageChannelMessageAssociationService.getByMessageIds( + messageIdsFromMessageChannelMessageAssociationsToDelete, + workspaceId, + manager, + ); + + const messageIdsFromMessageChannelMessageAssociationByMessageIds = + messageChannelMessageAssociationByMessageIds.map( + (messageChannelMessageAssociation) => + messageChannelMessageAssociation.messageId, + ); + + const messageIdsToDelete = + messageIdsFromMessageChannelMessageAssociationsToDelete.filter( + (messageId) => + !messageIdsFromMessageChannelMessageAssociationByMessageIds.includes( + messageId, + ), + ); + + await this.messageService.deleteByIds( + messageIdsToDelete, + workspaceId, + manager, + ); + + const messageThreadIdsFromMessageChannelMessageAssociationsToDelete = + messageChannelMessageAssociationsToDelete.map( + (messageChannelMessageAssociationToDelete) => + messageChannelMessageAssociationToDelete.messageThreadId, + ); + + const messagesByThreadIds = + await this.messageService.getByMessageThreadIds( + messageThreadIdsFromMessageChannelMessageAssociationsToDelete, + workspaceId, + manager, + ); + + const threadIdsToDelete = + messageThreadIdsFromMessageChannelMessageAssociationsToDelete.filter( + (threadId) => + !messagesByThreadIds.find( + (message) => message.messageThreadId === threadId, + ), + ); + + await this.messageThreadService.deleteByIds( + threadIdsToDelete, + workspaceId, + manager, + ); + }); } } diff --git a/packages/twenty-server/src/workspace/messaging/types/gmailMessageParsedResponse.ts b/packages/twenty-server/src/workspace/messaging/types/gmail-message-parsed-response.ts similarity index 100% rename from packages/twenty-server/src/workspace/messaging/types/gmailMessageParsedResponse.ts rename to packages/twenty-server/src/workspace/messaging/types/gmail-message-parsed-response.ts diff --git a/packages/twenty-server/src/workspace/messaging/types/gmailMessage.ts b/packages/twenty-server/src/workspace/messaging/types/gmail-message.ts similarity index 100% rename from packages/twenty-server/src/workspace/messaging/types/gmailMessage.ts rename to packages/twenty-server/src/workspace/messaging/types/gmail-message.ts diff --git a/packages/twenty-server/src/workspace/messaging/types/gmailThread.ts b/packages/twenty-server/src/workspace/messaging/types/gmail-thread.ts similarity index 100% rename from packages/twenty-server/src/workspace/messaging/types/gmailThread.ts rename to packages/twenty-server/src/workspace/messaging/types/gmail-thread.ts diff --git a/packages/twenty-server/src/workspace/messaging/types/messageOrThreadQuery.ts b/packages/twenty-server/src/workspace/messaging/types/message-or-thread-query.ts similarity index 100% rename from packages/twenty-server/src/workspace/messaging/types/messageOrThreadQuery.ts rename to packages/twenty-server/src/workspace/messaging/types/message-or-thread-query.ts diff --git a/packages/twenty-server/src/workspace/workspace-datasource/workspace-datasource.service.ts b/packages/twenty-server/src/workspace/workspace-datasource/workspace-datasource.service.ts index 8d78c5bdf4d..350ec645aba 100644 --- a/packages/twenty-server/src/workspace/workspace-datasource/workspace-datasource.service.ts +++ b/packages/twenty-server/src/workspace/workspace-datasource/workspace-datasource.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, EntityManager } from 'typeorm'; import { DataSourceService } from 'src/metadata/data-source/data-source.service'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; @@ -22,6 +22,15 @@ export class WorkspaceDataSourceService { public async connectToWorkspaceDataSource( workspaceId: string, ): Promise { + const { dataSource } = + await this.connectedToWorkspaceDataSourceAndReturnMetadata(workspaceId); + + return dataSource; + } + + public async connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId: string, + ) { const dataSourceMetadata = await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( workspaceId, @@ -36,7 +45,7 @@ export class WorkspaceDataSourceService { ); } - return dataSource; + return { dataSource, dataSourceMetadata }; } /** @@ -68,6 +77,7 @@ export class WorkspaceDataSourceService { /** * * Get the schema name for a workspace + * Note: This is assuming that the workspace only has one schema but we should prefer querying the metadata table instead. * * @param workspaceId * @returns string @@ -97,4 +107,23 @@ export class WorkspaceDataSourceService { return `${devId ? 'twenty_' : ''}${base36String}`; } + + public async executeRawQuery( + query: string, + parameters: any[] = [], + workspaceId: string, + transactionManager?: EntityManager, + ): Promise { + if (transactionManager) { + return await transactionManager.query(query, parameters); + } + const workspaceDataSource = + await this.connectToWorkspaceDataSource(workspaceId); + + if (workspaceDataSource) { + return await workspaceDataSource.query(query, parameters); + } + + throw new Error('No data source found or transaction manager provided'); + } } diff --git a/packages/twenty-server/src/workspace/workspace-manager/demo-objects-prefill-data/opportunity.ts b/packages/twenty-server/src/workspace/workspace-manager/demo-objects-prefill-data/opportunity.ts index 3c57fdf37ec..b8bb6caa3be 100644 --- a/packages/twenty-server/src/workspace/workspace-manager/demo-objects-prefill-data/opportunity.ts +++ b/packages/twenty-server/src/workspace/workspace-manager/demo-objects-prefill-data/opportunity.ts @@ -38,6 +38,7 @@ const generateOpportunities = ( amountCurrencyCode: 'USD', closeDate: new Date(), stage: getRandomStage(), + recordPosition: null, probability: getRandomProbability(), pipelineStepId: getRandomPipelineStepId(pipelineStepIds), pointOfContactId: company.personId, @@ -73,6 +74,7 @@ export const seedDemoOpportunity = async ( 'amountCurrencyCode', 'closeDate', 'stage', + 'recordPosition', 'probability', 'pipelineStepId', 'pointOfContactId', diff --git a/packages/twenty-server/src/workspace/workspace-manager/demo-objects-prefill-data/person.ts b/packages/twenty-server/src/workspace/workspace-manager/demo-objects-prefill-data/person.ts index 8ce4fd716af..be8c5eaa387 100644 --- a/packages/twenty-server/src/workspace/workspace-manager/demo-objects-prefill-data/person.ts +++ b/packages/twenty-server/src/workspace/workspace-manager/demo-objects-prefill-data/person.ts @@ -19,6 +19,7 @@ export const personPrefillData = async ( jobTitle: person.jobTitle, city: person.city, avatarUrl: person.avatarUrl, + recordPosition: null, companyId: companies[Math.floor(index / 2)].id, })); @@ -33,6 +34,7 @@ export const personPrefillData = async ( 'jobTitle', 'city', 'avatarUrl', + 'recordPosition', 'companyId', ]) .orIgnore() diff --git a/packages/twenty-server/src/workspace/workspace-manager/workspace-manager.service.ts b/packages/twenty-server/src/workspace/workspace-manager/workspace-manager.service.ts index 96b4fa8a8c6..abe4ef2325a 100644 --- a/packages/twenty-server/src/workspace/workspace-manager/workspace-manager.service.ts +++ b/packages/twenty-server/src/workspace/workspace-manager/workspace-manager.service.ts @@ -7,7 +7,7 @@ import { standardObjectsPrefillData } from 'src/workspace/workspace-manager/stan import { demoObjectsPrefillData } from 'src/workspace/workspace-manager/demo-objects-prefill-data/demo-objects-prefill-data'; import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; import { DataSourceEntity } from 'src/metadata/data-source/data-source.entity'; -import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync.metadata.service'; +import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync-metadata.service'; @Injectable() export class WorkspaceManagerService { @@ -39,8 +39,10 @@ export class WorkspaceManagerService { await this.setWorkspaceMaxRow(workspaceId, schemaName); await this.workspaceSyncMetadataService.syncStandardObjectsAndFieldsMetadata( - dataSourceMetadata.id, - workspaceId, + { + workspaceId, + dataSourceId: dataSourceMetadata.id, + }, ); await this.prefillWorkspaceWithStandardObjects( @@ -69,8 +71,10 @@ export class WorkspaceManagerService { await this.setWorkspaceMaxRow(workspaceId, schemaName); await this.workspaceSyncMetadataService.syncStandardObjectsAndFieldsMetadata( - dataSourceMetadata.id, - workspaceId, + { + workspaceId, + dataSourceId: dataSourceMetadata.id, + }, ); await this.prefillWorkspaceWithDemoObjects(dataSourceMetadata, workspaceId); diff --git a/packages/twenty-server/src/workspace/workspace-migration-runner/commands/workspace-execute-pending-migrations.command.ts b/packages/twenty-server/src/workspace/workspace-migration-runner/commands/workspace-execute-pending-migrations.command.ts new file mode 100644 index 00000000000..5b6ccb61496 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-migration-runner/commands/workspace-execute-pending-migrations.command.ts @@ -0,0 +1,37 @@ +import { Command, CommandRunner, Option } from 'nest-commander'; + +import { WorkspaceMigrationRunnerService } from 'src/workspace/workspace-migration-runner/workspace-migration-runner.service'; + +interface ExecuteWorkspaceMigrationsOptions { + workspaceId: string; +} + +@Command({ + name: 'workspace:apply-pending-migrations', + description: 'Apply pending migrations', +}) +export class WorkspaceExecutePendingMigrationsCommand extends CommandRunner { + constructor( + private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, + ) { + super(); + } + + async run( + _passedParam: string[], + options: ExecuteWorkspaceMigrationsOptions, + ): Promise { + await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( + options.workspaceId, + ); + } + + @Option({ + flags: '-w, --workspace-id [workspace_id]', + description: 'workspace id', + required: true, + }) + parseWorkspaceId(value: string): string { + return value; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-migration-runner/commands/workspace-sync-metadata-commands.module.ts b/packages/twenty-server/src/workspace/workspace-migration-runner/commands/workspace-sync-metadata-commands.module.ts new file mode 100644 index 00000000000..892afd2c10c --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-migration-runner/commands/workspace-sync-metadata-commands.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerModule } from 'src/workspace/workspace-migration-runner/workspace-migration-runner.module'; + +import { WorkspaceExecutePendingMigrationsCommand } from './workspace-execute-pending-migrations.command'; + +@Module({ + imports: [WorkspaceMigrationRunnerModule], + providers: [WorkspaceExecutePendingMigrationsCommand], +}) +export class WorkspaceMigrationRunnerCommandsModule {} diff --git a/packages/twenty-server/src/workspace/workspace-migration-runner/workspace-migration-runner.service.ts b/packages/twenty-server/src/workspace/workspace-migration-runner/workspace-migration-runner.service.ts index 9fe8fcf6de3..0325778dc2b 100644 --- a/packages/twenty-server/src/workspace/workspace-migration-runner/workspace-migration-runner.service.ts +++ b/packages/twenty-server/src/workspace/workspace-migration-runner/workspace-migration-runner.service.ts @@ -63,13 +63,25 @@ export class WorkspaceMigrationRunnerService { }, []); const queryRunner = workspaceDataSource?.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + const schemaName = this.workspaceDataSourceService.getSchemaName(workspaceId); - // Loop over each migration and create or update the table - // TODO: Should be done in a transaction - for (const migration of flattenedPendingMigrations) { - await this.handleTableChanges(queryRunner, schemaName, migration); + try { + // Loop over each migration and create or update the table + for (const migration of flattenedPendingMigrations) { + await this.handleTableChanges(queryRunner, schemaName, migration); + } + + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); } // Update appliedAt date for each migration @@ -81,8 +93,6 @@ export class WorkspaceMigrationRunnerService { ); } - await queryRunner.release(); - // Increment workspace cache version await this.workspaceCacheVersionService.incrementVersion(workspaceId); diff --git a/packages/twenty-server/src/workspace/workspace-query-runner/workspace-query-runner.service.ts b/packages/twenty-server/src/workspace/workspace-query-runner/workspace-query-runner.service.ts index 3e455a39aad..2cb012fa17d 100644 --- a/packages/twenty-server/src/workspace/workspace-query-runner/workspace-query-runner.service.ts +++ b/packages/twenty-server/src/workspace/workspace-query-runner/workspace-query-runner.service.ts @@ -35,7 +35,6 @@ import { } from 'src/workspace/workspace-query-runner/jobs/call-webhook-jobs.job'; import { parseResult } from 'src/workspace/workspace-query-runner/utils/parse-result.util'; import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service'; -import { handleExceptionAndConvertToGraphQLError } from 'src/filters/utils/global-exception-handler.util'; import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util'; import { WorkspaceQueryRunnerOptions } from './interfaces/query-runner-optionts.interface'; @@ -64,37 +63,28 @@ export class WorkspaceQueryRunnerService { args: FindManyResolverArgs, options: WorkspaceQueryRunnerOptions, ): Promise | undefined> { - try { - const { workspaceId, objectMetadataItem } = options; - const start = performance.now(); + const { workspaceId, objectMetadataItem } = options; + const start = performance.now(); - const query = await this.workspaceQueryBuilderFactory.findMany( - args, - options, - ); + const query = await this.workspaceQueryBuilderFactory.findMany( + args, + options, + ); - const result = await this.execute(query, workspaceId); - const end = performance.now(); + const result = await this.execute(query, workspaceId); + const end = performance.now(); - console.log( - `query time: ${end - start} ms on query ${ - options.objectMetadataItem.nameSingular - }`, - ); + console.log( + `query time: ${end - start} ms on query ${ + options.objectMetadataItem.nameSingular + }`, + ); - return this.parseResult>( - result, - objectMetadataItem, - '', - ); - } catch (exception) { - const error = handleExceptionAndConvertToGraphQLError( - exception, - this.exceptionHandlerService, - ); - - return Promise.reject(error); - } + return this.parseResult>( + result, + objectMetadataItem, + '', + ); } async findOne< @@ -104,67 +94,49 @@ export class WorkspaceQueryRunnerService { args: FindOneResolverArgs, options: WorkspaceQueryRunnerOptions, ): Promise { - try { - if (!args.filter || Object.keys(args.filter).length === 0) { - throw new BadRequestException('Missing filter argument'); - } - const { workspaceId, objectMetadataItem } = options; - const query = await this.workspaceQueryBuilderFactory.findOne( - args, - options, - ); - const result = await this.execute(query, workspaceId); - const parsedResult = this.parseResult>( - result, - objectMetadataItem, - '', - ); - - return parsedResult?.edges?.[0]?.node; - } catch (exception) { - const error = handleExceptionAndConvertToGraphQLError( - exception, - this.exceptionHandlerService, - ); - - return Promise.reject(error); + if (!args.filter || Object.keys(args.filter).length === 0) { + throw new BadRequestException('Missing filter argument'); } + const { workspaceId, objectMetadataItem } = options; + const query = await this.workspaceQueryBuilderFactory.findOne( + args, + options, + ); + const result = await this.execute(query, workspaceId); + const parsedResult = this.parseResult>( + result, + objectMetadataItem, + '', + ); + + return parsedResult?.edges?.[0]?.node; } async createMany( args: CreateManyResolverArgs, options: WorkspaceQueryRunnerOptions, ): Promise { - try { - const { workspaceId, objectMetadataItem } = options; - const query = await this.workspaceQueryBuilderFactory.createMany( - args, - options, - ); + const { workspaceId, objectMetadataItem } = options; + const query = await this.workspaceQueryBuilderFactory.createMany( + args, + options, + ); - const result = await this.execute(query, workspaceId); + const result = await this.execute(query, workspaceId); - const parsedResults = this.parseResult>( - result, - objectMetadataItem, - 'insertInto', - )?.records; + const parsedResults = this.parseResult>( + result, + objectMetadataItem, + 'insertInto', + )?.records; - await this.triggerWebhooks( - parsedResults, - CallWebhookJobsJobOperation.create, - options, - ); + await this.triggerWebhooks( + parsedResults, + CallWebhookJobsJobOperation.create, + options, + ); - return parsedResults; - } catch (exception) { - const error = handleExceptionAndConvertToGraphQLError( - exception, - this.exceptionHandlerService, - ); - - return Promise.reject(error); - } + return parsedResults; } async createOne( @@ -180,105 +152,78 @@ export class WorkspaceQueryRunnerService { args: UpdateOneResolverArgs, options: WorkspaceQueryRunnerOptions, ): Promise { - try { - const { workspaceId, objectMetadataItem } = options; - const query = await this.workspaceQueryBuilderFactory.updateOne( - args, - options, - ); - const result = await this.execute(query, workspaceId); + const { workspaceId, objectMetadataItem } = options; + const query = await this.workspaceQueryBuilderFactory.updateOne( + args, + options, + ); + const result = await this.execute(query, workspaceId); - const parsedResults = this.parseResult>( - result, - objectMetadataItem, - 'update', - )?.records; + const parsedResults = this.parseResult>( + result, + objectMetadataItem, + 'update', + )?.records; - await this.triggerWebhooks( - parsedResults, - CallWebhookJobsJobOperation.update, - options, - ); + await this.triggerWebhooks( + parsedResults, + CallWebhookJobsJobOperation.update, + options, + ); - return parsedResults?.[0]; - } catch (exception) { - const error = handleExceptionAndConvertToGraphQLError( - exception, - this.exceptionHandlerService, - ); - - return Promise.reject(error); - } + return parsedResults?.[0]; } async deleteOne( args: DeleteOneResolverArgs, options: WorkspaceQueryRunnerOptions, ): Promise { - try { - const { workspaceId, objectMetadataItem } = options; - const query = await this.workspaceQueryBuilderFactory.deleteOne( - args, - options, - ); - const result = await this.execute(query, workspaceId); + const { workspaceId, objectMetadataItem } = options; + const query = await this.workspaceQueryBuilderFactory.deleteOne( + args, + options, + ); + const result = await this.execute(query, workspaceId); - const parsedResults = this.parseResult>( - result, - objectMetadataItem, - 'deleteFrom', - )?.records; + const parsedResults = this.parseResult>( + result, + objectMetadataItem, + 'deleteFrom', + )?.records; - await this.triggerWebhooks( - parsedResults, - CallWebhookJobsJobOperation.delete, - options, - ); + await this.triggerWebhooks( + parsedResults, + CallWebhookJobsJobOperation.delete, + options, + ); - return parsedResults?.[0]; - } catch (exception) { - const error = handleExceptionAndConvertToGraphQLError( - exception, - this.exceptionHandlerService, - ); - - return Promise.reject(error); - } + return parsedResults?.[0]; } async updateMany( args: UpdateManyResolverArgs, options: WorkspaceQueryRunnerOptions, ): Promise { - try { - const { workspaceId, objectMetadataItem } = options; - const query = await this.workspaceQueryBuilderFactory.updateMany( - args, - options, - ); - const result = await this.execute(query, workspaceId); + const { workspaceId, objectMetadataItem } = options; + const query = await this.workspaceQueryBuilderFactory.updateMany( + args, + options, + ); + const result = await this.execute(query, workspaceId); - const parsedResults = this.parseResult>( - result, - objectMetadataItem, - 'update', - )?.records; + const parsedResults = this.parseResult>( + result, + objectMetadataItem, + 'update', + )?.records; - await this.triggerWebhooks( - parsedResults, - CallWebhookJobsJobOperation.update, - options, - ); + await this.triggerWebhooks( + parsedResults, + CallWebhookJobsJobOperation.update, + options, + ); - return parsedResults; - } catch (exception) { - const error = handleExceptionAndConvertToGraphQLError( - exception, - this.exceptionHandlerService, - ); - - return Promise.reject(error); - } + return parsedResults; } async deleteMany< @@ -288,35 +233,26 @@ export class WorkspaceQueryRunnerService { args: DeleteManyResolverArgs, options: WorkspaceQueryRunnerOptions, ): Promise { - try { - const { workspaceId, objectMetadataItem } = options; - const query = await this.workspaceQueryBuilderFactory.deleteMany( - args, - options, - ); - const result = await this.execute(query, workspaceId); + const { workspaceId, objectMetadataItem } = options; + const query = await this.workspaceQueryBuilderFactory.deleteMany( + args, + options, + ); + const result = await this.execute(query, workspaceId); - const parsedResults = this.parseResult>( - result, - objectMetadataItem, - 'deleteFrom', - )?.records; + const parsedResults = this.parseResult>( + result, + objectMetadataItem, + 'deleteFrom', + )?.records; - await this.triggerWebhooks( - parsedResults, - CallWebhookJobsJobOperation.delete, - options, - ); + await this.triggerWebhooks( + parsedResults, + CallWebhookJobsJobOperation.delete, + options, + ); - return parsedResults; - } catch (exception) { - const error = handleExceptionAndConvertToGraphQLError( - exception, - this.exceptionHandlerService, - ); - - return Promise.reject(error); - } + return parsedResults; } async execute( diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/commands/sync-workspace-metadata.command.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/commands/sync-workspace-metadata.command.ts index d680d09095d..949cbb716d9 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/commands/sync-workspace-metadata.command.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/commands/sync-workspace-metadata.command.ts @@ -1,11 +1,12 @@ import { Command, CommandRunner, Option } from 'nest-commander'; import { DataSourceService } from 'src/metadata/data-source/data-source.service'; -import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync.metadata.service'; +import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync-metadata.service'; // TODO: implement dry-run interface RunWorkspaceMigrationsOptions { workspaceId: string; + dryRun?: boolean; } @Command({ @@ -31,8 +32,11 @@ export class SyncWorkspaceMetadataCommand extends CommandRunner { ); await this.workspaceSyncMetadataService.syncStandardObjectsAndFieldsMetadata( - dataSourceMetadata.id, - options.workspaceId, + { + workspaceId: options.workspaceId, + dataSourceId: dataSourceMetadata.id, + }, + { dryRun: options.dryRun }, ); } @@ -44,4 +48,13 @@ export class SyncWorkspaceMetadataCommand extends CommandRunner { parseWorkspaceId(value: string): string { return value; } + + @Option({ + flags: '-d, --dry-run', + description: 'Dry run without applying changes', + required: false, + }) + dryRun(): boolean { + return true; + } } diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-field.comparator.spec.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-field.comparator.spec.ts new file mode 100644 index 00000000000..28bd2560a98 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-field.comparator.spec.ts @@ -0,0 +1,106 @@ +import { ComparatorAction } from 'src/workspace/workspace-sync-metadata/interfaces/comparator.interface'; + +import { WorkspaceFieldComparator } from 'src/workspace/workspace-sync-metadata/comparators/workspace-field.comparator'; + +describe('WorkspaceFieldComparator', () => { + let comparator: WorkspaceFieldComparator; + + beforeEach(() => { + // Initialize the comparator before each test + comparator = new WorkspaceFieldComparator(); + }); + + function createMockFieldMetadata(values: any) { + return { + workspaceId: 'some-workspace-id', + type: 'TEXT', + name: 'DefaultFieldName', + label: 'Default Field Label', + targetColumnMap: 'default_column', + defaultValue: null, + description: 'Default description', + isCustom: false, + isSystem: false, + isNullable: true, + ...values, + }; + } + + it('should generate CREATE action for new fields', () => { + const original = { fields: [] } as any; + const standard = { + fields: [createMockFieldMetadata({ name: 'New Field' })], + } as any; + + const result = comparator.compare(original, standard); + + expect(result).toEqual([ + { + action: ComparatorAction.CREATE, + object: expect.objectContaining(standard.fields[0]), + }, + ]); + }); + + it('should generate UPDATE action for modified fields', () => { + const original = { + fields: [ + createMockFieldMetadata({ + id: '1', + isNullable: true, + }), + ], + } as any; + const standard = { + fields: [ + createMockFieldMetadata({ + isNullable: false, + }), + ], + } as any; + + const result = comparator.compare(original, standard); + + expect(result).toEqual([ + { + action: ComparatorAction.UPDATE, + object: expect.objectContaining({ id: '1', isNullable: false }), + }, + ]); + }); + + it('should generate DELETE action for removed fields', () => { + const original = { + fields: [ + createMockFieldMetadata({ + id: '1', + name: 'Removed Field', + isActive: true, + }), + ], + } as any; + const standard = { fields: [] } as any; + + const result = comparator.compare(original, standard); + + expect(result).toEqual([ + { + action: ComparatorAction.DELETE, + object: expect.objectContaining({ name: 'Removed Field' }), + }, + ]); + }); + + it('should not generate any action for identical fields', () => { + const original = { + fields: [createMockFieldMetadata({ id: '1', isActive: true })], + } as any; + const standard = { + fields: [createMockFieldMetadata({})], + } as any; + + const result = comparator.compare(original, standard); + + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-object.comparator.spec.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-object.comparator.spec.ts new file mode 100644 index 00000000000..a92a297cca3 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-object.comparator.spec.ts @@ -0,0 +1,77 @@ +import { ComparatorAction } from 'src/workspace/workspace-sync-metadata/interfaces/comparator.interface'; + +import { WorkspaceObjectComparator } from 'src/workspace/workspace-sync-metadata/comparators/workspace-object.comparator'; + +describe('WorkspaceObjectComparator', () => { + let comparator: WorkspaceObjectComparator; + + beforeEach(() => { + // Initialize the comparator before each test + comparator = new WorkspaceObjectComparator(); + }); + + function createMockObjectMetadata(values: any) { + return { + nameSingular: 'TestObject', + namePlural: 'TestObjects', + labelSingular: 'Test Object', + labelPlural: 'Test Objects', + ...values, + }; + } + + it('should generate CREATE action for new objects', () => { + const standardObjectMetadata = createMockObjectMetadata({ + description: 'A standard object', + }); + + const result = comparator.compare(undefined, standardObjectMetadata); + + expect(result).toEqual({ + action: ComparatorAction.CREATE, + object: standardObjectMetadata, + }); + }); + + it('should generate UPDATE action for objects with differences', () => { + const originalObjectMetadata = createMockObjectMetadata({ + id: '1', + description: 'Original description', + }); + const standardObjectMetadata = createMockObjectMetadata({ + description: 'Updated description', + }); + + const result = comparator.compare( + originalObjectMetadata, + standardObjectMetadata, + ); + + expect(result).toEqual({ + action: ComparatorAction.UPDATE, + object: expect.objectContaining({ + id: '1', + description: 'Updated description', + }), + }); + }); + + it('should generate SKIP action for identical objects', () => { + const originalObjectMetadata = createMockObjectMetadata({ + id: '1', + description: 'Same description', + }); + const standardObjectMetadata = createMockObjectMetadata({ + description: 'Same description', + }); + + const result = comparator.compare( + originalObjectMetadata, + standardObjectMetadata, + ); + + expect(result).toEqual({ + action: ComparatorAction.SKIP, + }); + }); +}); diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-relation.comparator.spec.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-relation.comparator.spec.ts new file mode 100644 index 00000000000..09b602617d9 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-relation.comparator.spec.ts @@ -0,0 +1,60 @@ +import { ComparatorAction } from 'src/workspace/workspace-sync-metadata/interfaces/comparator.interface'; + +import { WorkspaceRelationComparator } from 'src/workspace/workspace-sync-metadata/comparators/workspace-relation.comparator'; + +describe('WorkspaceRelationComparator', () => { + let comparator: WorkspaceRelationComparator; + + beforeEach(() => { + comparator = new WorkspaceRelationComparator(); + }); + + function createMockRelationMetadata(values: any) { + return { + fromObjectMetadataId: 'object-1', + fromFieldMetadataId: 'field-1', + ...values, + }; + } + + it('should generate CREATE action for new relations', () => { + const original = []; + const standard = [createMockRelationMetadata({})]; + + const result = comparator.compare(original, standard); + + expect(result).toEqual([ + { + action: ComparatorAction.CREATE, + object: expect.objectContaining({ + fromObjectMetadataId: 'object-1', + fromFieldMetadataId: 'field-1', + }), + }, + ]); + }); + + it('should generate DELETE action for removed relations', () => { + const original = [createMockRelationMetadata({ id: '1' })]; + const standard = []; + + const result = comparator.compare(original, standard); + + expect(result).toEqual([ + { + action: ComparatorAction.DELETE, + object: expect.objectContaining({ id: '1' }), + }, + ]); + }); + + it('should not generate any action for identical relations', () => { + const relation = createMockRelationMetadata({}); + const original = [{ id: '1', ...relation }]; + const standard = [relation]; + + const result = comparator.compare(original, standard); + + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/index.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/index.ts new file mode 100644 index 00000000000..3c95f7babe3 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/index.ts @@ -0,0 +1,9 @@ +import { WorkspaceFieldComparator } from './workspace-field.comparator'; +import { WorkspaceObjectComparator } from './workspace-object.comparator'; +import { WorkspaceRelationComparator } from './workspace-relation.comparator'; + +export const workspaceSyncMetadataComparators = [ + WorkspaceFieldComparator, + WorkspaceObjectComparator, + WorkspaceRelationComparator, +]; diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/order-object-properties.util.spec.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/order-object-properties.util.spec.ts new file mode 100644 index 00000000000..60309e8b73a --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/order-object-properties.util.spec.ts @@ -0,0 +1,52 @@ +import { orderObjectProperties } from 'src/workspace/workspace-sync-metadata/comparators/utils/order-object-properties.util'; + +describe('orderObjectProperties', () => { + it('orders simple object properties', () => { + const input = { b: 2, a: 1 }; + const expected = { a: 1, b: 2 }; + + expect(orderObjectProperties(input)).toEqual(expected); + }); + + it('orders nested object properties', () => { + const input = { b: { d: 4, c: 3 }, a: 1 }; + const expected = { a: 1, b: { c: 3, d: 4 } }; + + expect(orderObjectProperties(input)).toEqual(expected); + }); + + it('orders properties in an array of objects', () => { + const input = [ + { b: 2, a: 1 }, + { d: 4, c: 3 }, + ]; + const expected = [ + { a: 1, b: 2 }, + { c: 3, d: 4 }, + ]; + + expect(orderObjectProperties(input)).toEqual(expected); + }); + + it('handles nested arrays within objects', () => { + const input = { b: [{ d: 4, c: 3 }], a: 1 }; + const expected = { a: 1, b: [{ c: 3, d: 4 }] }; + + expect(orderObjectProperties(input)).toEqual(expected); + }); + + it('handles complex nested structures', () => { + const input = { + c: 3, + a: { f: [{ j: 10, i: 9 }, 8], e: 5 }, + b: [7, { h: 6, g: 4 }], + }; + const expected = { + a: { e: 5, f: [{ i: 9, j: 10 }, 8] }, + b: [7, { g: 4, h: 6 }], + c: 3, + }; + + expect(orderObjectProperties(input)).toEqual(expected); + }); +}); diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/transform-metadata-for-comparison.util.spec.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/transform-metadata-for-comparison.util.spec.ts new file mode 100644 index 00000000000..eac21ff46fc --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/transform-metadata-for-comparison.util.spec.ts @@ -0,0 +1,59 @@ +import { transformMetadataForComparison } from 'src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util'; // Adjust the import path as necessary + +describe('transformMetadataForComparison', () => { + // Test for a single object + it('transforms a single object correctly with nested objects', () => { + const input = { name: 'Test', details: { a: 1, nested: { b: 2 } } }; + const result = transformMetadataForComparison(input, { + propertiesToStringify: ['details'], + }); + + expect(result).toEqual({ + name: 'Test', + details: '{"a":1,"nested":{"b":2}}', + }); + }); + + // Test for an array of objects + it('transforms an array of objects correctly, ignoring and stringifying multiple properties', () => { + const input = [ + { name: 'Test1', value: { a: 1 }, ignored: 'ignoreMe' }, + { name: 'Test2', value: { c: 3 }, extra: 'keepMe' }, + ]; + const result = transformMetadataForComparison(input, { + propertiesToIgnore: ['ignored'], + propertiesToStringify: ['value'], + keyFactory: (datum) => datum.name, + }); + + expect(result).toEqual({ + Test1: { name: 'Test1', value: '{"a":1}' }, + Test2: { name: 'Test2', value: '{"c":3}', extra: 'keepMe' }, + }); + }); + + // Test with a custom keyFactory function + it('uses a custom keyFactory function to generate keys', () => { + const input = [{ id: 123, name: 'Test' }]; + const result = transformMetadataForComparison(input, { + keyFactory: (datum) => `key-${datum.id}`, + }); + + expect(result).toHaveProperty('key-123'); + expect(result['key-123']).toEqual({ id: 123, name: 'Test' }); + }); + + // Test with an empty array + it('handles an empty array gracefully', () => { + const result = transformMetadataForComparison([], {}); + + expect(result).toEqual({}); + }); + + // Test with an empty object + it('handles an empty object gracefully', () => { + const result = transformMetadataForComparison({}, {}); + + expect(result).toEqual({}); + }); +}); diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/order-object-properties.util.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/order-object-properties.util.ts new file mode 100644 index 00000000000..1ac4acdde9d --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/order-object-properties.util.ts @@ -0,0 +1,21 @@ +export function orderObjectProperties(data: T[]): T[]; + +export function orderObjectProperties(data: T): T; + +export function orderObjectProperties | object>( + data: T, +): T { + if (Array.isArray(data)) { + return data.map(orderObjectProperties) as T; + } + + if (data !== null && typeof data === 'object') { + return Object.fromEntries( + Object.entries(data) + .sort() + .map(([key, value]) => [key, orderObjectProperties(value)]), + ) as T; + } + + return data; +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util.ts new file mode 100644 index 00000000000..963b568125f --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util.ts @@ -0,0 +1,82 @@ +import { orderObjectProperties } from './order-object-properties.util'; + +type TransformToString = { + [P in keyof T]: P extends Keys ? string : T[P]; +}; + +// Overload for an array of T +export function transformMetadataForComparison( + fieldMetadataCollection: T[], + options: { + propertiesToIgnore?: readonly Keys[]; + propertiesToStringify?: readonly Keys[]; + keyFactory: (datum: T) => string; + }, +): Record>; + +// Overload for a single T object +export function transformMetadataForComparison( + fieldMetadataCollection: T, + options: { + propertiesToIgnore?: readonly Keys[]; + propertiesToStringify?: readonly Keys[]; + }, +): TransformToString; + +export function transformMetadataForComparison( + metadata: T[] | T, + options: { + propertiesToIgnore?: readonly Keys[]; + propertiesToStringify?: readonly Keys[]; + keyFactory?: (datum: T) => string; + }, +): Record> | TransformToString { + const propertiesToIgnore = (options.propertiesToIgnore ?? + []) as readonly string[]; + const propertiesToStringify = (options.propertiesToStringify ?? + []) as readonly string[]; + + const transformProperties = (datum: T): TransformToString => { + const transformedField = {} as TransformToString; + + for (const property in datum) { + if (propertiesToIgnore.includes(property)) { + continue; + } + if ( + propertiesToStringify.includes(property) && + datum[property] !== null && + typeof datum[property] === 'object' + ) { + const orderedValue = orderObjectProperties(datum[property] as object); + + transformedField[property as string] = JSON.stringify( + orderedValue, + ) as T[Keys]; + } else { + transformedField[property as string] = datum[property]; + } + } + + return transformedField; + }; + + if (Array.isArray(metadata)) { + return metadata.reduce>>( + (acc, datum) => { + const key = options.keyFactory?.(datum); + + if (!key) { + throw new Error('keyFactory must be implemented'); + } + + acc[key] = transformProperties(datum); + + return acc; + }, + {}, + ); + } else { + return transformProperties(metadata); + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-field.comparator.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-field.comparator.ts new file mode 100644 index 00000000000..0cc2e222ecf --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-field.comparator.ts @@ -0,0 +1,169 @@ +import { Injectable } from '@nestjs/common'; + +import diff from 'microdiff'; + +import { + ComparatorAction, + FieldComparatorResult, +} from 'src/workspace/workspace-sync-metadata/interfaces/comparator.interface'; +import { PartialFieldMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-field-metadata.interface'; +import { PartialObjectMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface'; + +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { transformMetadataForComparison } from 'src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util'; + +const fieldPropertiesToIgnore = [ + 'id', + 'createdAt', + 'updatedAt', + 'objectMetadataId', + 'isActive', +] as const; + +const fieldPropertiesToStringify = [ + 'targetColumnMap', + 'defaultValue', + 'options', +] as const; + +@Injectable() +export class WorkspaceFieldComparator { + constructor() {} + + public compare( + originalObjectMetadata: ObjectMetadataEntity, + standardObjectMetadata: PartialObjectMetadata, + ): FieldComparatorResult[] { + const result: FieldComparatorResult[] = []; + const fieldPropertiesToUpdateMap: Record< + string, + Partial + > = {}; + // Double security to only compare non-custom fields + const filteredOriginalFieldCollection = + originalObjectMetadata.fields.filter((field) => !field.isCustom); + const originalFieldMetadataMap = transformMetadataForComparison( + filteredOriginalFieldCollection, + { + propertiesToIgnore: fieldPropertiesToIgnore, + propertiesToStringify: fieldPropertiesToStringify, + keyFactory(datum) { + return datum.name; + }, + }, + ); + const standardFieldMetadataMap = transformMetadataForComparison( + standardObjectMetadata.fields, + { + propertiesToIgnore: fieldPropertiesToStringify, + keyFactory(datum) { + return datum.name; + }, + }, + ); + + // Compare fields + const fieldMetadataDifference = diff( + originalFieldMetadataMap, + standardFieldMetadataMap, + ); + + for (const difference of fieldMetadataDifference) { + const fieldName = difference.path[0]; + // Object shouldn't have thousands of fields, so we can use find here + const standardFieldMetadata = standardObjectMetadata.fields.find( + (field) => field.name === fieldName, + ); + const originalFieldMetadata = originalObjectMetadata.fields.find( + (field) => field.name === fieldName, + ); + + switch (difference.type) { + case 'CREATE': { + if (!standardFieldMetadata) { + throw new Error( + `Field ${fieldName} not found in standardObjectMetadata`, + ); + } + + result.push({ + action: ComparatorAction.CREATE, + object: { + ...standardFieldMetadata, + objectMetadataId: originalObjectMetadata.id, + }, + }); + break; + } + case 'CHANGE': { + if (!originalFieldMetadata) { + throw new Error( + `Field ${fieldName} not found in originalObjectMetadata`, + ); + } + + const id = originalFieldMetadata.id; + const property = difference.path[difference.path.length - 1]; + + // If the old value and the new value are both null, skip + // Database is storing null, and we can get undefined here + if ( + difference.oldValue === null && + (difference.value === null || difference.value === undefined) + ) { + break; + } + + if (typeof property !== 'string') { + break; + } + + if (!fieldPropertiesToUpdateMap[id]) { + fieldPropertiesToUpdateMap[id] = {}; + } + + // If the property is a stringified JSON, parse it + if ( + (fieldPropertiesToStringify as readonly string[]).includes(property) + ) { + fieldPropertiesToUpdateMap[id][property] = JSON.parse( + difference.value, + ); + } else { + fieldPropertiesToUpdateMap[id][property] = difference.value; + } + break; + } + case 'REMOVE': { + if (!originalFieldMetadata) { + throw new Error( + `Field ${fieldName} not found in originalObjectMetadata`, + ); + } + + if (difference.path.length === 1) { + result.push({ + action: ComparatorAction.DELETE, + object: originalFieldMetadata, + }); + } + break; + } + } + } + + for (const [id, fieldPropertiesToUpdate] of Object.entries( + fieldPropertiesToUpdateMap, + )) { + result.push({ + action: ComparatorAction.UPDATE, + object: { + id, + ...fieldPropertiesToUpdate, + }, + }); + } + + return result; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-object.comparator.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-object.comparator.ts new file mode 100644 index 00000000000..bcbd562703f --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-object.comparator.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; + +import diff from 'microdiff'; +import omit from 'lodash.omit'; + +import { + ComparatorAction, + ObjectComparatorResult, +} from 'src/workspace/workspace-sync-metadata/interfaces/comparator.interface'; +import { PartialObjectMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface'; + +import { transformMetadataForComparison } from 'src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util'; +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; + +const objectPropertiesToIgnore = [ + 'id', + 'createdAt', + 'updatedAt', + 'labelIdentifierFieldMetadataId', + 'imageIdentifierFieldMetadataId', + 'isActive', + 'fields', +] as const; + +@Injectable() +export class WorkspaceObjectComparator { + constructor() {} + + public compare( + originalObjectMetadata: ObjectMetadataEntity | undefined, + standardObjectMetadata: PartialObjectMetadata, + ): ObjectComparatorResult { + // If the object doesn't exist in the original metadata, we need to create it + if (!originalObjectMetadata) { + return { + action: ComparatorAction.CREATE, + object: standardObjectMetadata, + }; + } + + const objectPropertiesToUpdate: Partial = {}; + + // Only compare properties that are not ignored + const partialOriginalObjectMetadata = transformMetadataForComparison( + originalObjectMetadata, + { + propertiesToIgnore: objectPropertiesToIgnore, + }, + ); + + // Compare objects + const objectMetadataDifference = diff( + partialOriginalObjectMetadata, + omit(standardObjectMetadata, 'fields'), + ); + + // Loop through the differences and create an object with the properties to update + for (const difference of objectMetadataDifference) { + // We only handle CHANGE here as REMOVE and CREATE are handled earlier. + if (difference.type === 'CHANGE') { + const property = difference.path[0]; + + objectPropertiesToUpdate[property] = difference.value; + } + } + + // If there are no properties to update, the objects are equal + if (Object.keys(objectPropertiesToUpdate).length === 0) { + return { + action: ComparatorAction.SKIP, + }; + } + + // If there are properties to update, we need to update the object + return { + action: ComparatorAction.UPDATE, + object: { + id: originalObjectMetadata.id, + ...objectPropertiesToUpdate, + }, + }; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-relation.comparator.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-relation.comparator.ts new file mode 100644 index 00000000000..727a8c5f516 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-relation.comparator.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; + +import diff from 'microdiff'; + +import { + ComparatorAction, + RelationComparatorResult, +} from 'src/workspace/workspace-sync-metadata/interfaces/comparator.interface'; + +import { RelationMetadataEntity } from 'src/metadata/relation-metadata/relation-metadata.entity'; +import { transformMetadataForComparison } from 'src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util'; + +const relationPropertiesToIgnore = ['createdAt', 'updatedAt'] as const; + +@Injectable() +export class WorkspaceRelationComparator { + constructor() {} + + compare( + originalRelationMetadataCollection: RelationMetadataEntity[], + standardRelationMetadataCollection: Partial[], + ): RelationComparatorResult[] { + const results: RelationComparatorResult[] = []; + + // Create a map of standard relations + const standardRelationMetadataMap = transformMetadataForComparison( + standardRelationMetadataCollection, + { + keyFactory(relationMetadata) { + return `${relationMetadata.fromObjectMetadataId}->${relationMetadata.fromFieldMetadataId}`; + }, + }, + ); + + // Create a filtered map of original relations + // We filter out 'id' later because we need it to remove the relation from DB + const originalRelationMetadataMap = transformMetadataForComparison( + originalRelationMetadataCollection, + { + propertiesToIgnore: relationPropertiesToIgnore, + keyFactory(relationMetadata) { + return `${relationMetadata.fromObjectMetadataId}->${relationMetadata.fromFieldMetadataId}`; + }, + }, + ); + + // Compare relations + const relationMetadataDifference = diff( + originalRelationMetadataMap, + standardRelationMetadataMap, + ); + + for (const difference of relationMetadataDifference) { + if (difference.type === 'CREATE') { + results.push({ + action: ComparatorAction.CREATE, + object: difference.value, + }); + } else if ( + difference.type === 'REMOVE' && + difference.path[difference.path.length - 1] !== 'id' + ) { + results.push({ + action: ComparatorAction.DELETE, + object: difference.oldValue, + }); + } + } + + return results; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/field-metadata.decorator.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/field-metadata.decorator.ts index a2fc29f6069..351d428a502 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/field-metadata.decorator.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/field-metadata.decorator.ts @@ -3,6 +3,7 @@ import { ReflectFieldMetadata, } from 'src/workspace/workspace-sync-metadata/interfaces/reflect-field-metadata.interface'; import { GateDecoratorParams } from 'src/workspace/workspace-sync-metadata/interfaces/gate-decorator.interface'; +import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface'; import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/generate-target-column-map.util'; @@ -14,7 +15,7 @@ export function FieldMetadata( ): PropertyDecorator { return (target: object, fieldKey: string) => { const existingFieldMetadata = - TypedReflect.getMetadata('fieldMetadata', target.constructor) ?? {}; + TypedReflect.getMetadata('fieldMetadataMap', target.constructor) ?? {}; const isNullable = TypedReflect.getMetadata('isNullable', target, fieldKey) ?? false; const isSystem = @@ -23,7 +24,7 @@ export function FieldMetadata( const { joinColumn, ...restParams } = params; TypedReflect.defineMetadata( - 'fieldMetadata', + 'fieldMetadataMap', { ...existingFieldMetadata, [fieldKey]: generateFieldMetadata( @@ -65,19 +66,22 @@ function generateFieldMetadata( gate: GateDecoratorParams | undefined = undefined, ): ReflectFieldMetadata[string] { const targetColumnMap = generateTargetColumnMap(params.type, false, fieldKey); - const defaultValue = params.defaultValue ?? generateDefaultValue(params.type); + const defaultValue = (params.defaultValue ?? + generateDefaultValue( + params.type, + )) as FieldMetadataDefaultValue<'default'> | null; return { name: fieldKey, ...params, - targetColumnMap: JSON.stringify(targetColumnMap), + targetColumnMap, isNullable: params.type === FieldMetadataType.RELATION ? true : isNullable, isSystem, isCustom: false, - options: params.options ? JSON.stringify(params.options) : null, + options: params.options, description: params.description, icon: params.icon, - defaultValue: defaultValue ? JSON.stringify(defaultValue) : null, + defaultValue, gate, }; } diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/relation-metadata.decorator.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/relation-metadata.decorator.ts index 142061671cf..62c5b11a252 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/relation-metadata.decorator.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/relation-metadata.decorator.ts @@ -9,17 +9,20 @@ export function RelationMetadata( params: RelationMetadataDecoratorParams, ): PropertyDecorator { return (target: object, fieldKey: string) => { - const existingRelationMetadata = - TypedReflect.getMetadata('relationMetadata', target.constructor) ?? []; + const relationMetadataCollection = + TypedReflect.getMetadata( + 'relationMetadataCollection', + target.constructor, + ) ?? []; const gate = TypedReflect.getMetadata('gate', target, fieldKey); const objectName = convertClassNameToObjectMetadataName( target.constructor.name, ); Reflect.defineMetadata( - 'relationMetadata', + 'relationMetadataCollection', [ - ...existingRelationMetadata, + ...relationMetadataCollection, { type: params.type, fromObjectNameSingular: objectName, diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/feature-flags.factory.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/feature-flags.factory.ts new file mode 100644 index 00000000000..82904e5d8f7 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/feature-flags.factory.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { WorkspaceSyncContext } from 'src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; +import { FeatureFlagMap } from 'src/core/feature-flag/interfaces/feature-flag-map.interface'; + +import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity'; + +@Injectable() +export class FeatureFlagFactory { + constructor( + @InjectRepository(FeatureFlagEntity, 'core') + private readonly featureFlagRepository: Repository, + ) {} + + async create(context: WorkspaceSyncContext): Promise { + const workspaceFeatureFlags = await this.featureFlagRepository.find({ + where: { workspaceId: context.workspaceId }, + }); + + const workspaceFeatureFlagsMap = workspaceFeatureFlags.reduce( + (result, currentFeatureFlag) => { + result[currentFeatureFlag.key] = currentFeatureFlag.value; + + return result; + }, + {} as FeatureFlagMap, + ); + + return workspaceFeatureFlagsMap; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/index.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/index.ts new file mode 100644 index 00000000000..df6a4bfdb98 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/index.ts @@ -0,0 +1,11 @@ +import { FeatureFlagFactory } from './feature-flags.factory'; +import { StandardObjectFactory } from './standard-object.factory'; +import { StandardRelationFactory } from './standard-relation.factory'; +import { WorkspaceSyncFactory } from './workspace-sync.factory'; + +export const workspaceSyncMetadataFactories = [ + FeatureFlagFactory, + StandardObjectFactory, + StandardRelationFactory, + WorkspaceSyncFactory, +]; diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-object.factory.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-object.factory.ts new file mode 100644 index 00000000000..fd76c4e06ad --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-object.factory.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceSyncContext } from 'src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; +import { PartialObjectMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface'; +import { FeatureFlagMap } from 'src/core/feature-flag/interfaces/feature-flag-map.interface'; +import { PartialFieldMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-field-metadata.interface'; + +import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata'; +import { standardObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects'; +import { TypedReflect } from 'src/utils/typed-reflect'; +import { isGatedAndNotEnabled } from 'src/workspace/workspace-sync-metadata/utils/is-gate-and-not-enabled.util'; + +@Injectable() +export class StandardObjectFactory { + create( + context: WorkspaceSyncContext, + workspaceFeatureFlagsMap: FeatureFlagMap, + ): PartialObjectMetadata[] { + return standardObjectMetadata + .map((metadata) => + this.createObjectMetadata(metadata, context, workspaceFeatureFlagsMap), + ) + .filter((metadata): metadata is PartialObjectMetadata => !!metadata); + } + + private createObjectMetadata( + metadata: typeof BaseObjectMetadata, + context: WorkspaceSyncContext, + workspaceFeatureFlagsMap: FeatureFlagMap, + ): PartialObjectMetadata | undefined { + const objectMetadata = TypedReflect.getMetadata('objectMetadata', metadata); + const fieldMetadataMap = + TypedReflect.getMetadata('fieldMetadataMap', metadata) ?? []; + + if (!objectMetadata) { + throw new Error( + `Object metadata decorator not found, can\'t parse ${metadata.name}`, + ); + } + + if (isGatedAndNotEnabled(objectMetadata.gate, workspaceFeatureFlagsMap)) { + return undefined; + } + + const fields = Object.values(fieldMetadataMap).reduce( + // Omit gate as we don't want to store it in the DB + (acc, { gate, ...fieldMetadata }) => { + if (isGatedAndNotEnabled(gate, workspaceFeatureFlagsMap)) { + return acc; + } + + acc.push({ + ...fieldMetadata, + workspaceId: context.workspaceId, + isSystem: objectMetadata.isSystem || fieldMetadata.isSystem, + }); + + return acc; + }, + [] as PartialFieldMetadata[], + ); + + return { + ...objectMetadata, + workspaceId: context.workspaceId, + dataSourceId: context.dataSourceId, + fields, + }; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-relation.factory.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-relation.factory.ts new file mode 100644 index 00000000000..8d98da4bcb4 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-relation.factory.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceSyncContext } from 'src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; +import { FeatureFlagMap } from 'src/core/feature-flag/interfaces/feature-flag-map.interface'; + +import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata'; +import { standardObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects'; +import { TypedReflect } from 'src/utils/typed-reflect'; +import { isGatedAndNotEnabled } from 'src/workspace/workspace-sync-metadata/utils/is-gate-and-not-enabled.util'; +import { assert } from 'src/utils/assert'; +import { RelationMetadataEntity } from 'src/metadata/relation-metadata/relation-metadata.entity'; +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; + +@Injectable() +export class StandardRelationFactory { + create( + context: WorkspaceSyncContext, + originalObjectMetadataMap: Record, + workspaceFeatureFlagsMap: FeatureFlagMap, + ): Partial[] { + return standardObjectMetadata.flatMap((standardObjectMetadata) => + this.createRelationMetadata( + standardObjectMetadata, + context, + originalObjectMetadataMap, + workspaceFeatureFlagsMap, + ), + ); + } + + private createRelationMetadata( + standardObjectMetadata: typeof BaseObjectMetadata, + context: WorkspaceSyncContext, + originalObjectMetadataMap: Record, + workspaceFeatureFlagsMap: FeatureFlagMap, + ): Partial[] { + const objectMetadata = TypedReflect.getMetadata( + 'objectMetadata', + standardObjectMetadata, + ); + const relationMetadataCollection = TypedReflect.getMetadata( + 'relationMetadataCollection', + standardObjectMetadata, + ); + + if (!objectMetadata) { + throw new Error( + `Object metadata decorator not found, can\'t parse ${standardObjectMetadata.name}`, + ); + } + + if ( + !relationMetadataCollection || + isGatedAndNotEnabled(objectMetadata.gate, workspaceFeatureFlagsMap) + ) { + return []; + } + + return relationMetadataCollection + .filter( + (relationMetadata) => + !isGatedAndNotEnabled( + relationMetadata.gate, + workspaceFeatureFlagsMap, + ), + ) + .map((relationMetadata) => { + const fromObjectMetadata = + originalObjectMetadataMap[relationMetadata.fromObjectNameSingular]; + + assert( + fromObjectMetadata, + `Object ${relationMetadata.fromObjectNameSingular} not found in DB + for relation FROM defined in class ${objectMetadata.nameSingular}`, + ); + + const toObjectMetadata = + originalObjectMetadataMap[relationMetadata.toObjectNameSingular]; + + assert( + toObjectMetadata, + `Object ${relationMetadata.toObjectNameSingular} not found in DB + for relation TO defined in class ${objectMetadata.nameSingular}`, + ); + + const fromFieldMetadata = fromObjectMetadata?.fields.find( + (field) => field.name === relationMetadata.fromFieldMetadataName, + ); + + assert( + fromFieldMetadata, + `Field ${relationMetadata.fromFieldMetadataName} not found in object ${relationMetadata.fromObjectNameSingular} + for relation FROM defined in class ${objectMetadata.nameSingular}`, + ); + + const toFieldMetadata = toObjectMetadata?.fields.find( + (field) => field.name === relationMetadata.toFieldMetadataName, + ); + + assert( + toFieldMetadata, + `Field ${relationMetadata.toFieldMetadataName} not found in object ${relationMetadata.toObjectNameSingular} + for relation TO defined in class ${objectMetadata.nameSingular}`, + ); + + return { + relationType: relationMetadata.type, + fromObjectMetadataId: fromObjectMetadata?.id, + toObjectMetadataId: toObjectMetadata?.id, + fromFieldMetadataId: fromFieldMetadata?.id, + toFieldMetadataId: toFieldMetadata?.id, + workspaceId: context.workspaceId, + }; + }); + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/workspace-sync.factory.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/workspace-sync.factory.ts new file mode 100644 index 00000000000..8a46950a293 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/workspace-sync.factory.ts @@ -0,0 +1,268 @@ +import { Injectable } from '@nestjs/common'; + +import { + FieldMetadataEntity, + FieldMetadataType, +} from 'src/metadata/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { + WorkspaceMigrationColumnActionType, + WorkspaceMigrationColumnRelation, + WorkspaceMigrationEntity, + WorkspaceMigrationTableAction, +} from 'src/metadata/workspace-migration/workspace-migration.entity'; +import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util'; +import { WorkspaceMigrationFactory } from 'src/metadata/workspace-migration/workspace-migration.factory'; +import { + RelationMetadataEntity, + RelationMetadataType, +} from 'src/metadata/relation-metadata/relation-metadata.entity'; +import { camelCase } from 'src/utils/camel-case'; +import { generateMigrationName } from 'src/metadata/workspace-migration/utils/generate-migration-name.util'; + +@Injectable() +export class WorkspaceSyncFactory { + constructor( + private readonly workspaceMigrationFactory: WorkspaceMigrationFactory, + ) {} + + async createObjectMigration( + originalObjectMetadataCollection: ObjectMetadataEntity[], + createdObjectMetadataCollection: ObjectMetadataEntity[], + objectMetadataDeleteCollection: ObjectMetadataEntity[], + createdFieldMetadataCollection: FieldMetadataEntity[], + fieldMetadataDeleteCollection: FieldMetadataEntity[], + ): Promise[]> { + const workspaceMigrations: Partial[] = []; + + /** + * Create object migrations + */ + if (createdObjectMetadataCollection.length > 0) { + for (const objectMetadata of createdObjectMetadataCollection) { + const migrations = [ + { + name: computeObjectTargetTable(objectMetadata), + action: 'create', + } satisfies WorkspaceMigrationTableAction, + ...objectMetadata.fields + .filter((field) => field.type !== FieldMetadataType.RELATION) + .map( + (field) => + ({ + name: computeObjectTargetTable(objectMetadata), + action: 'alter', + columns: this.workspaceMigrationFactory.createColumnActions( + WorkspaceMigrationColumnActionType.CREATE, + field, + ), + }) satisfies WorkspaceMigrationTableAction, + ), + ]; + + workspaceMigrations.push({ + workspaceId: objectMetadata.workspaceId, + name: generateMigrationName(`create-${objectMetadata.nameSingular}`), + isCustom: false, + migrations, + }); + } + } + + /** + * Delete object migrations + * TODO: handle object delete migrations. + * Note: we need to delete the relation first due to the DB constraint. + */ + // if (objectMetadataDeleteCollection.length > 0) { + // for (const objectMetadata of objectMetadataDeleteCollection) { + // const migrations = [ + // { + // name: computeObjectTargetTable(objectMetadata), + // action: 'drop', + // columns: [], + // } satisfies WorkspaceMigrationTableAction, + // ]; + + // workspaceMigrations.push({ + // workspaceId: objectMetadata.workspaceId, + // isCustom: false, + // migrations, + // }); + // } + // } + + /** + * Create field migrations + */ + const originalObjectMetadataMap = originalObjectMetadataCollection.reduce( + (result, currentObject) => { + result[currentObject.id] = currentObject; + + return result; + }, + {} as Record, + ); + + if (createdFieldMetadataCollection.length > 0) { + for (const fieldMetadata of createdFieldMetadataCollection) { + if (fieldMetadata.type === FieldMetadataType.RELATION) { + continue; + } + + const migrations = [ + { + name: computeObjectTargetTable( + originalObjectMetadataMap[fieldMetadata.objectMetadataId], + ), + action: 'alter', + columns: this.workspaceMigrationFactory.createColumnActions( + WorkspaceMigrationColumnActionType.CREATE, + fieldMetadata, + ), + } satisfies WorkspaceMigrationTableAction, + ]; + + workspaceMigrations.push({ + workspaceId: fieldMetadata.workspaceId, + name: generateMigrationName(`create-${fieldMetadata.name}`), + isCustom: false, + migrations, + }); + } + } + + /** + * Delete field migrations + */ + if (fieldMetadataDeleteCollection.length > 0) { + for (const fieldMetadata of fieldMetadataDeleteCollection) { + const migrations = [ + { + name: computeObjectTargetTable( + originalObjectMetadataMap[fieldMetadata.objectMetadataId], + ), + action: 'alter', + columns: [ + { + action: WorkspaceMigrationColumnActionType.DROP, + columnName: fieldMetadata.name, + }, + ], + } satisfies WorkspaceMigrationTableAction, + ]; + + workspaceMigrations.push({ + workspaceId: fieldMetadata.workspaceId, + name: generateMigrationName(`delete-${fieldMetadata.name}`), + isCustom: false, + migrations, + }); + } + } + + return workspaceMigrations; + } + + async createRelationMigration( + originalObjectMetadataCollection: ObjectMetadataEntity[], + createdRelationMetadataCollection: RelationMetadataEntity[], + // TODO: handle relation deletion + // eslint-disable-next-line @typescript-eslint/no-unused-vars + relationMetadataDeleteCollection: RelationMetadataEntity[], + ): Promise[]> { + const workspaceMigrations: Partial[] = []; + + if (createdRelationMetadataCollection.length > 0) { + for (const relationMetadata of createdRelationMetadataCollection) { + const toObjectMetadata = originalObjectMetadataCollection.find( + (object) => object.id === relationMetadata.toObjectMetadataId, + ); + + const fromObjectMetadata = originalObjectMetadataCollection.find( + (object) => object.id === relationMetadata.fromObjectMetadataId, + ); + + if (!toObjectMetadata) { + throw new Error( + `ObjectMetadata with id ${relationMetadata.toObjectMetadataId} not found`, + ); + } + + if (!fromObjectMetadata) { + throw new Error( + `ObjectMetadata with id ${relationMetadata.fromObjectMetadataId} not found`, + ); + } + + const toFieldMetadata = toObjectMetadata.fields.find( + (field) => field.id === relationMetadata.toFieldMetadataId, + ); + + if (!toFieldMetadata) { + throw new Error( + `FieldMetadata with id ${relationMetadata.toFieldMetadataId} not found`, + ); + } + + const migrations = [ + { + name: computeObjectTargetTable(toObjectMetadata), + action: 'alter', + columns: [ + { + action: WorkspaceMigrationColumnActionType.RELATION, + columnName: `${camelCase(toFieldMetadata.name)}Id`, + referencedTableName: + computeObjectTargetTable(fromObjectMetadata), + referencedTableColumnName: 'id', + isUnique: + relationMetadata.relationType === + RelationMetadataType.ONE_TO_ONE, + } satisfies WorkspaceMigrationColumnRelation, + ], + } satisfies WorkspaceMigrationTableAction, + ]; + + workspaceMigrations.push({ + workspaceId: relationMetadata.workspaceId, + name: generateMigrationName( + `create-relation-from-${fromObjectMetadata.nameSingular}-to-${toObjectMetadata.nameSingular}`, + ), + isCustom: false, + migrations, + }); + } + } + + // if (relationMetadataDeleteCollection.length > 0) { + // for (const relationMetadata of relationMetadataDeleteCollection) { + // const toObjectMetadata = originalObjectMetadataCollection.find( + // (object) => object.id === relationMetadata.toObjectMetadataId, + // ); + + // if (!toObjectMetadata) { + // throw new Error( + // `ObjectMetadata with id ${relationMetadata.toObjectMetadataId} not found`, + // ); + // } + + // const migrations = [ + // { + // name: computeObjectTargetTable(toObjectMetadata), + // action: 'drop', + // columns: [], + // } satisfies WorkspaceMigrationTableAction, + // ]; + + // workspaceMigrations.push({ + // workspaceId: relationMetadata.workspaceId, + // isCustom: false, + // migrations, + // }); + // } + // } + + return workspaceMigrations; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/comparator.interface.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/comparator.interface.ts new file mode 100644 index 00000000000..a90201192bc --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/comparator.interface.ts @@ -0,0 +1,46 @@ +import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity'; +import { RelationMetadataEntity } from 'src/metadata/relation-metadata/relation-metadata.entity'; + +import { PartialFieldMetadata } from './partial-field-metadata.interface'; +import { PartialObjectMetadata } from './partial-object-metadata.interface'; + +export const enum ComparatorAction { + SKIP = 'SKIP', + CREATE = 'CREATE', + UPDATE = 'UPDATE', + DELETE = 'DELETE', +} + +export interface ComparatorSkipResult { + action: ComparatorAction.SKIP; +} + +export interface ComparatorCreateResult { + action: ComparatorAction.CREATE; + object: T; +} + +export interface ComparatorUpdateResult { + action: ComparatorAction.UPDATE; + object: T; +} + +export interface ComparatorDeleteResult { + action: ComparatorAction.DELETE; + object: T; +} + +export type ObjectComparatorResult = + | ComparatorSkipResult + | ComparatorCreateResult + | ComparatorUpdateResult>; + +export type FieldComparatorResult = + | ComparatorSkipResult + | ComparatorCreateResult + | ComparatorUpdateResult & { id: string }> + | ComparatorDeleteResult; + +export type RelationComparatorResult = + | ComparatorCreateResult> + | ComparatorDeleteResult; diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface.ts index 0e12d1bcd57..1080b34cf7b 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface.ts @@ -2,6 +2,7 @@ import { PartialFieldMetadata } from 'src/workspace/workspace-sync-metadata/inte import { ReflectObjectMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/reflect-object-metadata.interface'; export type PartialObjectMetadata = ReflectObjectMetadata & { + id?: string; workspaceId: string; dataSourceId: string; fields: PartialFieldMetadata[]; diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/reflect-field-metadata.interface.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/reflect-field-metadata.interface.ts index 6d4c66ca218..873ed5b75e9 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/reflect-field-metadata.interface.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/reflect-field-metadata.interface.ts @@ -1,6 +1,7 @@ import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface'; import { GateDecoratorParams } from 'src/workspace/workspace-sync-metadata/interfaces/gate-decorator.interface'; import { FieldMetadataOptions } from 'src/metadata/field-metadata/interfaces/field-metadata-options.interface'; +import { FieldMetadataTargetColumnMap } from 'src/metadata/field-metadata/interfaces/field-metadata-target-column-map.interface'; import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; @@ -23,13 +24,13 @@ export interface ReflectFieldMetadata { > & { name: string; type: FieldMetadataType; - targetColumnMap: string; + targetColumnMap: FieldMetadataTargetColumnMap<'default'>; isNullable: boolean; isSystem: boolean; isCustom: boolean; description?: string; - defaultValue: string | null; + defaultValue: FieldMetadataDefaultValue<'default'> | null; gate?: GateDecoratorParams; - options?: string | null; + options?: FieldMetadataOptions<'default'> | null; }; } diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface.ts new file mode 100644 index 00000000000..fc0c7b5c558 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface.ts @@ -0,0 +1,4 @@ +export interface WorkspaceSyncContext { + workspaceId: string; + dataSourceId: string; +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/reflective-metadata.factory.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/reflective-metadata.factory.ts deleted file mode 100644 index ab10138f303..00000000000 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/reflective-metadata.factory.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import assert from 'assert'; - -import { PartialObjectMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface'; -import { MappedObjectMetadataEntity } from 'src/workspace/workspace-sync-metadata/interfaces/mapped-metadata.interface'; - -import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata'; -import { TypedReflect } from 'src/utils/typed-reflect'; -import { isGatedAndNotEnabled } from 'src/workspace/workspace-sync-metadata/utils/is-gate-and-not-enabled.util'; - -@Injectable() -export class ReflectiveMetadataFactory { - async createObjectMetadata( - metadata: typeof BaseObjectMetadata, - workspaceId: string, - defaultDataSourceId: string, - workspaceFeatureFlagsMap: Record, - ): Promise { - const objectMetadata = TypedReflect.getMetadata('objectMetadata', metadata); - const fieldMetadata = - TypedReflect.getMetadata('fieldMetadata', metadata) ?? {}; - - if (!objectMetadata) { - throw new Error( - `Object metadata decorator not found, can\'t parse ${metadata.name}`, - ); - } - - if (isGatedAndNotEnabled(objectMetadata, workspaceFeatureFlagsMap)) { - return undefined; - } - - const fields = Object.values(fieldMetadata).filter( - (field) => !isGatedAndNotEnabled(field, workspaceFeatureFlagsMap), - ); - - return { - ...objectMetadata, - workspaceId, - dataSourceId: defaultDataSourceId, - fields: fields.map((field) => ({ - ...field, - workspaceId, - isSystem: objectMetadata.isSystem || field.isSystem, - })), - }; - } - - async createObjectMetadataCollection( - metadataCollection: (typeof BaseObjectMetadata)[], - workspaceId: string, - dataSourceId: string, - workspaceFeatureFlagsMap: Record, - ) { - const metadataPromises = metadataCollection.map((metadata) => - this.createObjectMetadata( - metadata, - workspaceId, - dataSourceId, - workspaceFeatureFlagsMap, - ), - ); - const resolvedMetadata = await Promise.all(metadataPromises); - - return resolvedMetadata.filter( - (metadata): metadata is PartialObjectMetadata => !!metadata, - ); - } - - createRelationMetadata( - metadata: typeof BaseObjectMetadata, - workspaceId: string, - objectMetadataFromDB: Record, - workspaceFeatureFlagsMap: Record, - ) { - const objectMetadata = TypedReflect.getMetadata('objectMetadata', metadata); - const relationMetadata = TypedReflect.getMetadata( - 'relationMetadata', - metadata, - ); - - if (!objectMetadata) { - throw new Error( - `Object metadata decorator not found, can\'t parse ${metadata.name}`, - ); - } - - if ( - !relationMetadata || - isGatedAndNotEnabled(objectMetadata, workspaceFeatureFlagsMap) - ) { - return []; - } - - return relationMetadata - .filter( - (relation) => !isGatedAndNotEnabled(relation, workspaceFeatureFlagsMap), - ) - .map((relation) => { - const fromObjectMetadata = - objectMetadataFromDB[relation.fromObjectNameSingular]; - - assert( - fromObjectMetadata, - `Object ${relation.fromObjectNameSingular} not found in DB - for relation FROM defined in class ${objectMetadata.nameSingular}`, - ); - - const toObjectMetadata = - objectMetadataFromDB[relation.toObjectNameSingular]; - - assert( - toObjectMetadata, - `Object ${relation.toObjectNameSingular} not found in DB - for relation TO defined in class ${objectMetadata.nameSingular}`, - ); - - const fromFieldMetadata = - fromObjectMetadata?.fields[relation.fromFieldMetadataName]; - - assert( - fromFieldMetadata, - `Field ${relation.fromFieldMetadataName} not found in object ${relation.fromObjectNameSingular} - for relation FROM defined in class ${objectMetadata.nameSingular}`, - ); - - const toFieldMetadata = - toObjectMetadata?.fields[relation.toFieldMetadataName]; - - assert( - toFieldMetadata, - `Field ${relation.toFieldMetadataName} not found in object ${relation.toObjectNameSingular} - for relation TO defined in class ${objectMetadata.nameSingular}`, - ); - - return { - relationType: relation.type, - fromObjectMetadataId: fromObjectMetadata?.id, - toObjectMetadataId: toObjectMetadata?.id, - fromFieldMetadataId: fromFieldMetadata?.id, - toFieldMetadataId: toFieldMetadata?.id, - workspaceId, - }; - }); - } - - createRelationMetadataCollection( - metadataCollection: (typeof BaseObjectMetadata)[], - workspaceId: string, - objectMetadataFromDB: Record, - workspaceFeatureFlagsMap: Record