Implement query variables in useCombinedFindManyRecords (#10015)

Implements filtering, ordering and cursor filtering for the hook
useCombinedFindManyRecords, because it was not implemented, which was
misleading because variables could be passed to it.

The difficult part was to make sure that the cursor filtering was
working, both before and after a cursor, because it was only hard coded
for last cursor (equivalent to after).

The duplicate limit parameter in the type RecordGqlOperationVariables
was merged into one limit parameter, because it was making the developer
guess how both could be handled.

This single limit parameter can be used for either : general limit
without cursor, first records from after cursor, last records until
before cursor. Since those cases are exclusive it's better to have only
one limit parameter and have an internal logic handling those cases.

Tests were added on the relevant parts, especially
useCombinedFindManyRecordsQueryVariables which requires its own unit
test to handle this cursor + limit logic.

Record show page pagination was tested to make sure removing the
duplicate limit parameter had no impact.
This commit is contained in:
Lucas Bordeau
2025-02-05 11:59:38 +01:00
committed by GitHub
parent 28a3f75946
commit 074cc113ac
9 changed files with 877 additions and 26 deletions

View File

@ -9,6 +9,5 @@ export type RecordGqlOperationVariables = {
cursorFilter?: {
cursor: string;
cursorDirection: QueryCursorDirection;
limit: number;
};
};

View File

@ -82,7 +82,7 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
: {}),
orderBy,
lastCursor: cursorFilter?.cursor ?? undefined,
limit: cursorFilter?.limit ?? limit,
limit,
},
fetchPolicy: fetchPolicy,
onCompleted: handleFindManyRecordsCompleted,

View File

