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();
+ });
+});