Fix fetch more notes (#16442)
fixes : https://github.com/twentyhq/twenty/issues/16320 --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+11
-3
@@ -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 (
|
||||
<div ref={tbodyRef}>
|
||||
<StyledContainer ref={tbodyRef}>
|
||||
{loading && <StyledText>Loading more...</StyledText>}
|
||||
</div>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+50
-13
@@ -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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+4
-11
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 = <T extends Task | Note>({
|
||||
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<Task | Note>({
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
+25
-10
@@ -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<TaskTarget | NoteTarget>({
|
||||
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<TaskTarget | NoteTarget>({
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
+1
@@ -40,6 +40,7 @@ export const useUpdateActivityTargetFromCell = ({
|
||||
joinObjectNameSingular === ''
|
||||
? activityObjectNameSingular
|
||||
: joinObjectNameSingular,
|
||||
shouldMatchRootQueryFilter: true,
|
||||
});
|
||||
|
||||
const { deleteOneRecord: deleteOneActivityTarget } = useDeleteOneRecord({
|
||||
|
||||
@@ -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 && (
|
||||
<StyledContainer>
|
||||
<StyledTitleBar>
|
||||
<StyledTitle>
|
||||
{title} <StyledCount>{notes.length}</StyledCount>
|
||||
{title} <StyledCount>{totalCount}</StyledCount>
|
||||
</StyledTitle>
|
||||
{button}
|
||||
</StyledTitleBar>
|
||||
|
||||
@@ -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 = () => {
|
||||
<NoteList
|
||||
title={t`All`}
|
||||
notes={notes}
|
||||
totalCount={totalCountNotes}
|
||||
button={
|
||||
hasObjectUpdatePermissions && (
|
||||
<Button
|
||||
@@ -103,6 +112,10 @@ export const NotesCard = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<CustomResolverFetchMoreLoader
|
||||
loading={loading}
|
||||
onLastRowVisible={handleLastRowVisible}
|
||||
/>
|
||||
</StyledNotesContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<Note>({
|
||||
const {
|
||||
activities,
|
||||
loading,
|
||||
totalCountActivities,
|
||||
fetchMoreActivities,
|
||||
hasNextPage,
|
||||
} = useActivities<Note>({
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,15 +9,24 @@ type UseTasksProps = {
|
||||
};
|
||||
|
||||
export const useTasks = ({ targetableObjects }: UseTasksProps) => {
|
||||
const { activities: tasks, loading: tasksLoading } = useActivities<Task>({
|
||||
const {
|
||||
activities: tasks,
|
||||
loading: tasksLoading,
|
||||
fetchMoreActivities: fetchMoreTasks,
|
||||
hasNextPage,
|
||||
totalCountActivities,
|
||||
} = useActivities<Task>({
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
+274
@@ -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> = {},
|
||||
): 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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+92
@@ -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,
|
||||
}
|
||||
`;
|
||||
+45
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user