From 9c29c436b90e85aaa2566e66378b847fa2dad8fa Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Fri, 10 Nov 2023 12:43:14 +0100 Subject: [PATCH] Feat/pagination front (#2387) * Finished renaming and scope * wip * WIP update * Ok * Cleaned * Finished infinite scroll * Clean * Fixed V1 tables * Fix post merge * Removed ScrollWrapper * Put back ScrollWrapper * Put back in the right place --- front/package.json | 1 + .../hooks/useOptimisticEffect.ts | 67 +++++++-- .../types/OptimisticEffectDefinition.ts | 10 +- .../types/OptimisticEffectResolver.ts | 6 +- .../types/internal/OptimisticEffect.ts | 10 +- .../table/components/CompanyTable.tsx | 4 +- .../components/RecordTableContainer.tsx | 8 +- .../metadata/components/RecordTableEffect.tsx | 45 ++---- .../getRecordOptimisticEffectDefinition.ts | 33 ++++ .../metadata/hooks/useCreateOneObject.ts | 18 ++- .../metadata/hooks/useFindManyObjects.ts | 141 +++++++++++++++--- .../metadata/hooks/useSetRecordTableData.ts | 2 +- .../modules/metadata/hooks/useTableObjects.ts | 65 ++++++++ .../metadata/hooks/useUpdateOneObject.ts | 6 +- .../metadata/states/cursorFamilyState.ts | 6 + .../states/fetchMoreObjectsFamilyState.ts | 8 + .../metadata/states/hasNextPageFamilyState.ts | 6 + .../isFetchingMoreObjectsFamilyState.ts | 9 ++ .../types/PaginatedObjectTypeResults.ts | 18 ++- .../utils/generateCreateOneObjectMutation.ts | 5 + .../generateFindManyCustomObjectsQuery.ts | 17 ++- .../utils/generateUpdateOneObjectMutation.ts | 20 ++- .../people/table/components/PersonTable.tsx | 4 +- .../components/RecordTableBody.tsx | 98 ++++++------ .../components/RecordTableBodyV1.tsx | 32 ++++ .../components/RecordTableEffect.tsx | 3 +- .../components/RecordTableRow.tsx | 2 +- .../record-table/components/RecordTableV1.tsx | 139 +++++++++++++++++ front/yarn.lock | 5 + 29 files changed, 630 insertions(+), 158 deletions(-) create mode 100644 front/src/modules/metadata/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition.ts create mode 100644 front/src/modules/metadata/hooks/useTableObjects.ts create mode 100644 front/src/modules/metadata/states/cursorFamilyState.ts create mode 100644 front/src/modules/metadata/states/fetchMoreObjectsFamilyState.ts create mode 100644 front/src/modules/metadata/states/hasNextPageFamilyState.ts create mode 100644 front/src/modules/metadata/states/isFetchingMoreObjectsFamilyState.ts create mode 100644 front/src/modules/ui/object/record-table/components/RecordTableBodyV1.tsx create mode 100644 front/src/modules/ui/object/record-table/components/RecordTableV1.tsx diff --git a/front/package.json b/front/package.json index 7b904b028..77f9451ab 100644 --- a/front/package.json +++ b/front/package.json @@ -45,6 +45,7 @@ "react-helmet-async": "^1.3.0", "react-hook-form": "^7.45.1", "react-hotkeys-hook": "^4.4.0", + "react-intersection-observer": "^9.5.2", "react-loading-skeleton": "^3.3.1", "react-phone-number-input": "^3.3.4", "react-responsive": "^9.0.2", diff --git a/front/src/modules/apollo/optimistic-effect/hooks/useOptimisticEffect.ts b/front/src/modules/apollo/optimistic-effect/hooks/useOptimisticEffect.ts index f2d97daae..d13666f1f 100644 --- a/front/src/modules/apollo/optimistic-effect/hooks/useOptimisticEffect.ts +++ b/front/src/modules/apollo/optimistic-effect/hooks/useOptimisticEffect.ts @@ -4,9 +4,12 @@ import { OperationVariables, useApolloClient, } from '@apollo/client'; +import { isNonEmptyArray } from '@sniptt/guards'; import { useRecoilCallback } from 'recoil'; import { GET_COMPANIES } from '@/companies/graphql/queries/getCompanies'; +import { ObjectMetadataItem } from '@/metadata/types/ObjectMetadataItem'; +import { generateFindManyCustomObjectsQuery } from '@/metadata/utils/generateFindManyCustomObjectsQuery'; import { GET_PEOPLE } from '@/people/graphql/queries/getPeople'; import { GET_API_KEYS } from '@/settings/developers/graphql/queries/getApiKeys'; import { @@ -16,6 +19,7 @@ import { } from '~/generated/graphql'; import { optimisticEffectState } from '../states/optimisticEffectState'; +import { OptimisticEffect } from '../types/internal/OptimisticEffect'; import { OptimisticEffectDefinition } from '../types/OptimisticEffectDefinition'; export const useOptimisticEffect = () => { @@ -28,7 +32,7 @@ export const useOptimisticEffect = () => { definition, }: { variables: OperationVariables; - definition: OptimisticEffectDefinition; + definition: OptimisticEffectDefinition; }) => { const optimisticEffects = snapshot .getLoadable(optimisticEffectState) @@ -39,12 +43,47 @@ export const useOptimisticEffect = () => { newData, query, variables, + isUsingFlexibleBackend, + objectMetadataItem, }: { cache: ApolloCache; - newData: unknown[]; + newData: unknown; variables: OperationVariables; query: DocumentNode; + isUsingFlexibleBackend?: boolean; + objectMetadataItem?: ObjectMetadataItem; }) => { + if (isUsingFlexibleBackend && objectMetadataItem) { + const generatedQuery = generateFindManyCustomObjectsQuery({ + objectMetadataItem, + }); + + const existingData = cache.readQuery({ + query: generatedQuery, + variables, + }); + + if (!existingData) { + return; + } + + cache.writeQuery({ + query: generatedQuery, + variables, + data: { + [objectMetadataItem.namePlural]: definition.resolver({ + currentData: (existingData as any)?.[ + objectMetadataItem.namePlural + ], + newData, + variables, + }), + }, + }); + + return; + } + const existingData = cache.readQuery({ query, variables, @@ -82,6 +121,7 @@ export const useOptimisticEffect = () => { }, }); } + if (query === GET_API_KEYS) { cache.writeQuery({ query, @@ -104,7 +144,9 @@ export const useOptimisticEffect = () => { typename: definition.typename, query: definition.query, writer: optimisticEffectWriter, - }; + objectMetadataItem: definition.objectMetadataItem, + isUsingFlexibleBackend: definition.isUsingFlexibleBackend, + } satisfies OptimisticEffect; set(optimisticEffectState, { ...optimisticEffects, @@ -115,26 +157,31 @@ export const useOptimisticEffect = () => { const triggerOptimisticEffects = useRecoilCallback( ({ snapshot }) => - (typename: string, newData: any[]) => { + (typename: string, newData: unknown) => { const optimisticEffects = snapshot .getLoadable(optimisticEffectState) .getValue(); - Object.values(optimisticEffects).forEach((optimisticEffect) => { + for (const optimisticEffect of Object.values(optimisticEffects)) { // We need to update the typename when createObject type differs from listObject types // It is the case for apiKey, where the creation route returns an ApiKeyToken type - const formattedNewData = newData.map((data) => { - return { ...data, __typename: typename }; - }); + const formattedNewData = isNonEmptyArray(newData) + ? newData.map((data: any) => { + return { ...data, __typename: typename }; + }) + : newData; + if (optimisticEffect.typename === typename) { optimisticEffect.writer({ cache: apolloClient.cache, - query: optimisticEffect.query, + query: optimisticEffect.query ?? ({} as DocumentNode), newData: formattedNewData, variables: optimisticEffect.variables, + isUsingFlexibleBackend: optimisticEffect.isUsingFlexibleBackend, + objectMetadataItem: optimisticEffect.objectMetadataItem, }); } - }); + } }, ); diff --git a/front/src/modules/apollo/optimistic-effect/types/OptimisticEffectDefinition.ts b/front/src/modules/apollo/optimistic-effect/types/OptimisticEffectDefinition.ts index 9e7e1bc8a..db8c8bf49 100644 --- a/front/src/modules/apollo/optimistic-effect/types/OptimisticEffectDefinition.ts +++ b/front/src/modules/apollo/optimistic-effect/types/OptimisticEffectDefinition.ts @@ -1,10 +1,14 @@ import { DocumentNode } from 'graphql'; +import { ObjectMetadataItem } from '@/metadata/types/ObjectMetadataItem'; + import { OptimisticEffectResolver } from './OptimisticEffectResolver'; -export type OptimisticEffectDefinition = { +export type OptimisticEffectDefinition = { key: string; - query: DocumentNode; + query?: DocumentNode; typename: string; - resolver: OptimisticEffectResolver; + resolver: OptimisticEffectResolver; + objectMetadataItem?: ObjectMetadataItem; + isUsingFlexibleBackend?: boolean; }; diff --git a/front/src/modules/apollo/optimistic-effect/types/OptimisticEffectResolver.ts b/front/src/modules/apollo/optimistic-effect/types/OptimisticEffectResolver.ts index a1ca99aa7..f1d956b4c 100644 --- a/front/src/modules/apollo/optimistic-effect/types/OptimisticEffectResolver.ts +++ b/front/src/modules/apollo/optimistic-effect/types/OptimisticEffectResolver.ts @@ -1,11 +1,11 @@ import { OperationVariables } from '@apollo/client'; -export type OptimisticEffectResolver = ({ +export type OptimisticEffectResolver = ({ currentData, newData, variables, }: { - currentData: T[]; - newData: T[]; + currentData: any; //TODO: Change when decommissioning v1 + newData: any; //TODO: Change when decommissioning v1 variables: OperationVariables; }) => void; diff --git a/front/src/modules/apollo/optimistic-effect/types/internal/OptimisticEffect.ts b/front/src/modules/apollo/optimistic-effect/types/internal/OptimisticEffect.ts index eeedf4f22..040d89316 100644 --- a/front/src/modules/apollo/optimistic-effect/types/internal/OptimisticEffect.ts +++ b/front/src/modules/apollo/optimistic-effect/types/internal/OptimisticEffect.ts @@ -1,5 +1,7 @@ import { ApolloCache, DocumentNode, OperationVariables } from '@apollo/client'; +import { ObjectMetadataItem } from '@/metadata/types/ObjectMetadataItem'; + type OptimisticEffectWriter = ({ cache, newData, @@ -8,14 +10,18 @@ type OptimisticEffectWriter = ({ }: { cache: ApolloCache; query: DocumentNode; - newData: T[]; + newData: T; variables: OperationVariables; + objectMetadataItem?: ObjectMetadataItem; + isUsingFlexibleBackend?: boolean; }) => void; export type OptimisticEffect = { key: string; - query: DocumentNode; + query?: DocumentNode; typename: string; variables: OperationVariables; writer: OptimisticEffectWriter; + objectMetadataItem?: ObjectMetadataItem; + isUsingFlexibleBackend?: boolean; }; diff --git a/front/src/modules/companies/table/components/CompanyTable.tsx b/front/src/modules/companies/table/components/CompanyTable.tsx index 522a5a08f..78bfbea34 100644 --- a/front/src/modules/companies/table/components/CompanyTable.tsx +++ b/front/src/modules/companies/table/components/CompanyTable.tsx @@ -5,8 +5,8 @@ import { companiesAvailableFieldDefinitions } from '@/companies/constants/compan import { getCompaniesOptimisticEffectDefinition } from '@/companies/graphql/optimistic-effect-definitions/getCompaniesOptimisticEffectDefinition'; import { useCompanyTableContextMenuEntries } from '@/companies/hooks/useCompanyTableContextMenuEntries'; import { useSpreadsheetCompanyImport } from '@/companies/hooks/useSpreadsheetCompanyImport'; -import { RecordTable } from '@/ui/object/record-table/components/RecordTable'; import { RecordTableEffect } from '@/ui/object/record-table/components/RecordTableEffect'; +import { RecordTableV1 } from '@/ui/object/record-table/components/RecordTableV1'; import { TableOptionsDropdownId } from '@/ui/object/record-table/constants/TableOptionsDropdownId'; import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable'; import { TableOptionsDropdown } from '@/ui/object/record-table/options/components/TableOptionsDropdown'; @@ -135,7 +135,7 @@ export const CompanyTable = () => { setContextMenuEntries={setContextMenuEntries} setActionBarEntries={setActionBarEntries} /> - { - const { columnDefinitions } = useFindOneObjectMetadataItem({ - objectNamePlural, - }); + const { columnDefinitions, foundObjectMetadataItem } = + useFindOneObjectMetadataItem({ + objectNamePlural, + }); const { updateOneObject } = useUpdateOneObject({ objectNamePlural, + objectNameSingular: foundObjectMetadataItem?.nameSingular, }); const tableScopeId = objectNamePlural ?? ''; diff --git a/front/src/modules/metadata/components/RecordTableEffect.tsx b/front/src/modules/metadata/components/RecordTableEffect.tsx index 4e3cf803c..5465f63bf 100644 --- a/front/src/modules/metadata/components/RecordTableEffect.tsx +++ b/front/src/modules/metadata/components/RecordTableEffect.tsx @@ -1,27 +1,25 @@ import { useEffect } from 'react'; -import { useRecoilValue } from 'recoil'; -import { turnFiltersIntoWhereClauseV2 } from '@/ui/object/object-filter-dropdown/utils/turnFiltersIntoWhereClauseV2'; -import { turnSortsIntoOrderByV2 } from '@/ui/object/object-sort-dropdown/utils/turnSortsIntoOrderByV2'; -import { useRecordTableScopedStates } from '@/ui/object/record-table/hooks/internal/useRecordTableScopedStates'; +import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable'; import { useView } from '@/views/hooks/useView'; import { ViewType } from '@/views/types/ViewType'; -import { useRecordTable } from '../../ui/object/record-table/hooks/useRecordTable'; -import { useFindManyObjects } from '../hooks/useFindManyObjects'; import { useFindOneObjectMetadataItem } from '../hooks/useFindOneObjectMetadataItem'; +import { useTableObjects } from '../hooks/useTableObjects'; export const RecordTableEffect = () => { - const { scopeId } = useRecordTable(); + const { scopeId: objectNamePlural, setAvailableTableColumns } = + useRecordTable(); const { - foundObjectMetadataItem, columnDefinitions, filterDefinitions, sortDefinitions, + foundObjectMetadataItem, } = useFindOneObjectMetadataItem({ - objectNamePlural: scopeId, + objectNamePlural, }); + const { setAvailableSortDefinitions, setAvailableFilterDefinitions, @@ -30,32 +28,7 @@ export const RecordTableEffect = () => { setViewObjectId, } = useView(); - const { setRecordTableData, setAvailableTableColumns } = useRecordTable(); - - const { tableFiltersState, tableSortsState } = useRecordTableScopedStates(); - - const tableFilters = useRecoilValue(tableFiltersState); - const tableSorts = useRecoilValue(tableSortsState); - - const { objects, loading } = useFindManyObjects({ - objectNamePlural: scopeId, - filter: turnFiltersIntoWhereClauseV2( - tableFilters, - foundObjectMetadataItem?.fields ?? [], - ), - orderBy: turnSortsIntoOrderByV2( - tableSorts, - foundObjectMetadataItem?.fields ?? [], - ), - }); - - useEffect(() => { - if (!loading) { - const entities = objects ?? []; - - setRecordTableData(entities); - } - }, [objects, setRecordTableData, loading]); + useTableObjects(); useEffect(() => { if (!foundObjectMetadataItem) { @@ -70,7 +43,6 @@ export const RecordTableEffect = () => { setAvailableTableColumns(columnDefinitions); }, [ - setAvailableTableColumns, setViewObjectId, setViewType, columnDefinitions, @@ -80,6 +52,7 @@ export const RecordTableEffect = () => { foundObjectMetadataItem, sortDefinitions, filterDefinitions, + setAvailableTableColumns, ]); return <>; diff --git a/front/src/modules/metadata/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition.ts b/front/src/modules/metadata/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition.ts new file mode 100644 index 000000000..79f036afb --- /dev/null +++ b/front/src/modules/metadata/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition.ts @@ -0,0 +1,33 @@ +import { produce } from 'immer'; + +import { OptimisticEffectDefinition } from '@/apollo/optimistic-effect/types/OptimisticEffectDefinition'; +import { ObjectMetadataItem } from '@/metadata/types/ObjectMetadataItem'; +import { PaginatedObjectTypeResults } from '@/metadata/types/PaginatedObjectTypeResults'; +import { capitalize } from '~/utils/string/capitalize'; + +export const getRecordOptimisticEffectDefinition = ({ + objectMetadataItem, +}: { + objectMetadataItem: ObjectMetadataItem; +}) => + ({ + key: `record-create-optimistic-effect-definition-${objectMetadataItem.nameSingular}`, + typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, + resolver: ({ + currentData, + newData, + }: { + currentData: unknown; + newData: unknown; + }) => { + const newRecordPaginatedCacheField = produce< + PaginatedObjectTypeResults + >(currentData as PaginatedObjectTypeResults, (draft) => { + draft.edges.unshift({ node: newData, cursor: '' }); + }); + + return newRecordPaginatedCacheField; + }, + isUsingFlexibleBackend: true, + objectMetadataItem, + } satisfies OptimisticEffectDefinition); diff --git a/front/src/modules/metadata/hooks/useCreateOneObject.ts b/front/src/modules/metadata/hooks/useCreateOneObject.ts index 5d2f9e161..e17ca153e 100644 --- a/front/src/modules/metadata/hooks/useCreateOneObject.ts +++ b/front/src/modules/metadata/hooks/useCreateOneObject.ts @@ -1,7 +1,8 @@ import { useMutation } from '@apollo/client'; -import { getOperationName } from '@apollo/client/utilities'; +import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; import { Currency, FieldMetadataType } from '~/generated-metadata/graphql'; +import { capitalize } from '~/utils/string/capitalize'; import { ObjectMetadataItemIdentifier } from '../types/ObjectMetadataItemIdentifier'; @@ -23,10 +24,11 @@ const defaultFieldValues: Record = { export const useCreateOneObject = ({ objectNamePlural, }: Pick) => { + const { triggerOptimisticEffects } = useOptimisticEffect(); + const { foundObjectMetadataItem, objectNotFoundInMetadata, - findManyQuery, createOneMutation, } = useFindOneObjectMetadataItem({ objectNamePlural, @@ -36,8 +38,8 @@ export const useCreateOneObject = ({ const [mutate] = useMutation(createOneMutation); const createOneObject = foundObjectMetadataItem - ? (input: Record = {}) => { - return mutate({ + ? async (input: Record) => { + const createdObject = await mutate({ variables: { input: { ...foundObjectMetadataItem.fields.reduce( @@ -50,8 +52,14 @@ export const useCreateOneObject = ({ ...input, }, }, - refetchQueries: [getOperationName(findManyQuery) ?? ''], }); + + triggerOptimisticEffects( + `${capitalize(foundObjectMetadataItem.nameSingular)}Edge`, + createdObject.data[ + `create${capitalize(foundObjectMetadataItem.nameSingular)}` + ], + ); } : undefined; diff --git a/front/src/modules/metadata/hooks/useFindManyObjects.ts b/front/src/modules/metadata/hooks/useFindManyObjects.ts index 4f508bc6a..c13b29e4f 100644 --- a/front/src/modules/metadata/hooks/useFindManyObjects.ts +++ b/front/src/modules/metadata/hooks/useFindManyObjects.ts @@ -1,12 +1,22 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useQuery } from '@apollo/client'; +import { isNonEmptyArray } from '@apollo/client/utilities'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useRecoilState } from 'recoil'; import { useSnackBar } from '@/ui/feedback/snack-bar/hooks/useSnackBar'; import { logError } from '~/utils/logError'; +import { capitalize } from '~/utils/string/capitalize'; +import { cursorFamilyState } from '../states/cursorFamilyState'; +import { hasNextPageFamilyState } from '../states/hasNextPageFamilyState'; +import { isFetchingMoreObjectsFamilyState } from '../states/isFetchingMoreObjectsFamilyState'; import { ObjectMetadataItemIdentifier } from '../types/ObjectMetadataItemIdentifier'; import { PaginatedObjectType } from '../types/PaginatedObjectType'; -import { PaginatedObjectTypeResults } from '../types/PaginatedObjectTypeResults'; +import { + PaginatedObjectTypeEdge, + PaginatedObjectTypeResults, +} from '../types/PaginatedObjectTypeResults'; import { formatPagedObjectsToObjects } from '../utils/formatPagedObjectsToObjects'; import { useFindOneObjectMetadataItem } from './useFindOneObjectMetadataItem'; @@ -27,6 +37,18 @@ export const useFindManyObjects = < onCompleted?: (data: PaginatedObjectTypeResults) => void; skip?: boolean; }) => { + const [lastCursor, setLastCursor] = useRecoilState( + cursorFamilyState(objectNamePlural), + ); + + const [hasNextPage, setHasNextPage] = useRecoilState( + hasNextPageFamilyState(objectNamePlural), + ); + + const [, setIsFetchingMoreObjects] = useRecoilState( + isFetchingMoreObjectsFamilyState(objectNamePlural), + ); + const { foundObjectMetadataItem, objectNotFoundInMetadata, findManyQuery } = useFindOneObjectMetadataItem({ objectNamePlural, @@ -35,29 +57,107 @@ export const useFindManyObjects = < const { enqueueSnackBar } = useSnackBar(); - const { data, loading, error } = useQuery>( - findManyQuery, - { - skip: skip || !foundObjectMetadataItem, - variables: { - filter: filter ?? {}, - orderBy: orderBy ?? {}, - }, - onCompleted: (data) => - objectNamePlural && onCompleted?.(data[objectNamePlural]), - onError: (error) => { - logError( - `useFindManyObjects for "${objectNamePlural}" error : ` + error, - ); + const { data, loading, error, fetchMore } = useQuery< + PaginatedObjectType + >(findManyQuery, { + skip: skip || !foundObjectMetadataItem || !objectNamePlural, + variables: { + filter: filter ?? {}, + orderBy: orderBy ?? {}, + }, + onCompleted: (data) => { + if (objectNamePlural) { + onCompleted?.(data[objectNamePlural]); + + if (objectNamePlural && data?.[objectNamePlural]) { + setLastCursor(data?.[objectNamePlural]?.pageInfo.endCursor); + setHasNextPage(data?.[objectNamePlural]?.pageInfo.hasNextPage); + } + } + }, + onError: (error) => { + logError(`useFindManyObjects for "${objectNamePlural}" error : ` + error); + enqueueSnackBar( + `Error during useFindManyObjects for "${objectNamePlural}", ${error.message}`, + { + variant: 'error', + }, + ); + }, + }); + + const fetchMoreObjects = useCallback(async () => { + if (objectNamePlural && hasNextPage) { + setIsFetchingMoreObjects(true); + + try { + await fetchMore({ + variables: { + filter: filter ?? {}, + orderBy: orderBy ?? {}, + lastCursor: isNonEmptyString(lastCursor) ? lastCursor : undefined, + }, + updateQuery: (prev, { fetchMoreResult }) => { + const uniqueByCursor = ( + a: PaginatedObjectTypeEdge[], + ) => { + const seenCursors = new Set(); + + return a.filter((item) => { + const currentCursor = item.cursor; + + return seenCursors.has(currentCursor) + ? false + : seenCursors.add(currentCursor); + }); + }; + + const previousEdges = prev?.[objectNamePlural]?.edges; + const nextEdges = fetchMoreResult?.[objectNamePlural]?.edges; + + let newEdges: any[] = []; + + if (isNonEmptyArray(previousEdges) && isNonEmptyArray(nextEdges)) { + newEdges = uniqueByCursor([ + ...prev?.[objectNamePlural]?.edges, + ...fetchMoreResult?.[objectNamePlural]?.edges, + ]); + } + + return Object.assign({}, prev, { + [objectNamePlural]: { + __typename: `${capitalize( + foundObjectMetadataItem?.nameSingular ?? '', + )}Connection`, + edges: newEdges, + pageInfo: fetchMoreResult?.[objectNamePlural].pageInfo, + }, + } as PaginatedObjectType); + }, + }); + } catch (error) { + logError(`fetchMoreObjects for "${objectNamePlural}" error : ` + error); enqueueSnackBar( - `Error during useFindManyObjects for "${objectNamePlural}", ${error.message}`, + `Error during fetchMoreObjects for "${objectNamePlural}", ${error}`, { variant: 'error', }, ); - }, - }, - ); + } finally { + setIsFetchingMoreObjects(false); + } + } + }, [ + objectNamePlural, + lastCursor, + fetchMore, + filter, + orderBy, + foundObjectMetadataItem, + hasNextPage, + setIsFetchingMoreObjects, + enqueueSnackBar, + ]); const objects = useMemo( () => @@ -75,5 +175,6 @@ export const useFindManyObjects = < loading, error, objectNotFoundInMetadata, + fetchMoreObjects, }; }; diff --git a/front/src/modules/metadata/hooks/useSetRecordTableData.ts b/front/src/modules/metadata/hooks/useSetRecordTableData.ts index a6f0b9272..4400930b9 100644 --- a/front/src/modules/metadata/hooks/useSetRecordTableData.ts +++ b/front/src/modules/metadata/hooks/useSetRecordTableData.ts @@ -13,7 +13,7 @@ export const useSetRecordTableData = () => { return useRecoilCallback( ({ set, snapshot }) => - (newEntityArray: T[]) => { + >(newEntityArray: T[]) => { for (const entity of newEntityArray) { const currentEntity = snapshot .getLoadable(entityFieldsFamilyState(entity.id)) diff --git a/front/src/modules/metadata/hooks/useTableObjects.ts b/front/src/modules/metadata/hooks/useTableObjects.ts new file mode 100644 index 000000000..1b0b413a7 --- /dev/null +++ b/front/src/modules/metadata/hooks/useTableObjects.ts @@ -0,0 +1,65 @@ +import { useRecoilValue } from 'recoil'; + +import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; +import { turnFiltersIntoWhereClauseV2 } from '@/ui/object/object-filter-dropdown/utils/turnFiltersIntoWhereClauseV2'; +import { turnSortsIntoOrderByV2 } from '@/ui/object/object-sort-dropdown/utils/turnSortsIntoOrderByV2'; +import { useRecordTableScopedStates } from '@/ui/object/record-table/hooks/internal/useRecordTableScopedStates'; +import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable'; + +import { getRecordOptimisticEffectDefinition } from '../graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition'; + +import { useFindManyObjects } from './useFindManyObjects'; +import { useFindOneObjectMetadataItem } from './useFindOneObjectMetadataItem'; + +export const useTableObjects = () => { + const { scopeId: objectNamePlural } = useRecordTable(); + + const { registerOptimisticEffect } = useOptimisticEffect(); + + const { foundObjectMetadataItem } = useFindOneObjectMetadataItem({ + objectNamePlural, + }); + + const { setRecordTableData } = useRecordTable(); + + const { tableFiltersState, tableSortsState } = useRecordTableScopedStates(); + + const tableFilters = useRecoilValue(tableFiltersState); + const tableSorts = useRecoilValue(tableSortsState); + + const filter = turnFiltersIntoWhereClauseV2( + tableFilters, + foundObjectMetadataItem?.fields ?? [], + ); + + const orderBy = turnSortsIntoOrderByV2( + tableSorts, + foundObjectMetadataItem?.fields ?? [], + ); + + const { objects, loading, fetchMoreObjects } = useFindManyObjects({ + objectNamePlural, + filter, + orderBy, + onCompleted: (data) => { + const entities = data.edges.map((edge) => edge.node) ?? []; + + setRecordTableData(entities); + + if (foundObjectMetadataItem) { + registerOptimisticEffect({ + variables: { orderBy, filter }, + definition: getRecordOptimisticEffectDefinition({ + objectMetadataItem: foundObjectMetadataItem, + }), + }); + } + }, + }); + + return { + objects, + loading, + fetchMoreObjects, + }; +}; diff --git a/front/src/modules/metadata/hooks/useUpdateOneObject.ts b/front/src/modules/metadata/hooks/useUpdateOneObject.ts index fab452d7f..228edd297 100644 --- a/front/src/modules/metadata/hooks/useUpdateOneObject.ts +++ b/front/src/modules/metadata/hooks/useUpdateOneObject.ts @@ -1,5 +1,4 @@ import { useMutation } from '@apollo/client'; -import { getOperationName } from '@apollo/client/utilities'; import { ObjectMetadataItemIdentifier } from '../types/ObjectMetadataItemIdentifier'; @@ -12,7 +11,6 @@ export const useUpdateOneObject = ({ const { foundObjectMetadataItem, objectNotFoundInMetadata, - findManyQuery, updateOneMutation, } = useFindOneObjectMetadataItem({ objectNamePlural, @@ -20,9 +18,7 @@ export const useUpdateOneObject = ({ }); // TODO: type this with a minimal type at least with Record - const [mutate] = useMutation(updateOneMutation, { - refetchQueries: [getOperationName(findManyQuery) ?? ''], - }); + const [mutate] = useMutation(updateOneMutation); const updateOneObject = foundObjectMetadataItem ? ({ diff --git a/front/src/modules/metadata/states/cursorFamilyState.ts b/front/src/modules/metadata/states/cursorFamilyState.ts new file mode 100644 index 000000000..e9c44191e --- /dev/null +++ b/front/src/modules/metadata/states/cursorFamilyState.ts @@ -0,0 +1,6 @@ +import { atomFamily } from 'recoil'; + +export const cursorFamilyState = atomFamily({ + key: 'cursorFamilyState', + default: '', +}); diff --git a/front/src/modules/metadata/states/fetchMoreObjectsFamilyState.ts b/front/src/modules/metadata/states/fetchMoreObjectsFamilyState.ts new file mode 100644 index 000000000..510e030e2 --- /dev/null +++ b/front/src/modules/metadata/states/fetchMoreObjectsFamilyState.ts @@ -0,0 +1,8 @@ +import { atomFamily } from 'recoil'; + +export const fetchMoreObjectsFamilyState = atomFamily< + { fetchMore: () => void }, + string +>({ + key: 'fetchMoreObjectsFamilyState', +}); diff --git a/front/src/modules/metadata/states/hasNextPageFamilyState.ts b/front/src/modules/metadata/states/hasNextPageFamilyState.ts new file mode 100644 index 000000000..20260b927 --- /dev/null +++ b/front/src/modules/metadata/states/hasNextPageFamilyState.ts @@ -0,0 +1,6 @@ +import { atomFamily } from 'recoil'; + +export const hasNextPageFamilyState = atomFamily({ + key: 'hasNextPageFamilyState', + default: false, +}); diff --git a/front/src/modules/metadata/states/isFetchingMoreObjectsFamilyState.ts b/front/src/modules/metadata/states/isFetchingMoreObjectsFamilyState.ts new file mode 100644 index 000000000..2f330f7f5 --- /dev/null +++ b/front/src/modules/metadata/states/isFetchingMoreObjectsFamilyState.ts @@ -0,0 +1,9 @@ +import { atomFamily } from 'recoil'; + +export const isFetchingMoreObjectsFamilyState = atomFamily< + boolean, + string | undefined +>({ + key: 'isFetchingMoreObjectsFamilyState', + default: false, +}); diff --git a/front/src/modules/metadata/types/PaginatedObjectTypeResults.ts b/front/src/modules/metadata/types/PaginatedObjectTypeResults.ts index d4427780a..03f86bd71 100644 --- a/front/src/modules/metadata/types/PaginatedObjectTypeResults.ts +++ b/front/src/modules/metadata/types/PaginatedObjectTypeResults.ts @@ -1,6 +1,14 @@ -export type PaginatedObjectTypeResults = { - edges: { - node: ObjectType; - cursor: string; - }[]; +export type PaginatedObjectTypeEdge = { + node: ObjectType; + cursor: string; +}; + +export type PaginatedObjectTypeResults = { + __typename?: string; + edges: PaginatedObjectTypeEdge[]; + pageInfo: { + hasNextPage: boolean; + startCursor: string; + endCursor: string; + }; }; diff --git a/front/src/modules/metadata/utils/generateCreateOneObjectMutation.ts b/front/src/modules/metadata/utils/generateCreateOneObjectMutation.ts index 630910215..83d5d39e9 100644 --- a/front/src/modules/metadata/utils/generateCreateOneObjectMutation.ts +++ b/front/src/modules/metadata/utils/generateCreateOneObjectMutation.ts @@ -4,6 +4,8 @@ import { capitalize } from '~/utils/string/capitalize'; import { ObjectMetadataItem } from '../types/ObjectMetadataItem'; +import { mapFieldMetadataToGraphQLQuery } from './mapFieldMetadataToGraphQLQuery'; + export const generateCreateOneObjectMutation = ({ objectMetadataItem, }: { @@ -15,6 +17,9 @@ export const generateCreateOneObjectMutation = ({ mutation CreateOne${capitalizedObjectName}($input: ${capitalizedObjectName}CreateInput!) { create${capitalizedObjectName}(data: $input) { id + ${objectMetadataItem.fields + .map(mapFieldMetadataToGraphQLQuery) + .join('\n')} } } `; diff --git a/front/src/modules/metadata/utils/generateFindManyCustomObjectsQuery.ts b/front/src/modules/metadata/utils/generateFindManyCustomObjectsQuery.ts index 8dd975668..b704721ae 100644 --- a/front/src/modules/metadata/utils/generateFindManyCustomObjectsQuery.ts +++ b/front/src/modules/metadata/utils/generateFindManyCustomObjectsQuery.ts @@ -8,18 +8,20 @@ import { mapFieldMetadataToGraphQLQuery } from './mapFieldMetadataToGraphQLQuery export const generateFindManyCustomObjectsQuery = ({ objectMetadataItem, - _fromCursor, }: { objectMetadataItem: ObjectMetadataItem; - _fromCursor?: string; }) => { return gql` - query FindMany${objectMetadataItem.namePlural}($filter: ${capitalize( + query FindMany${capitalize( + objectMetadataItem.namePlural, + )}($filter: ${capitalize( objectMetadataItem.nameSingular, )}FilterInput, $orderBy: ${capitalize( objectMetadataItem.nameSingular, - )}OrderByInput) { - ${objectMetadataItem.namePlural}(filter: $filter, orderBy: $orderBy){ + )}OrderByInput, $lastCursor: String) { + ${ + objectMetadataItem.namePlural + }(filter: $filter, orderBy: $orderBy, first: 30, after: $lastCursor){ edges { node { id @@ -29,6 +31,11 @@ export const generateFindManyCustomObjectsQuery = ({ } cursor } + pageInfo { + hasNextPage + startCursor + endCursor + } } } `; diff --git a/front/src/modules/metadata/utils/generateUpdateOneObjectMutation.ts b/front/src/modules/metadata/utils/generateUpdateOneObjectMutation.ts index a2c856ae8..b7a83aa5e 100644 --- a/front/src/modules/metadata/utils/generateUpdateOneObjectMutation.ts +++ b/front/src/modules/metadata/utils/generateUpdateOneObjectMutation.ts @@ -4,6 +4,16 @@ import { capitalize } from '~/utils/string/capitalize'; import { ObjectMetadataItem } from '../types/ObjectMetadataItem'; +import { mapFieldMetadataToGraphQLQuery } from './mapFieldMetadataToGraphQLQuery'; + +export const getUpdateOneObjectMutationGraphQLField = ({ + objectNameSingular, +}: { + objectNameSingular: string; +}) => { + return `update${capitalize(objectNameSingular)}`; +}; + export const generateUpdateOneObjectMutation = ({ objectMetadataItem, }: { @@ -11,10 +21,18 @@ export const generateUpdateOneObjectMutation = ({ }) => { const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); + const graphQLFieldForUpdateOneObjectMutation = + getUpdateOneObjectMutationGraphQLField({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + return gql` mutation UpdateOne${capitalizedObjectName}($idToUpdate: ID!, $input: ${capitalizedObjectName}UpdateInput!) { - update${capitalizedObjectName}(id: $idToUpdate, data: $input) { + ${graphQLFieldForUpdateOneObjectMutation}(id: $idToUpdate, data: $input) { id + ${objectMetadataItem.fields + .map(mapFieldMetadataToGraphQLQuery) + .join('\n')} } } `; diff --git a/front/src/modules/people/table/components/PersonTable.tsx b/front/src/modules/people/table/components/PersonTable.tsx index 7d10f3c67..209fdc2d2 100644 --- a/front/src/modules/people/table/components/PersonTable.tsx +++ b/front/src/modules/people/table/components/PersonTable.tsx @@ -5,8 +5,8 @@ import { getPeopleOptimisticEffectDefinition } from '@/people/graphql/optimistic import { usePersonTableContextMenuEntries } from '@/people/hooks/usePersonTableContextMenuEntries'; import { useSpreadsheetPersonImport } from '@/people/hooks/useSpreadsheetPersonImport'; import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata'; -import { RecordTable } from '@/ui/object/record-table/components/RecordTable'; import { RecordTableEffect } from '@/ui/object/record-table/components/RecordTableEffect'; +import { RecordTableV1 } from '@/ui/object/record-table/components/RecordTableV1'; import { TableOptionsDropdownId } from '@/ui/object/record-table/constants/TableOptionsDropdownId'; import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable'; import { TableOptionsDropdown } from '@/ui/object/record-table/options/components/TableOptionsDropdown'; @@ -119,7 +119,7 @@ export const PersonTable = () => { setContextMenuEntries={setContextMenuEntries} setActionBarEntries={setActionBarEntries} /> - ` - ${({ top }) => top && `padding-top: ${top}px;`} - ${({ bottom }) => bottom && `padding-bottom: ${bottom}px;`} -`; +import { RecordTableRow, StyledRow } from './RecordTableRow'; export const RecordTableBody = () => { - const scrollWrapperRef = useScrollWrapperScopedRef(); + const { ref: lastTableRowRef, inView: lastTableRowIsVisible } = useInView(); const tableRowIds = useRecoilValue(tableRowIdsState); + const { scopeId: objectNamePlural } = useRecordTable(); + + const { foundObjectMetadataItem } = useFindOneObjectMetadataItem({ + objectNamePlural, + }); + + const [isFetchingMoreObjects] = useRecoilState( + isFetchingMoreObjectsFamilyState(foundObjectMetadataItem?.namePlural), + ); + const isFetchingRecordTableData = useRecoilValue( isFetchingRecordTableDataState, ); - const rowVirtualizer = useVirtual({ - size: tableRowIds.length, - parentRef: scrollWrapperRef, - overscan: 50, - }); + const { fetchMoreObjects } = useTableObjects(); - const items = rowVirtualizer.virtualItems; - const paddingTop = items.length > 0 ? items[0].start : 0; - const paddingBottom = - items.length > 0 - ? rowVirtualizer.totalSize - items[items.length - 1].end - : 0; + useEffect(() => { + if (lastTableRowIsVisible && isDefined(fetchMoreObjects)) { + fetchMoreObjects(); + } + }, [lastTableRowIsVisible, fetchMoreObjects]); + + const lastRowId = tableRowIds[tableRowIds.length - 1]; if (isFetchingRecordTableData) { - return null; + return <>; } return ( - {paddingTop > 0 && ( - - - - )} - {items.map((virtualItem) => { - const rowId = tableRowIds[virtualItem.index]; - - return ( - - - - - - ); - })} - {paddingBottom > 0 && ( - - - + {tableRowIds.map((rowId, rowIndex) => ( + + + + + + ))} + {isFetchingMoreObjects && ( + + + Fetching more... + + )} ); diff --git a/front/src/modules/ui/object/record-table/components/RecordTableBodyV1.tsx b/front/src/modules/ui/object/record-table/components/RecordTableBodyV1.tsx new file mode 100644 index 000000000..95d4664cc --- /dev/null +++ b/front/src/modules/ui/object/record-table/components/RecordTableBodyV1.tsx @@ -0,0 +1,32 @@ +import { useRecoilValue } from 'recoil'; + +import { RowIdContext } from '../contexts/RowIdContext'; +import { RowIndexContext } from '../contexts/RowIndexContext'; +import { isFetchingRecordTableDataState } from '../states/isFetchingRecordTableDataState'; +import { tableRowIdsState } from '../states/tableRowIdsState'; + +import { RecordTableRow } from './RecordTableRow'; + +export const RecordTableBodyV1 = () => { + const tableRowIds = useRecoilValue(tableRowIdsState); + + const isFetchingRecordTableData = useRecoilValue( + isFetchingRecordTableDataState, + ); + + if (isFetchingRecordTableData) { + return <>; + } + + return ( + + {tableRowIds.map((rowId, rowIndex) => ( + + + + + + ))} + + ); +}; diff --git a/front/src/modules/ui/object/record-table/components/RecordTableEffect.tsx b/front/src/modules/ui/object/record-table/components/RecordTableEffect.tsx index 03dbd208e..a0f2710fd 100644 --- a/front/src/modules/ui/object/record-table/components/RecordTableEffect.tsx +++ b/front/src/modules/ui/object/record-table/components/RecordTableEffect.tsx @@ -26,8 +26,7 @@ export const RecordTableEffect = ({ }: { useGetRequest: typeof useGetCompaniesQuery | typeof useGetPeopleQuery; getRequestResultKey: string; - getRequestOptimisticEffectDefinition: OptimisticEffectDefinition; - + getRequestOptimisticEffectDefinition: OptimisticEffectDefinition; filterDefinitionArray: FilterDefinition[]; sortDefinitionArray: SortDefinition[]; setActionBarEntries?: () => void; diff --git a/front/src/modules/ui/object/record-table/components/RecordTableRow.tsx b/front/src/modules/ui/object/record-table/components/RecordTableRow.tsx index 907321fea..4eee76bff 100644 --- a/front/src/modules/ui/object/record-table/components/RecordTableRow.tsx +++ b/front/src/modules/ui/object/record-table/components/RecordTableRow.tsx @@ -9,7 +9,7 @@ import { useCurrentRowSelected } from '../record-table-row/hooks/useCurrentRowSe import { CheckboxCell } from './CheckboxCell'; import { RecordTableCell } from './RecordTableCell'; -const StyledRow = styled.tr<{ selected: boolean }>` +export const StyledRow = styled.tr<{ selected: boolean }>` background: ${(props) => props.selected ? props.theme.accent.quaternary : 'none'}; `; diff --git a/front/src/modules/ui/object/record-table/components/RecordTableV1.tsx b/front/src/modules/ui/object/record-table/components/RecordTableV1.tsx new file mode 100644 index 000000000..09cd14dfa --- /dev/null +++ b/front/src/modules/ui/object/record-table/components/RecordTableV1.tsx @@ -0,0 +1,139 @@ +import { useRef } from 'react'; +import styled from '@emotion/styled'; + +import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { + useListenClickOutside, + useListenClickOutsideByClassName, +} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; + +import { EntityUpdateMutationContext } from '../contexts/EntityUpdateMutationHookContext'; +import { useRecordTable } from '../hooks/useRecordTable'; +import { TableHotkeyScope } from '../types/TableHotkeyScope'; + +import { RecordTableBodyV1 } from './RecordTableBodyV1'; +import { RecordTableHeader } from './RecordTableHeader'; + +const StyledTable = styled.table` + border-collapse: collapse; + + border-radius: ${({ theme }) => theme.border.radius.sm}; + border-spacing: 0; + margin-left: ${({ theme }) => theme.table.horizontalCellMargin}; + margin-right: ${({ theme }) => theme.table.horizontalCellMargin}; + table-layout: fixed; + + width: calc(100% - ${({ theme }) => theme.table.horizontalCellMargin} * 2); + + th { + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-collapse: collapse; + color: ${({ theme }) => theme.font.color.tertiary}; + padding: 0; + text-align: left; + + :last-child { + border-right-color: transparent; + } + :first-of-type { + border-left-color: transparent; + border-right-color: transparent; + } + :last-of-type { + width: 100%; + } + } + + td { + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-collapse: collapse; + color: ${({ theme }) => theme.font.color.primary}; + padding: 0; + + text-align: left; + + :last-child { + border-right-color: transparent; + } + :first-of-type { + border-left-color: transparent; + border-right-color: transparent; + } + } +`; + +const StyledTableWithHeader = styled.div` + display: flex; + flex: 1; + flex-direction: column; + width: 100%; +`; + +const StyledTableContainer = styled.div` + display: flex; + flex-direction: column; + height: 100%; + overflow: auto; + position: relative; +`; + +type RecordTableV1Props = { + updateEntityMutation: (params: any) => void; +}; + +export const RecordTableV1 = ({ updateEntityMutation }: RecordTableV1Props) => { + const tableBodyRef = useRef(null); + + const { + leaveTableFocus, + setRowSelectedState, + resetTableRowSelection, + useMapKeyboardToSoftFocus, + } = useRecordTable(); + + useMapKeyboardToSoftFocus(); + + useListenClickOutside({ + refs: [tableBodyRef], + callback: () => { + leaveTableFocus(); + }, + }); + + useScopedHotkeys( + 'escape', + () => { + resetTableRowSelection(); + }, + TableHotkeyScope.Table, + ); + + useListenClickOutsideByClassName({ + classNames: ['entity-table-cell'], + excludeClassNames: ['action-bar', 'context-menu'], + callback: () => { + resetTableRowSelection(); + }, + }); + + return ( + + + +
+ + + + + +
+
+
+
+ ); +}; diff --git a/front/yarn.lock b/front/yarn.lock index 4dc00f8b9..2ec2c71b9 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -16416,6 +16416,11 @@ react-inspector@^6.0.0: resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-6.0.2.tgz#aa3028803550cb6dbd7344816d5c80bf39d07e9d" integrity sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ== +react-intersection-observer@^9.5.2: + version "9.5.2" + resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-9.5.2.tgz#f68363a1ff292323c0808201b58134307a1626d0" + integrity sha512-EmoV66/yvksJcGa1rdW0nDNc4I1RifDWkT50gXSFnPLYQ4xUptuDD4V7k+Rj1OgVAlww628KLGcxPXFlOkkU/Q== + react-is@18.1.0: version "18.1.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.1.0.tgz#61aaed3096d30eacf2a2127118b5b41387d32a67"