@ -0,0 +1,570 @@
import { gql } from '@apollo/client';
import { renderHook, waitFor } from '@testing-library/react';
import { useSetRecoilState } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
import { useCombinedFindManyRecords } from '@/object-record/multiple-objects/hooks/useCombinedFindManyRecords';
import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
jest.mock(
'@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery',
() => ({
useGenerateCombinedFindManyRecordsQuery: jest.fn(),
}),
);
const mockQuery = gql`
query CombinedFindManyRecords(
$filterPerson: PersonFilterInput
$filterCompany: CompanyFilterInput
$orderByPerson: [PersonOrderByInput]
$orderByCompany: [CompanyOrderByInput]
$firstPerson: Int
$lastPerson: Int
$afterPerson: String
$beforePerson: String
$firstCompany: Int
$lastCompany: Int
$afterCompany: String
$beforeCompany: String
$limitPerson: Int
$limitCompany: Int
) {
people(
filter: $filterPerson
orderBy: $orderByPerson
first: $firstPerson
after: $afterPerson
last: $lastPerson
before: $beforePerson
limit: $limitPerson
) {
edges {
node {
__typename
id
name {
firstName
lastName
}
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
companies(
filter: $filterCompany
orderBy: $orderByCompany
first: $firstCompany
after: $afterCompany
last: $lastCompany
before: $beforeCompany
limit: $limitCompany
) {
edges {
node {
__typename
id
name
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
`;
type RenderUseCombinedFindManyRecordsHookParams = {
operationSignatures: RecordGqlOperationSignature[];
mockVariables?: Record<string, any>;
mockResponseData?: Record<string, any>;
skip?: boolean;
expectedResult?: Record<string, any>;
mockQueryResult?: any;
};
const renderUseCombinedFindManyRecordsHook = async ({
operationSignatures,
mockVariables = {},
mockResponseData,
skip = false,
expectedResult = {},
mockQueryResult = mockQuery,
}: RenderUseCombinedFindManyRecordsHookParams) => {
(useGenerateCombinedFindManyRecordsQuery as jest.Mock).mockReturnValue(
mockQueryResult,
);
const mocks = [
{
request: {
query: mockQuery,
variables: mockVariables,
},
result: {
data: mockResponseData,
},
},
];
const { result } = renderHook(
() => {
const setObjectMetadataItems = useSetRecoilState(
objectMetadataItemsState,
);
setObjectMetadataItems(generatedMockObjectMetadataItems);
return useCombinedFindManyRecords({
operationSignatures,
skip,
});
},
{
wrapper: getJestMetadataAndApolloMocksWrapper({ apolloMocks: mocks }),
},
);
expect(result.current.loading).toBe(!skip);
expect(result.current.result).toEqual({});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.result).toEqual(expectedResult);
return result;
};
describe('useCombinedFindManyRecords', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should return records for multiple objects', async () => {
const mockResponseData = {
people: {
edges: [
{
node: {
__typename: 'Person',
id: '1',
name: {
firstName: 'John',
lastName: 'Doe',
},
},
cursor: 'cursor1',
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: 'cursor1',
endCursor: 'cursor1',
},
totalCount: 1,
},
companies: {
edges: [
{
node: {
__typename: 'Company',
id: '1',
name: 'Twenty',
},
cursor: 'cursor1',
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: 'cursor1',
endCursor: 'cursor1',
},
totalCount: 1,
},
};
await renderUseCombinedFindManyRecordsHook({
operationSignatures: [
{
objectNameSingular: 'person',
fields: {
id: true,
name: {
firstName: true,
lastName: true,
},
} as RecordGqlFields,
variables: {},
},
{
objectNameSingular: 'company',
fields: {
id: true,
name: true,
} as RecordGqlFields,
variables: {},
},
],
mockResponseData,
expectedResult: {
people: [
{
__typename: 'Person',
id: '1',
name: {
firstName: 'John',
lastName: 'Doe',
},
},
],
companies: [
{
__typename: 'Company',
id: '1',
name: 'Twenty',
},
],
},
});
});
it('should handle forward pagination with after cursor and first limit', async () => {
const mockResponseData = {
people: {
edges: [
{
node: {
__typename: 'Person',
id: '1',
name: {
firstName: 'John',
lastName: 'Doe',
},
},
cursor: 'cursor1',
},
],
pageInfo: {
hasNextPage: true,
hasPreviousPage: true,
startCursor: 'cursor1',
endCursor: 'cursor1',
},
totalCount: 10,
},
};
await renderUseCombinedFindManyRecordsHook({
operationSignatures: [
{
objectNameSingular: 'person',
fields: {
id: true,
name: {
firstName: true,
lastName: true,
},
} as RecordGqlFields,
variables: {
limit: 1,
cursorFilter: {
cursor: 'previousCursor',
cursorDirection: 'after',
},
},
},
],
mockVariables: {
firstPerson: 1,
afterPerson: 'previousCursor',
},
mockResponseData,
expectedResult: {
people: [
{
__typename: 'Person',
id: '1',
name: {
firstName: 'John',
lastName: 'Doe',
},
},
],
},
});
});
it('should handle backward pagination with before cursor and last limit', async () => {
const mockResponseData = {
people: {
edges: [
{
node: {
__typename: 'Person',
id: '2',
name: {
firstName: 'Jane',
lastName: 'Smith',
},
},
cursor: 'cursor2',
},
],
pageInfo: {
hasNextPage: true,
hasPreviousPage: true,
startCursor: 'cursor2',
endCursor: 'cursor2',
},
totalCount: 10,
},
};
await renderUseCombinedFindManyRecordsHook({
operationSignatures: [
{
objectNameSingular: 'person',
fields: {
id: true,
name: {
firstName: true,
lastName: true,
},
} as RecordGqlFields,
variables: {
limit: 1,
cursorFilter: {
cursor: 'nextCursor',
cursorDirection: 'before',
},
},
},
],
mockVariables: {
lastPerson: 1,
beforePerson: 'nextCursor',
},
mockResponseData,
expectedResult: {
people: [
{
__typename: 'Person',
id: '2',
name: {
firstName: 'Jane',
lastName: 'Smith',
},
},
],
},
});
});
it('should handle limit-based pagination without cursor', async () => {
const mockResponseData = {
people: {
edges: [
{
node: {
__typename: 'Person',
id: '3',
name: {
firstName: 'Alice',
lastName: 'Johnson',
},
},
cursor: 'cursor3',
},
],
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'cursor3',
endCursor: 'cursor3',
},
totalCount: 10,
},
};
await renderUseCombinedFindManyRecordsHook({
operationSignatures: [
{
objectNameSingular: 'person',
fields: {
id: true,
name: {
firstName: true,
lastName: true,
},
} as RecordGqlFields,
variables: {
limit: 1,
},
},
],
mockVariables: {
limitPerson: 1,
},
mockResponseData,
expectedResult: {
people: [
{
__typename: 'Person',
id: '3',
name: {
firstName: 'Alice',
lastName: 'Johnson',
},
},
],
},
});
});
it('should handle multiple objects with different pagination strategies', async () => {
const mockResponseData = {
people: {
edges: [
{
node: {
__typename: 'Person',
id: '1',
name: {
firstName: 'John',
lastName: 'Doe',
},
},
cursor: 'cursor1',
},
],
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'cursor1',
endCursor: 'cursor1',
},
totalCount: 10,
},
companies: {
edges: [
{
node: {
__typename: 'Company',
id: '1',
name: 'Twenty',
},
cursor: 'cursor1',
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: 'cursor1',
endCursor: 'cursor1',
},
totalCount: 1,
},
};
await renderUseCombinedFindManyRecordsHook({
operationSignatures: [
{
objectNameSingular: 'person',
fields: {
id: true,
name: {
firstName: true,
lastName: true,
},
} as RecordGqlFields,
variables: {
limit: 1,
cursorFilter: {
cursor: 'previousCursor',
cursorDirection: 'after',
},
},
},
{
objectNameSingular: 'company',
fields: {
id: true,
name: true,
} as RecordGqlFields,
variables: {
limit: 1,
},
},
],
mockVariables: {
firstPerson: 1,
afterPerson: 'previousCursor',
limitCompany: 1,
},
mockResponseData,
expectedResult: {
people: [
{
__typename: 'Person',
id: '1',
name: {
firstName: 'John',
lastName: 'Doe',
},
},
],
companies: [
{
__typename: 'Company',
id: '1',
name: 'Twenty',
},
],
},
});
});
it('should handle empty operation signatures', async () => {
await renderUseCombinedFindManyRecordsHook({
operationSignatures: [],
mockResponseData: {},
expectedResult: {},
});
});
it('should handle skip flag', async () => {
await renderUseCombinedFindManyRecordsHook({
operationSignatures: [
{
objectNameSingular: 'person',
fields: {
id: true,
} as RecordGqlFields,
variables: {},
},
],
skip: true,
mockResponseData: {},
expectedResult: {},
});
});
});

