From 6acdde72efc5edb90eec2fa5418138b5ba9fb8c6 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:43:21 +0100 Subject: [PATCH] Fix fetch more notes (#16442) fixes : https://github.com/twentyhq/twenty/issues/16320 --------- Co-authored-by: Charles Bochet --- .../CustomResolverFetchMoreLoader.tsx | 14 +- ...ctivityTargetsOperationSignatureFactory.ts | 63 +++- .../hooks/__tests__/useActivities.test.tsx | 15 +- .../modules/activities/hooks/useActivities.ts | 124 ++++---- .../useActivityTargetsForTargetableObjects.ts | 35 ++- .../hooks/useUpdateActivityTargetFromCell.ts | 1 + .../activities/notes/components/NoteList.tsx | 12 +- .../activities/notes/components/NotesCard.tsx | 15 +- .../activities/notes/hooks/useNotes.ts | 16 +- .../activities/tasks/hooks/useTasks.ts | 15 +- .../availableWorkspacesUtils.test.ts | 274 ++++++++++++++++++ ...epthRecordGqlFieldsFromRecord.test.ts.snap | 92 ++++++ ...rateDepthRecordGqlFieldsFromRecord.test.ts | 45 +++ 13 files changed, 607 insertions(+), 114 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/utils/__tests__/availableWorkspacesUtils.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/__snapshots__/generateDepthRecordGqlFieldsFromRecord.test.ts.snap create mode 100644 packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/generateDepthRecordGqlFieldsFromRecord.test.ts diff --git a/packages/twenty-front/src/modules/activities/components/CustomResolverFetchMoreLoader.tsx b/packages/twenty-front/src/modules/activities/components/CustomResolverFetchMoreLoader.tsx index 606b9d320c..a7136604fb 100644 --- a/packages/twenty-front/src/modules/activities/components/CustomResolverFetchMoreLoader.tsx +++ b/packages/twenty-front/src/modules/activities/components/CustomResolverFetchMoreLoader.tsx @@ -6,6 +6,10 @@ type CustomResolverFetchMoreLoaderProps = { onLastRowVisible: (...args: any[]) => any; }; +const StyledContainer = styled.div` + min-height: 1px; +`; + const StyledText = styled.div` align-items: center; box-shadow: none; @@ -21,12 +25,16 @@ export const CustomResolverFetchMoreLoader = ({ onLastRowVisible, }: CustomResolverFetchMoreLoaderProps) => { const { ref: tbodyRef } = useInView({ - onChange: onLastRowVisible, + onChange: (inView) => { + if (inView) { + onLastRowVisible(); + } + }, }); return ( -
+ {loading && Loading more...} -
+ ); }; diff --git a/packages/twenty-front/src/modules/activities/graphql/operation-signatures/factories/findActivityTargetsOperationSignatureFactory.ts b/packages/twenty-front/src/modules/activities/graphql/operation-signatures/factories/findActivityTargetsOperationSignatureFactory.ts index a854b4794d..2c51884156 100644 --- a/packages/twenty-front/src/modules/activities/graphql/operation-signatures/factories/findActivityTargetsOperationSignatureFactory.ts +++ b/packages/twenty-front/src/modules/activities/graphql/operation-signatures/factories/findActivityTargetsOperationSignatureFactory.ts @@ -1,11 +1,12 @@ -import { generateActivityTargetMorphFieldKeys } from '@/activities/utils/generateActivityTargetMorphFieldKeys'; import { getJoinObjectNameSingular } from '@/activities/utils/getJoinObjectNameSingular'; import { type CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { generateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromObject'; import { type RecordGqlOperationSignatureFactory } from '@/object-record/graphql/types/RecordGqlOperationSignatureFactory'; +import { isDefined } from 'twenty-shared/utils'; type FindActivityTargetsOperationSignatureFactory = { - objectNameSingular: CoreObjectNameSingular; + objectNameSingular: CoreObjectNameSingular.Note | CoreObjectNameSingular.Task; objectMetadataItems: ObjectMetadataItem[]; }; @@ -14,14 +15,50 @@ export const findActivityTargetsOperationSignatureFactory: RecordGqlOperationSig > = ({ objectNameSingular, objectMetadataItems, -}: FindActivityTargetsOperationSignatureFactory) => ({ - objectNameSingular: getJoinObjectNameSingular(objectNameSingular), - variables: {}, - fields: { - id: true, - __typename: true, - createdAt: true, - updatedAt: true, - ...generateActivityTargetMorphFieldKeys(objectMetadataItems), - }, -}); +}: FindActivityTargetsOperationSignatureFactory) => { + const targetObjectNameSingular = + getJoinObjectNameSingular(objectNameSingular); + + const targetObjectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === targetObjectNameSingular, + ); + + const activityObjectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === objectNameSingular, + ); + + if ( + !isDefined(targetObjectMetadataItem) || + !isDefined(activityObjectMetadataItem) + ) { + throw new Error(`Cannot find target or targetable object metadata item`); + } + + const activityFieldKeys = generateDepthRecordGqlFieldsFromObject({ + objectMetadataItems, + objectMetadataItem: activityObjectMetadataItem, + depth: 0, + }); + + return { + objectNameSingular: targetObjectNameSingular, + variables: {}, + fields: { + id: true, + __typename: true, + createdAt: true, + updatedAt: true, + [objectNameSingular]: { + ...activityFieldKeys, + [targetObjectMetadataItem.namePlural]: + generateDepthRecordGqlFieldsFromObject({ + objectMetadataItems, + objectMetadataItem: targetObjectMetadataItem, + depth: 1, + }), + }, + }, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivities.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivities.test.tsx index e3b2e74572..a71455bb9a 100644 --- a/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivities.test.tsx +++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivities.test.tsx @@ -15,7 +15,7 @@ jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ })); const mockActivityTarget = { - __typename: 'ActivityTarget', + __typename: 'TaskTarget', updatedAt: '2021-08-03T19:20:06.000Z', createdAt: '2021-08-03T19:20:06.000Z', personId: '1', @@ -55,18 +55,11 @@ describe('useActivities', () => { ); useActivityTargetsForTargetableObjectsMock.useActivityTargetsForTargetableObjects.mockReturnValue( { - activityTargets: [mockActivityTarget], + activityTargets: [{ ...mockActivityTarget, task: mockActivity }], loadingActivityTargets: false, }, ); - const useFindManyRecordsMock = jest.requireMock( - '@/object-record/hooks/useFindManyRecords', - ); - useFindManyRecordsMock.useFindManyRecords.mockReturnValue({ - records: [mockActivity], - }); - const { result } = renderHook( () => { const activities = useActivities({ @@ -74,9 +67,9 @@ describe('useActivities', () => { targetableObjects: [ { targetObjectNameSingular: 'company', id: '123' }, ], - activitiesFilters: {}, - activitiesOrderByVariables: [{}], skip: false, + limit: 10, + activityTargetsOrderByVariables: [{}], }); return activities; }, diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivities.ts b/packages/twenty-front/src/modules/activities/hooks/useActivities.ts index a895e37cc6..bfb8d35e2f 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useActivities.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useActivities.ts @@ -1,92 +1,84 @@ -import { isNonEmptyString } from '@sniptt/guards'; -import { useRecoilCallback } from 'recoil'; - -import { findActivitiesOperationSignatureFactory } from '@/activities/graphql/operation-signatures/factories/findActivitiesOperationSignatureFactory'; import { useActivityTargetsForTargetableObjects } from '@/activities/hooks/useActivityTargetsForTargetableObjects'; import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { type Note } from '@/activities/types/Note'; +import { type NoteTarget } from '@/activities/types/NoteTarget'; import { type Task } from '@/activities/types/Task'; -import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { type TaskTarget } from '@/activities/types/TaskTarget'; import { type CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; import { type RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { type RecordGqlOperationFilter } from 'twenty-shared/types'; -import { sortByAscString } from '~/utils/array/sortByAscString'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; export const useActivities = ({ objectNameSingular, targetableObjects, - activitiesFilters, - activitiesOrderByVariables, + activityTargetsOrderByVariables, skip, + limit, }: { - objectNameSingular: CoreObjectNameSingular; + objectNameSingular: CoreObjectNameSingular.Note | CoreObjectNameSingular.Task; targetableObjects: ActivityTargetableObject[]; - activitiesFilters: RecordGqlOperationFilter; - activitiesOrderByVariables: RecordGqlOperationOrderBy; + activityTargetsOrderByVariables: RecordGqlOperationOrderBy; skip?: boolean; + limit: number; }) => { - const { objectMetadataItems } = useObjectMetadataItems(); + const updateActivitiesInStore = useRecoilCallback( + ({ set }) => + (activityTargets: (TaskTarget | NoteTarget)[]) => { + for (const activityTarget of activityTargets) { + const activity = activityTarget[objectNameSingular]; + set(recordStoreFamilyState(activity.id), activity); + } + }, + [objectNameSingular], + ); - const { activityTargets, loadingActivityTargets } = - useActivityTargetsForTargetableObjects({ - objectNameSingular, - targetableObjects, - skip: skip, + const { + activityTargets, + loadingActivityTargets, + totalCountActivityTargets, + fetchMoreActivityTargets, + hasNextPage, + } = useActivityTargetsForTargetableObjects({ + objectNameSingular, + targetableObjects, + skip: skip, + activityTargetsOrderByVariables, + onCompleted: updateActivitiesInStore, + limit, + }); + + const activities = activityTargets.map((activityTarget) => { + return activityTarget[objectNameSingular]; + }) as T[]; + + const fetchMoreActivities = async () => { + const result = await fetchMoreActivityTargets(); + + if (!isDefined(result?.data)) { + return []; + } + + const activityTargets = getRecordsFromRecordConnection< + TaskTarget | NoteTarget + >({ + recordConnection: result.data, }); - const activityIds = [ - ...new Set( - activityTargets - ? [ - ...activityTargets - .map( - (activityTarget) => - activityTarget.taskId ?? activityTarget.noteId, - ) - .filter(isNonEmptyString), - ].sort(sortByAscString) - : [], - ), - ]; + updateActivitiesInStore(activityTargets); - const skipBecauseNoActivityTargetFound = activityIds.length === 0; - - const filter: RecordGqlOperationFilter = { - id: { - in: activityIds, - }, - ...activitiesFilters, + return activityTargets.map((activityTarget) => { + return activityTarget[objectNameSingular]; + }) as T[]; }; - const FIND_ACTIVITIES_OPERATION_SIGNATURE = - findActivitiesOperationSignatureFactory({ - objectMetadataItems, - objectNameSingular, - }); - - const { records: activities, loading: loadingActivities } = - useFindManyRecords({ - skip: skip || loadingActivityTargets || skipBecauseNoActivityTargetFound, - objectNameSingular: - FIND_ACTIVITIES_OPERATION_SIGNATURE.objectNameSingular, - recordGqlFields: FIND_ACTIVITIES_OPERATION_SIGNATURE.fields, - filter, - orderBy: activitiesOrderByVariables, - onCompleted: useRecoilCallback( - ({ set }) => - (activities) => { - for (const activity of activities) { - set(recordStoreFamilyState(activity.id), activity); - } - }, - [], - ), - }); - return { activities: activities as T[], - loading: loadingActivities || loadingActivityTargets, + loading: loadingActivityTargets, + totalCountActivities: totalCountActivityTargets, + fetchMoreActivities, + hasNextPage, }; }; diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObjects.ts b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObjects.ts index eaac869d63..c8637e77cb 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObjects.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObjects.ts @@ -7,6 +7,7 @@ import { type TaskTarget } from '@/activities/types/TaskTarget'; import { getActivityTargetsFilter } from '@/activities/utils/getActivityTargetsFilter'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { type CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { type RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; export const useActivityTargetsForTargetableObjects = ({ @@ -14,14 +15,18 @@ export const useActivityTargetsForTargetableObjects = ({ targetableObjects, skip, onCompleted, + activityTargetsOrderByVariables, + limit, }: { - objectNameSingular: CoreObjectNameSingular; + objectNameSingular: CoreObjectNameSingular.Note | CoreObjectNameSingular.Task; targetableObjects: Pick< ActivityTargetableObject, 'id' | 'targetObjectNameSingular' >[]; skip?: boolean; onCompleted?: (activityTargets: (TaskTarget | NoteTarget)[]) => void; + activityTargetsOrderByVariables: RecordGqlOperationOrderBy; + limit: number; }) => { const activityTargetsFilter = getActivityTargetsFilter({ targetableObjects: targetableObjects, @@ -38,18 +43,28 @@ export const useActivityTargetsForTargetableObjects = ({ // TODO: We want to optimistically remove from this request // If we are on a show page and we remove the current show page object corresponding activity target // See also if we need to update useTimelineActivities - const { records: activityTargets, loading: loadingActivityTargets } = - useFindManyRecords({ - skip, - objectNameSingular: - FIND_ACTIVITY_TARGETS_OPERATION_SIGNATURE.objectNameSingular, - filter: activityTargetsFilter, - recordGqlFields: FIND_ACTIVITY_TARGETS_OPERATION_SIGNATURE.fields, - onCompleted, - }); + const { + records: activityTargets, + loading: loadingActivityTargets, + totalCount: totalCountActivityTargets, + fetchMoreRecords: fetchMoreActivityTargets, + hasNextPage, + } = useFindManyRecords({ + skip, + objectNameSingular: + FIND_ACTIVITY_TARGETS_OPERATION_SIGNATURE.objectNameSingular, + filter: activityTargetsFilter, + recordGqlFields: FIND_ACTIVITY_TARGETS_OPERATION_SIGNATURE.fields, + onCompleted, + orderBy: activityTargetsOrderByVariables, + limit, + }); return { activityTargets, loadingActivityTargets, + totalCountActivityTargets: totalCountActivityTargets ?? 0, + fetchMoreActivityTargets, + hasNextPage, }; }; diff --git a/packages/twenty-front/src/modules/activities/inline-cell/hooks/useUpdateActivityTargetFromCell.ts b/packages/twenty-front/src/modules/activities/inline-cell/hooks/useUpdateActivityTargetFromCell.ts index 6bd45fea58..d94417c3a0 100644 --- a/packages/twenty-front/src/modules/activities/inline-cell/hooks/useUpdateActivityTargetFromCell.ts +++ b/packages/twenty-front/src/modules/activities/inline-cell/hooks/useUpdateActivityTargetFromCell.ts @@ -40,6 +40,7 @@ export const useUpdateActivityTargetFromCell = ({ joinObjectNameSingular === '' ? activityObjectNameSingular : joinObjectNameSingular, + shouldMatchRootQueryFilter: true, }); const { deleteOneRecord: deleteOneActivityTarget } = useDeleteOneRecord({ diff --git a/packages/twenty-front/src/modules/activities/notes/components/NoteList.tsx b/packages/twenty-front/src/modules/activities/notes/components/NoteList.tsx index 6506ea0813..b46c5e238b 100644 --- a/packages/twenty-front/src/modules/activities/notes/components/NoteList.tsx +++ b/packages/twenty-front/src/modules/activities/notes/components/NoteList.tsx @@ -1,5 +1,5 @@ -import { type ReactElement } from 'react'; import styled from '@emotion/styled'; +import { type ReactElement } from 'react'; import { type Note } from '@/activities/types/Note'; @@ -9,6 +9,7 @@ type NoteListProps = { title: string; notes: Note[]; button?: ReactElement | false | null; + totalCount: number; }; const StyledContainer = styled.div` @@ -47,13 +48,18 @@ const StyledNoteContainer = styled.div` width: 100%; `; -export const NoteList = ({ title, notes, button }: NoteListProps) => ( +export const NoteList = ({ + title, + notes, + totalCount, + button, +}: NoteListProps) => ( <> {notes && notes.length > 0 && ( - {title} {notes.length} + {title} {totalCount} {button} diff --git a/packages/twenty-front/src/modules/activities/notes/components/NotesCard.tsx b/packages/twenty-front/src/modules/activities/notes/components/NotesCard.tsx index 03c0d52ed5..13f922e043 100644 --- a/packages/twenty-front/src/modules/activities/notes/components/NotesCard.tsx +++ b/packages/twenty-front/src/modules/activities/notes/components/NotesCard.tsx @@ -1,3 +1,4 @@ +import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader'; import { SkeletonLoader } from '@/activities/components/SkeletonLoader'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { NoteList } from '@/activities/notes/components/NoteList'; @@ -29,7 +30,14 @@ const StyledNotesContainer = styled.div` export const NotesCard = () => { const targetRecord = useTargetRecord(); - const { notes, loading } = useNotes(targetRecord); + const { notes, loading, totalCountNotes, fetchMoreNotes, hasNextPage } = + useNotes(targetRecord); + + const handleLastRowVisible = async () => { + if (hasNextPage) { + await fetchMoreNotes(); + } + }; const openCreateActivity = useOpenCreateActivityDrawer({ activityObjectNameSingular: CoreObjectNameSingular.Note, @@ -87,6 +95,7 @@ export const NotesCard = () => { { ) } /> + ); }; diff --git a/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts b/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts index 078108934a..69257591c3 100644 --- a/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts +++ b/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts @@ -15,17 +15,22 @@ export const useNotes = (targetableObject: ActivityTargetableObject) => { const notesQueryVariables = useMemo( () => ({ - filter: {}, orderBy: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY, }) as RecordGqlOperationVariables, [], ); - const { activities, loading } = useActivities({ + const { + activities, + loading, + totalCountActivities, + fetchMoreActivities, + hasNextPage, + } = useActivities({ objectNameSingular: CoreObjectNameSingular.Note, - activitiesFilters: notesQueryVariables.filter ?? {}, - activitiesOrderByVariables: notesQueryVariables.orderBy ?? [{}], + activityTargetsOrderByVariables: notesQueryVariables.orderBy ?? [{}], targetableObjects: [targetableObject], + limit: 10, }); const [currentNotesQueryVariables, setCurrentNotesQueryVariables] = @@ -45,5 +50,8 @@ export const useNotes = (targetableObject: ActivityTargetableObject) => { return { notes: activities as Note[], loading, + totalCountNotes: totalCountActivities, + fetchMoreNotes: fetchMoreActivities, + hasNextPage, }; }; diff --git a/packages/twenty-front/src/modules/activities/tasks/hooks/useTasks.ts b/packages/twenty-front/src/modules/activities/tasks/hooks/useTasks.ts index 64292e228b..c8acce13a4 100644 --- a/packages/twenty-front/src/modules/activities/tasks/hooks/useTasks.ts +++ b/packages/twenty-front/src/modules/activities/tasks/hooks/useTasks.ts @@ -9,15 +9,24 @@ type UseTasksProps = { }; export const useTasks = ({ targetableObjects }: UseTasksProps) => { - const { activities: tasks, loading: tasksLoading } = useActivities({ + const { + activities: tasks, + loading: tasksLoading, + fetchMoreActivities: fetchMoreTasks, + hasNextPage, + totalCountActivities, + } = useActivities({ objectNameSingular: CoreObjectNameSingular.Task, targetableObjects, - activitiesFilters: {}, - activitiesOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY, + activityTargetsOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY, + limit: 200, }); return { tasks: (tasks ?? []) as Task[], tasksLoading, + fetchMoreTasks, + hasNextPage, + totalCountTasks: totalCountActivities, }; }; diff --git a/packages/twenty-front/src/modules/auth/utils/__tests__/availableWorkspacesUtils.test.ts b/packages/twenty-front/src/modules/auth/utils/__tests__/availableWorkspacesUtils.test.ts new file mode 100644 index 0000000000..1518028b1e --- /dev/null +++ b/packages/twenty-front/src/modules/auth/utils/__tests__/availableWorkspacesUtils.test.ts @@ -0,0 +1,274 @@ +import { AppPath } from 'twenty-shared/types'; +import { + type AvailableWorkspace, + type AvailableWorkspaces, +} from '~/generated/graphql'; +import { + countAvailableWorkspaces, + getAvailableWorkspacePathAndSearchParams, + getFirstAvailableWorkspaces, +} from '../availableWorkspacesUtils'; + +const createMockAvailableWorkspace = ( + overrides: Partial = {}, +): AvailableWorkspace => ({ + __typename: 'AvailableWorkspace', + id: 'workspace-1', + displayName: 'Test Workspace', + logo: null, + sso: [], + workspaceUrls: { + customUrl: null, + subdomainUrl: 'https://test.twenty.com', + }, + ...overrides, +}); + +const createMockAvailableWorkspaces = ( + signInWorkspaces: AvailableWorkspace[] = [], + signUpWorkspaces: AvailableWorkspace[] = [], +): AvailableWorkspaces => ({ + __typename: 'AvailableWorkspaces', + availableWorkspacesForSignIn: signInWorkspaces, + availableWorkspacesForSignUp: signUpWorkspaces, +}); + +describe('availableWorkspacesUtils', () => { + describe('countAvailableWorkspaces', () => { + it('should return 0 when both arrays are empty', () => { + const availableWorkspaces = createMockAvailableWorkspaces([], []); + + expect(countAvailableWorkspaces(availableWorkspaces)).toBe(0); + }); + + it('should count only sign-in workspaces when sign-up is empty', () => { + const signInWorkspaces = [ + createMockAvailableWorkspace({ id: 'ws-1' }), + createMockAvailableWorkspace({ id: 'ws-2' }), + ]; + const availableWorkspaces = createMockAvailableWorkspaces( + signInWorkspaces, + [], + ); + + expect(countAvailableWorkspaces(availableWorkspaces)).toBe(2); + }); + + it('should count only sign-up workspaces when sign-in is empty', () => { + const signUpWorkspaces = [ + createMockAvailableWorkspace({ id: 'ws-1' }), + createMockAvailableWorkspace({ id: 'ws-2' }), + createMockAvailableWorkspace({ id: 'ws-3' }), + ]; + const availableWorkspaces = createMockAvailableWorkspaces( + [], + signUpWorkspaces, + ); + + expect(countAvailableWorkspaces(availableWorkspaces)).toBe(3); + }); + + it('should count workspaces from both arrays', () => { + const signInWorkspaces = [ + createMockAvailableWorkspace({ id: 'ws-1' }), + createMockAvailableWorkspace({ id: 'ws-2' }), + ]; + const signUpWorkspaces = [ + createMockAvailableWorkspace({ id: 'ws-3' }), + createMockAvailableWorkspace({ id: 'ws-4' }), + createMockAvailableWorkspace({ id: 'ws-5' }), + ]; + const availableWorkspaces = createMockAvailableWorkspaces( + signInWorkspaces, + signUpWorkspaces, + ); + + expect(countAvailableWorkspaces(availableWorkspaces)).toBe(5); + }); + }); + + describe('getFirstAvailableWorkspaces', () => { + it('should return first sign-in workspace when available', () => { + const signInWorkspace = createMockAvailableWorkspace({ + id: 'sign-in-ws', + displayName: 'Sign In Workspace', + }); + const signUpWorkspace = createMockAvailableWorkspace({ + id: 'sign-up-ws', + displayName: 'Sign Up Workspace', + }); + const availableWorkspaces = createMockAvailableWorkspaces( + [signInWorkspace], + [signUpWorkspace], + ); + + expect(getFirstAvailableWorkspaces(availableWorkspaces)).toEqual( + signInWorkspace, + ); + }); + + it('should return first sign-up workspace when sign-in is empty', () => { + const signUpWorkspace = createMockAvailableWorkspace({ + id: 'sign-up-ws', + displayName: 'Sign Up Workspace', + }); + const availableWorkspaces = createMockAvailableWorkspaces( + [], + [signUpWorkspace], + ); + + expect(getFirstAvailableWorkspaces(availableWorkspaces)).toEqual( + signUpWorkspace, + ); + }); + + it('should return undefined when both arrays are empty', () => { + const availableWorkspaces = createMockAvailableWorkspaces([], []); + + expect(getFirstAvailableWorkspaces(availableWorkspaces)).toBeUndefined(); + }); + + it('should prioritize sign-in over sign-up workspace', () => { + const firstSignInWorkspace = createMockAvailableWorkspace({ + id: 'first-sign-in', + }); + const secondSignInWorkspace = createMockAvailableWorkspace({ + id: 'second-sign-in', + }); + const signUpWorkspace = createMockAvailableWorkspace({ + id: 'sign-up-ws', + }); + const availableWorkspaces = createMockAvailableWorkspaces( + [firstSignInWorkspace, secondSignInWorkspace], + [signUpWorkspace], + ); + + expect(getFirstAvailableWorkspaces(availableWorkspaces)).toEqual( + firstSignInWorkspace, + ); + }); + }); + + describe('getAvailableWorkspacePathAndSearchParams', () => { + describe('when workspace has loginToken', () => { + it('should return Verify path with loginToken in search params', () => { + const workspace = createMockAvailableWorkspace({ + loginToken: 'test-login-token', + }); + + const result = getAvailableWorkspacePathAndSearchParams(workspace); + + expect(result).toEqual({ + pathname: AppPath.Verify, + searchParams: { loginToken: 'test-login-token' }, + }); + }); + + it('should include default search params with loginToken', () => { + const workspace = createMockAvailableWorkspace({ + loginToken: 'test-login-token', + }); + + const result = getAvailableWorkspacePathAndSearchParams(workspace, { + foo: 'bar', + }); + + expect(result).toEqual({ + pathname: AppPath.Verify, + searchParams: { foo: 'bar', loginToken: 'test-login-token' }, + }); + }); + }); + + describe('when workspace has personalInviteToken and inviteHash', () => { + it('should return Invite path with inviteToken in search params', () => { + const workspace = createMockAvailableWorkspace({ + personalInviteToken: 'test-invite-token', + inviteHash: 'test-invite-hash', + }); + + const result = getAvailableWorkspacePathAndSearchParams(workspace); + + expect(result).toEqual({ + pathname: '/invite/test-invite-hash', + searchParams: { inviteToken: 'test-invite-token' }, + }); + }); + + it('should include default search params with inviteToken', () => { + const workspace = createMockAvailableWorkspace({ + personalInviteToken: 'test-invite-token', + inviteHash: 'test-invite-hash', + }); + + const result = getAvailableWorkspacePathAndSearchParams(workspace, { + source: 'email', + }); + + expect(result).toEqual({ + pathname: '/invite/test-invite-hash', + searchParams: { source: 'email', inviteToken: 'test-invite-token' }, + }); + }); + }); + + describe('when workspace has only personalInviteToken without inviteHash', () => { + it('should return SignInUp path with inviteToken in search params', () => { + const workspace = createMockAvailableWorkspace({ + personalInviteToken: 'test-invite-token', + inviteHash: undefined, + }); + + const result = getAvailableWorkspacePathAndSearchParams(workspace); + + expect(result).toEqual({ + pathname: AppPath.SignInUp, + searchParams: { inviteToken: 'test-invite-token' }, + }); + }); + }); + + describe('when workspace has neither loginToken nor invite tokens', () => { + it('should return SignInUp path with empty search params', () => { + const workspace = createMockAvailableWorkspace(); + + const result = getAvailableWorkspacePathAndSearchParams(workspace); + + expect(result).toEqual({ + pathname: AppPath.SignInUp, + searchParams: {}, + }); + }); + + it('should preserve default search params', () => { + const workspace = createMockAvailableWorkspace(); + + const result = getAvailableWorkspacePathAndSearchParams(workspace, { + redirect: '/dashboard', + }); + + expect(result).toEqual({ + pathname: AppPath.SignInUp, + searchParams: { redirect: '/dashboard' }, + }); + }); + }); + + describe('priority: loginToken over invite tokens', () => { + it('should prioritize loginToken when both loginToken and invite tokens exist', () => { + const workspace = createMockAvailableWorkspace({ + loginToken: 'test-login-token', + personalInviteToken: 'test-invite-token', + inviteHash: 'test-invite-hash', + }); + + const result = getAvailableWorkspacePathAndSearchParams(workspace); + + expect(result).toEqual({ + pathname: AppPath.Verify, + searchParams: { loginToken: 'test-login-token' }, + }); + }); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/__snapshots__/generateDepthRecordGqlFieldsFromRecord.test.ts.snap b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/__snapshots__/generateDepthRecordGqlFieldsFromRecord.test.ts.snap new file mode 100644 index 0000000000..713a3ef6f1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/__snapshots__/generateDepthRecordGqlFieldsFromRecord.test.ts.snap @@ -0,0 +1,92 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateDepthRecordGqlFieldsFromRecord should generate depth one record gql fields from empty record 1`] = ` +{ + "accountOwner": false, + "accountOwnerId": false, + "address": false, + "annualRecurringRevenue": false, + "attachments": false, + "createdAt": false, + "createdBy": false, + "deletedAt": false, + "domainName": false, + "employees": false, + "favorites": false, + "id": false, + "idealCustomerProfile": false, + "introVideo": false, + "linkedinLink": false, + "name": false, + "noteTargets": false, + "opportunities": false, + "people": false, + "position": false, + "searchVector": false, + "tagline": false, + "taskTargets": false, + "timelineActivities": false, + "updatedAt": false, + "visaSponsorship": false, + "workPolicy": false, + "xLink": false, +} +`; + +exports[`generateDepthRecordGqlFieldsFromRecord should generate depth one record gql fields from record with some fields 1`] = ` +{ + "accountOwner": false, + "accountOwnerId": false, + "address": false, + "annualRecurringRevenue": false, + "attachments": false, + "createdAt": false, + "createdBy": false, + "deletedAt": false, + "domainName": true, + "employees": false, + "favorites": false, + "id": true, + "idealCustomerProfile": false, + "introVideo": false, + "linkedinLink": false, + "name": true, + "noteTargets": false, + "opportunities": false, + "people": false, + "position": false, + "searchVector": false, + "tagline": false, + "taskTargets": false, + "timelineActivities": false, + "updatedAt": false, + "visaSponsorship": false, + "workPolicy": false, + "xLink": false, +} +`; + +exports[`generateDepthRecordGqlFieldsFromRecord should generate depth zero record gql fields from record with some fields 1`] = ` +{ + "accountOwnerId": false, + "address": false, + "annualRecurringRevenue": false, + "createdAt": false, + "createdBy": false, + "deletedAt": false, + "domainName": false, + "employees": false, + "id": true, + "idealCustomerProfile": false, + "introVideo": false, + "linkedinLink": false, + "name": true, + "position": false, + "searchVector": false, + "tagline": false, + "updatedAt": false, + "visaSponsorship": false, + "workPolicy": false, + "xLink": false, +} +`; diff --git a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/generateDepthRecordGqlFieldsFromRecord.test.ts b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/generateDepthRecordGqlFieldsFromRecord.test.ts new file mode 100644 index 0000000000..11c2de5e0d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/generateDepthRecordGqlFieldsFromRecord.test.ts @@ -0,0 +1,45 @@ +import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromRecord'; +import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems'; +import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow'; + +describe('generateDepthRecordGqlFieldsFromRecord', () => { + it('should generate depth one record gql fields from record with some fields', () => { + const result = generateDepthRecordGqlFieldsFromRecord({ + objectMetadataItem: getMockObjectMetadataItemOrThrow('company'), + objectMetadataItems: generatedMockObjectMetadataItems, + depth: 1, + record: { + id: '123', + name: 'Test Company', + domainName: 'test.com', + }, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should generate depth zero record gql fields from record with some fields', () => { + const result = generateDepthRecordGqlFieldsFromRecord({ + objectMetadataItem: getMockObjectMetadataItemOrThrow('company'), + objectMetadataItems: generatedMockObjectMetadataItems, + depth: 0, + record: { + id: '123', + name: 'Test Company', + }, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should generate depth one record gql fields from empty record', () => { + const result = generateDepthRecordGqlFieldsFromRecord({ + objectMetadataItem: getMockObjectMetadataItemOrThrow('company'), + objectMetadataItems: generatedMockObjectMetadataItems, + depth: 1, + record: {}, + }); + + expect(result).toMatchSnapshot(); + }); +});