feat: record group fetch more (#8868)

Fix #8756 

This PR is adding a load more button when we're grouping by a field in
table view.
Only 8 records are printed at first, and a click on `Load more` is
needed to show more records.

<img width="1347" alt="Screenshot 2024-12-04 at 11 54 15 AM"
src="https://github.com/user-attachments/assets/4ad6ad4f-8de9-424d-b7b6-5f82f28c53df">
<img width="1352" alt="Screenshot 2024-12-04 at 11 54 23 AM"
src="https://github.com/user-attachments/assets/2a94b4ac-7285-4ba2-9cff-d2f653e36302">

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2024-12-05 10:51:56 +01:00
committed by GitHub
parent b686e0b2e6
commit 7ab00a4c82
19 changed files with 789 additions and 165 deletions

View File

@ -0,0 +1,467 @@
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { ReactNode, act } from 'react';
import { RecordGroupContext } from '@/object-record/record-group/states/context/RecordGroupContext';
import { useLazyLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLazyLoadRecordIndexTable';
import { RecordTableComponentInstance } from '@/object-record/record-table/components/RecordTableComponentInstance';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { MockedResponse } from '@apollo/client/testing';
import gql from 'graphql-tag';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { getPeopleMock } from '~/testing/mock-data/people';
const recordTableId = 'people';
const objectNameSingular = 'person';
const onColumnsChange = jest.fn();
const ObjectNamePluralSetter = ({ children }: { children: ReactNode }) => {
return <>{children}</>;
};
const mocks: MockedResponse[] = [
{
request: {
query: gql`
query FindManyPeople(
$filter: PersonFilterInput
$orderBy: [PersonOrderByInput]
$lastCursor: String
$limit: Int
) {
people(
filter: $filter
orderBy: $orderBy
first: $limit
after: $lastCursor
) {
edges {
node {
__typename
avatarUrl
deletedAt
id
name {
firstName
lastName
}
noteTargets {
edges {
node {
__typename
company {
__typename
accountOwnerId
address {
addressStreet1
addressStreet2
addressCity
addressState
addressCountry
addressPostcode
addressLat
addressLng
}
annualRecurringRevenue {
amountMicros
currencyCode
}
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
domainName {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
employees
id
idealCustomerProfile
introVideo {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name
position
tagline
updatedAt
visaSponsorship
workPolicy
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
}
companyId
createdAt
deletedAt
id
note {
__typename
body
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
position
title
updatedAt
}
noteId
opportunity {
__typename
amount {
amountMicros
currencyCode
}
closeDate
companyId
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
name
pointOfContactId
position
stage
updatedAt
}
opportunityId
person {
__typename
avatarUrl
city
companyId
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
emails {
primaryEmail
additionalEmails
}
id
intro
jobTitle
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name {
firstName
lastName
}
performanceRating
phones {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
position
updatedAt
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
workPreference
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
}
personId
rocket {
__typename
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
name
position
updatedAt
}
rocketId
updatedAt
}
}
}
position
taskTargets {
edges {
node {
__typename
company {
__typename
accountOwnerId
address {
addressStreet1
addressStreet2
addressCity
addressState
addressCountry
addressPostcode
addressLat
addressLng
}
annualRecurringRevenue {
amountMicros
currencyCode
}
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
domainName {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
employees
id
idealCustomerProfile
introVideo {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name
position
tagline
updatedAt
visaSponsorship
workPolicy
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
}
companyId
createdAt
deletedAt
id
opportunity {
__typename
amount {
amountMicros
currencyCode
}
closeDate
companyId
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
name
pointOfContactId
position
stage
updatedAt
}
opportunityId
person {
__typename
avatarUrl
city
companyId
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
emails {
primaryEmail
additionalEmails
}
id
intro
jobTitle
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name {
firstName
lastName
}
performanceRating
phones {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
position
updatedAt
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
workPreference
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
}
personId
rocket {
__typename
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
name
position
updatedAt
}
rocketId
task {
__typename
assigneeId
body
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
dueAt
id
position
status
title
updatedAt
}
taskId
updatedAt
}
}
}
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
`,
variables: {
filter: {},
orderBy: [{ position: 'AscNullsFirst' }],
},
},
result: jest.fn(() => ({
data: {
people: getPeopleMock(),
},
})),
},
];
const HookMockWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
});
const Wrapper = ({ children }: { children: ReactNode }) => {
return (
<HookMockWrapper>
<ObjectNamePluralSetter>
<ViewComponentInstanceContext.Provider
value={{ instanceId: 'instanceId' }}
>
<RecordTableComponentInstance
recordTableId={recordTableId}
onColumnsChange={onColumnsChange}
>
<RecordGroupContext.Provider value={{ recordGroupId: 'default' }}>
{children}
</RecordGroupContext.Provider>
</RecordTableComponentInstance>
</ViewComponentInstanceContext.Provider>
</ObjectNamePluralSetter>
</HookMockWrapper>
);
};
describe('useLazyLoadRecordIndexTable', () => {
it('should fetch', async () => {
const { result } = renderHook(
() => {
const { findManyRecords, ...result } =
useLazyLoadRecordIndexTable(objectNameSingular);
return {
findManyRecords,
...result,
};
},
{
wrapper: Wrapper,
},
);
expect(result.current.loading).toBe(false);
act(() => {
result.current.findManyRecords();
});
expect(Array.isArray(result.current.records)).toBe(true);
expect(result.current.records.length).toBe(13);
});
});