View File

@ -0,0 +1,191 @@
import { RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
import { useCombinedFindManyRecordsQueryVariables } from '@/object-record/multiple-objects/hooks/useCombinedFindManyRecordsQueryVariables';
describe('useCombinedFindManyRecordsQueryVariables', () => {
it('should generate variables with after cursor and first limit', () => {
const operationSignatures: RecordGqlOperationSignature[] = [
{
objectNameSingular: 'person',
fields: {
id: true,
name: {
firstName: true,
lastName: true,
},
} as RecordGqlFields,
variables: {
filter: { id: { eq: '123' } },
orderBy: [{ createdAt: 'AscNullsLast' }],
limit: 10,
cursorFilter: {
cursor: 'cursor123',
cursorDirection: 'after',
},
},
},
];
const result = useCombinedFindManyRecordsQueryVariables({
operationSignatures,
});
expect(result).toEqual({
filterPerson: { id: { eq: '123' } },
orderByPerson: [{ createdAt: 'AscNullsLast' }],
afterPerson: 'cursor123',
firstPerson: 10,
});
});
it('should generate variables with before cursor and last limit', () => {
const operationSignatures: RecordGqlOperationSignature[] = [
{
objectNameSingular: 'person',
fields: {
id: true,
name: true,
} as RecordGqlFields,
variables: {
filter: { id: { eq: '123' } },
orderBy: [{ createdAt: 'AscNullsLast' }],
limit: 10,
cursorFilter: {
cursor: 'cursor123',
cursorDirection: 'before',
},
},
},
];
const result = useCombinedFindManyRecordsQueryVariables({
operationSignatures,
});
expect(result).toEqual({
filterPerson: { id: { eq: '123' } },
orderByPerson: [{ createdAt: 'AscNullsLast' }],
beforePerson: 'cursor123',
lastPerson: 10,
});
});
it('should generate variables with limit only (no cursor)', () => {
const operationSignatures: RecordGqlOperationSignature[] = [
{
objectNameSingular: 'person',
fields: {
id: true,
name: true,
} as RecordGqlFields,
variables: {
filter: { id: { eq: '123' } },
orderBy: [{ createdAt: 'AscNullsLast' }],
limit: 10,
},
},
];
const result = useCombinedFindManyRecordsQueryVariables({
operationSignatures,
});
expect(result).toEqual({
filterPerson: { id: { eq: '123' } },
orderByPerson: [{ createdAt: 'AscNullsLast' }],
limitPerson: 10,
});
});
it('should handle multiple objects with different pagination strategies', () => {
const operationSignatures: RecordGqlOperationSignature[] = [
{
objectNameSingular: 'person',
fields: {
id: true,
} as RecordGqlFields,
variables: {
filter: { id: { eq: '123' } },
limit: 10,
cursorFilter: {
cursor: 'cursor123',
cursorDirection: 'after',
},
},
},
{
objectNameSingular: 'company',
fields: {
id: true,
} as RecordGqlFields,
variables: {
filter: { name: { eq: 'Twenty' } },
limit: 20,
},
},
];
const result = useCombinedFindManyRecordsQueryVariables({
operationSignatures,
});
expect(result).toEqual({
filterPerson: { id: { eq: '123' } },
afterPerson: 'cursor123',
firstPerson: 10,
filterCompany: { name: { eq: 'Twenty' } },
limitCompany: 20,
});
});
it('should handle empty operation signatures', () => {
const result = useCombinedFindManyRecordsQueryVariables({
operationSignatures: [],
});
expect(result).toEqual({});
});
it('should handle empty variables', () => {
const operationSignatures: RecordGqlOperationSignature[] = [
{
objectNameSingular: 'person',
fields: {
id: true,
} as RecordGqlFields,
variables: {},
},
];
const result = useCombinedFindManyRecordsQueryVariables({
operationSignatures,
});
expect(result).toEqual({});
});
it('should handle cursor without limit', () => {
const operationSignatures: RecordGqlOperationSignature[] = [
{
objectNameSingular: 'person',
fields: {
id: true,
} as RecordGqlFields,
variables: {
cursorFilter: {
cursor: 'cursor123',
cursorDirection: 'after',
},
},
},
];
const result = useCombinedFindManyRecordsQueryVariables({
operationSignatures,
});
expect(result).toEqual({
afterPerson: 'cursor123',
});
});
});

View File

@ -3,6 +3,7 @@ import { useQuery } from '@apollo/client';
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
import { useCombinedFindManyRecordsQueryVariables } from '@/object-record/multiple-objects/hooks/useCombinedFindManyRecordsQueryVariables';
import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery';
import { MultiObjectRecordQueryResult } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
@ -17,10 +18,15 @@ export const useCombinedFindManyRecords = ({
operationSignatures,
});
const queryVariables = useCombinedFindManyRecordsQueryVariables({
operationSignatures,
});
const { data, loading } = useQuery<MultiObjectRecordQueryResult>(
findManyQuery ?? EMPTY_QUERY,
{
skip,
variables: queryVariables,
},
);

View File

@ -0,0 +1,75 @@
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
import { isNonEmptyString } from '@sniptt/guards';
import { capitalize, isDefined } from 'twenty-shared';
import { isNonEmptyArray } from '~/utils/isNonEmptyArray';
export const useCombinedFindManyRecordsQueryVariables = ({
operationSignatures,
}: {
operationSignatures: RecordGqlOperationSignature[];
}) => {
if (!isNonEmptyArray(operationSignatures)) {
return {};
}
return operationSignatures.reduce(
(acc, { objectNameSingular, variables }) => {
const capitalizedName = capitalize(objectNameSingular);
const filter = isDefined(variables?.filter)
? { [`filter${capitalizedName}`]: variables.filter }
: {};
const orderBy = isDefined(variables?.orderBy)
? { [`orderBy${capitalizedName}`]: variables.orderBy }
: {};
let limit = {};
const hasLimit = isDefined(variables.limit) && variables.limit > 0;
const cursorDirection = variables.cursorFilter?.cursorDirection;
let cursorFilter = {};
if (isNonEmptyString(variables.cursorFilter?.cursor)) {
if (cursorDirection === 'after') {
cursorFilter = {
[`after${capitalizedName}`]: variables.cursorFilter?.cursor,
};
if (hasLimit) {
cursorFilter = {
...cursorFilter,
[`first${capitalizedName}`]: variables.limit,
};
}
} else if (cursorDirection === 'before') {
cursorFilter = {
[`before${capitalizedName}`]: variables.cursorFilter?.cursor,
};
if (hasLimit) {
cursorFilter = {
...cursorFilter,
[`last${capitalizedName}`]: variables.limit,
};
}
}
} else if (hasLimit) {
limit = {
[`limit${capitalizedName}`]: variables.limit,
};
}
return {
...acc,
...filter,
...orderBy,
...limit,
...cursorFilter,
};
},
{},
);
};

View File

@ -6,6 +6,7 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { getCombinedFindManyRecordsQueryFilteringPart } from '@/object-record/multiple-objects/utils/getCombinedFindManyRecordsQueryFilteringPart';
import { capitalize } from 'twenty-shared';
import { isNonEmptyArray } from '~/utils/isNonEmptyArray';
@ -38,10 +39,10 @@ export const useGenerateCombinedFindManyRecordsQuery = ({
)
.join(', ');
const lastCursorPerMetadataItemArray = operationSignatures
const cursorFilteringPerMetadataItemArray = operationSignatures
.map(
({ objectNameSingular }) =>
`$lastCursor${capitalize(objectNameSingular)}: String`,
`$after${capitalize(objectNameSingular)}: String, $before${capitalize(objectNameSingular)}: String, $first${capitalize(objectNameSingular)}: Int, $last${capitalize(objectNameSingular)}: Int`,
)
.join(', ');
@ -52,48 +53,42 @@ export const useGenerateCombinedFindManyRecordsQuery = ({
)
.join(', ');
const queryKeyWithObjectMetadataItemArray = operationSignatures.map(
(queryKey) => {
const queryOperationSignatureWithObjectMetadataItemArray =
operationSignatures.map((operationSignature) => {
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === queryKey.objectNameSingular,
objectMetadataItem.nameSingular ===
operationSignature.objectNameSingular,
);
if (isUndefined(objectMetadataItem)) {
throw new Error(
`Object metadata item not found for object name singular: ${queryKey.objectNameSingular}`,
`Object metadata item not found for object name singular: ${operationSignature.objectNameSingular}`,
);
}
return { ...queryKey, objectMetadataItem };
},
);
return { operationSignature, objectMetadataItem };
});
return gql`
query CombinedFindManyRecords(
${filterPerMetadataItemArray},
${orderByPerMetadataItemArray},
${lastCursorPerMetadataItemArray},
${cursorFilteringPerMetadataItemArray},
${limitPerMetadataItemArray}
) {
${queryKeyWithObjectMetadataItemArray
${queryOperationSignatureWithObjectMetadataItemArray
.map(
({ objectMetadataItem, fields }) =>
`${objectMetadataItem.namePlural}(filter: $filter${capitalize(
objectMetadataItem.nameSingular,
)}, orderBy: $orderBy${capitalize(
objectMetadataItem.nameSingular,
)}, first: $limit${capitalize(
objectMetadataItem.nameSingular,
)}, after: $lastCursor${capitalize(
objectMetadataItem.nameSingular,
)}){
({ objectMetadataItem, operationSignature }) =>
`${getCombinedFindManyRecordsQueryFilteringPart(
objectMetadataItem,
)} {
edges {
node ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems: objectMetadataItems,
objectMetadataItem,
recordGqlFields:
fields ??
operationSignature.fields ??
generateDepthOneRecordGqlFields({
objectMetadataItem,
}),

View File

@ -0,0 +1,15 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from 'twenty-shared';
export const getCombinedFindManyRecordsQueryFilteringPart = (
objectMetadataItem: ObjectMetadataItem,
) => {
return `${objectMetadataItem.namePlural}(
filter: $filter${capitalize(objectMetadataItem.nameSingular)},
orderBy: $orderBy${capitalize(objectMetadataItem.nameSingular)},
after: $after${capitalize(objectMetadataItem.nameSingular)},
before: $before${capitalize(objectMetadataItem.nameSingular)},
first: $first${capitalize(objectMetadataItem.nameSingular)},
last: $last${capitalize(objectMetadataItem.nameSingular)},
limit: $limit${capitalize(objectMetadataItem.nameSingular)})`;
};

View File

@ -67,11 +67,11 @@ export const useRecordShowPagePagination = (
id: { neq: objectRecordId },
},
orderBy,
limit: isNonEmptyString(currentRecordCursorFromRequest) ? 1 : undefined,
cursorFilter: isNonEmptyString(currentRecordCursorFromRequest)
? {
cursorDirection: 'before',
cursor: currentRecordCursorFromRequest,
limit: 1,
}
: undefined,
objectNameSingular,
@ -90,11 +90,11 @@ export const useRecordShowPagePagination = (
},
fetchPolicy: 'network-only',
orderBy,
limit: isNonEmptyString(currentRecordCursorFromRequest) ? 1 : undefined,
cursorFilter: currentRecordCursorFromRequest
? {
cursorDirection: 'after',
cursor: currentRecordCursorFromRequest,
limit: 1,
}
: undefined,
objectNameSingular,