View File

@ -1,58 +0,0 @@
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { mocks } from '@/auth/hooks/__mocks__/useAuth';
import { RecordGroupContext } from '@/object-record/record-group/states/context/RecordGroupContext';
import { useLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLoadRecordIndexTable';
import { RecordTableComponentInstance } from '@/object-record/record-table/components/RecordTableComponentInstance';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const recordTableId = 'people';
const objectNameSingular = 'person';
const onColumnsChange = jest.fn();
const ObjectNamePluralSetter = ({ children }: { children: ReactNode }) => {
return <>{children}</>;
};
const HookMockWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
});
const Wrapper = ({ children }: { children: ReactNode }) => {
return (
<HookMockWrapper>
<ObjectNamePluralSetter>
<ViewComponentInstanceContext.Provider
value={{ instanceId: 'instanceId' }}
>
<RecordTableComponentInstance
recordTableId={recordTableId}
onColumnsChange={onColumnsChange}
>
<RecordGroupContext.Provider value={{ recordGroupId: 'default' }}>
{children}
</RecordGroupContext.Provider>
</RecordTableComponentInstance>
</ViewComponentInstanceContext.Provider>
</ObjectNamePluralSetter>
</HookMockWrapper>
);
};
describe('useObjectRecordTable', () => {
it('should skip fetch if currentWorkspace is undefined', async () => {
const { result } = renderHook(
() => useLoadRecordIndexTable(objectNameSingular),
{
wrapper: Wrapper,
},
);
expect(result.current.loading).toBe(false);
expect(Array.isArray(result.current.records)).toBe(true);
expect(result.current.records.length).toBe(13);
});
});

View File

@ -142,7 +142,7 @@ export const useFetchMoreRecordsWithPagination = <
const pageInfo =
fetchMoreResult?.[objectMetadataItem.namePlural]?.pageInfo;
if (isDefined(data?.[objectMetadataItem.namePlural])) {
if (isDefined(pageInfo)) {
set(
cursorFamilyState(queryIdentifier),
pageInfo.endCursor ?? '',
@ -201,7 +201,6 @@ export const useFetchMoreRecordsWithPagination = <
fetchMore,
filter,
orderBy,
data,
onCompleted,
handleFindManyRecordsError,
queryIdentifier,

View File

@ -67,7 +67,7 @@ export const useLazyFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
onError: handleFindManyRecordsError,
});
const { fetchMoreRecords, totalCount, records } =
const { fetchMoreRecords, totalCount, records, hasNextPage } =
useFetchMoreRecordsWithPagination<T>({
objectNameSingular,
filter,
@ -108,8 +108,9 @@ export const useLazyFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
loading,
error,
fetchMore,
fetchMoreRecordsWithPagination: fetchMoreRecords,
fetchMoreRecords,
queryStateIdentifier: queryIdentifier,
findManyRecords: findManyRecordsLazy,
hasNextPage,
};
};