Fix optimistic rendering issues on board and table (#2846)

* Fix optimistic rendering issues on board and table

* Remove dead code

* Improve re-renders of Table

* Remove re-renders on board
This commit is contained in:
Charles Bochet
2023-12-05 22:29:27 +01:00
committed by GitHub
parent 976e058328
commit 69f48ea330
28 changed files with 606 additions and 465 deletions

View File

@ -3,6 +3,7 @@ import styled from '@emotion/styled';
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordTable } from '@/ui/object/record-table/components/RecordTable';
import { TableOptionsDropdownId } from '@/ui/object/record-table/constants/TableOptionsDropdownId';
import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable';
@ -12,8 +13,6 @@ import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToC
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { useUpdateOneRecord } from '../hooks/useUpdateOneRecord';
import { RecordTableEffect } from './RecordTableEffect';
const StyledContainer = styled.div`

View File

@ -6,6 +6,7 @@ import { isNonEmptyString } from '@sniptt/guards';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { IconBuildingSkyscraper } from '@/ui/display/icon';
import { PageAddButton } from '@/ui/layout/page/PageAddButton';
import { PageBody } from '@/ui/layout/page/PageBody';
@ -15,8 +16,6 @@ import { PageHotkeysEffect } from '@/ui/layout/page/PageHotkeysEffect';
import { RecordTableActionBar } from '@/ui/object/record-table/action-bar/components/RecordTableActionBar';
import { RecordTableContextMenu } from '@/ui/object/record-table/context-menu/components/RecordTableContextMenu';
import { useCreateOneRecord } from '../hooks/useCreateOneRecord';
import { RecordTableContainer } from './RecordTableContainer';
const StyledTableContainer = styled.div`

View File

@ -16,26 +16,49 @@ export const getRecordOptimisticEffectDefinition = ({
resolver: ({
currentData,
newData,
deletedRecordIds,
}: {
currentData: unknown;
newData: unknown;
newData: { id: string } & Record<string, any>;
deletedRecordIds?: string[];
}) => {
const newRecordPaginatedCacheField = produce<
PaginatedRecordTypeResults<any>
>(currentData as PaginatedRecordTypeResults<any>, (draft) => {
if (!draft) {
return {
edges: [{ node: newData, cursor: '' }],
pageInfo: {
endCursor: '',
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
},
};
if (newData) {
if (!draft) {
return {
__typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
edges: [{ node: newData, cursor: '' }],
pageInfo: {
endCursor: '',
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
},
};
}
const existingRecord = draft.edges.find(
(edge) => edge.node.id === newData.id,
);
if (existingRecord) {
existingRecord.node = newData;
return;
}
draft.edges.unshift({
node: newData,
cursor: '',
__typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
});
}
draft.edges.unshift({ node: newData, cursor: '' });
if (deletedRecordIds) {
draft.edges = draft.edges.filter(
(edge) => !deletedRecordIds.includes(edge.node.id),
);
}
});
return newRecordPaginatedCacheField;

View File

@ -1,9 +1,10 @@
import { useMutation } from '@apollo/client';
import { useApolloClient } from '@apollo/client';
import { v4 } from 'uuid';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useGenerateEmptyRecord } from '@/object-record/hooks/useGenerateEmptyRecord';
import { capitalize } from '~/utils/string/capitalize';
export const useCreateOneRecord = <T>({
@ -20,21 +21,42 @@ export const useCreateOneRecord = <T>({
);
// TODO: type this with a minimal type at least with Record<string, any>
const [mutate] = useMutation(createOneRecordMutation);
const apolloClient = useApolloClient();
const { generateEmptyRecord } = useGenerateEmptyRecord({
objectMetadataItem,
});
const createOneRecord = async (input: Record<string, any>) => {
const createdObject = await mutate({
const recordId = v4();
triggerOptimisticEffects(
`${capitalize(objectMetadataItem.nameSingular)}Edge`,
generateEmptyRecord(recordId),
);
const createdObject = await apolloClient.mutate({
mutation: createOneRecordMutation,
variables: {
input: { ...input, id: v4() },
input: { ...input, id: recordId },
},
optimisticResponse: {
[`create${capitalize(objectMetadataItem.nameSingular)}`]:
generateEmptyRecord(recordId),
},
});
if (!createdObject.data) {
return null;
}
triggerOptimisticEffects(
`${capitalize(objectMetadataItem.nameSingular)}Edge`,
createdObject.data[
`create${capitalize(objectMetadataItem.nameSingular)}`
],
);
return createdObject.data[
`create${capitalize(objectMetadataItem.nameSingular)}`
] as T;

View File

@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { useMutation } from '@apollo/client';
import { useApolloClient } from '@apollo/client';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { useOptimisticEvict } from '@/apollo/optimistic-effect/hooks/useOptimisticEvict';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
@ -10,6 +11,9 @@ export const useDeleteOneRecord = <T>({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const { performOptimisticEvict } = useOptimisticEvict();
const { triggerOptimisticEffects } = useOptimisticEffect({
objectNameSingular,
});
const { objectMetadataItem, deleteOneRecordMutation } = useObjectMetadataItem(
{
@ -17,16 +21,15 @@ export const useDeleteOneRecord = <T>({
},
);
// TODO: type this with a minimal type at least with Record<string, any>
const [mutate] = useMutation(deleteOneRecordMutation);
const apolloClient = useApolloClient();
const deleteOneRecord = useCallback(
async (idToDelete: string) => {
const deletedRecord = await mutate({
variables: {
idToDelete,
},
});
triggerOptimisticEffects(
`${capitalize(objectMetadataItem.nameSingular)}Edge`,
undefined,
[idToDelete],
);
performOptimisticEvict(
capitalize(objectMetadataItem.nameSingular),
@ -34,11 +37,24 @@ export const useDeleteOneRecord = <T>({
idToDelete,
);
const deletedRecord = await apolloClient.mutate({
mutation: deleteOneRecordMutation,
variables: {
idToDelete,
},
});
return deletedRecord.data[
`create${capitalize(objectMetadataItem.nameSingular)}`
] as T;
},
[performOptimisticEvict, objectMetadataItem, mutate],
[
triggerOptimisticEffects,
objectMetadataItem.nameSingular,
performOptimisticEvict,
apolloClient,
deleteOneRecordMutation,
],
);
return {

View File

@ -214,7 +214,7 @@ export const useFindManyRecords = <
return {
objectMetadataItem,
records,
records: records as RecordType[],
loading,
error,
fetchMoreRecords,

View File

@ -0,0 +1,177 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const useGenerateEmptyRecord = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const generateEmptyRecord = (id: string) => {
if (objectMetadataItem.nameSingular === 'company') {
return {
id,
domainName: '',
accountOwnerId: null,
createdAt: '2023-12-05T16:04:42.261Z',
address: '',
people: [
{
edges: [],
__typename: 'PersonConnection',
},
],
xLink: {
label: '',
url: '',
__typename: 'Link',
},
attachments: {
edges: [],
__typename: 'AttachmentConnection',
},
activityTargets: {
edges: [],
__typename: 'ActivityTargetConnection',
},
idealCustomerProfile: null,
annualRecurringRevenue: {
amountMicros: null,
currencyCode: null,
__typename: 'Currency',
},
updatedAt: '2023-12-05T16:04:42.261Z',
employees: null,
accountOwner: null,
name: '',
linkedinLink: {
label: '',
url: '',
__typename: 'Link',
},
favorites: {
edges: [],
__typename: 'FavoriteConnection',
},
opportunities: {
edges: [],
__typename: 'OpportunityConnection',
},
__typename: 'Company',
};
}
if (objectMetadataItem.nameSingular === 'person') {
return {
id,
activityTargets: {
edges: [],
__typename: 'ActivityTargetConnection',
},
opportunities: {
edges: [],
__typename: 'OpportunityConnection',
},
companyId: null,
favorites: {
edges: [],
__typename: 'FavoriteConnection',
},
phone: '',
company: null,
xLink: {
label: '',
url: '',
__typename: 'Link',
},
jobTitle: '',
pointOfContactForOpportunities: {
edges: [],
__typename: 'OpportunityConnection',
},
email: '',
attachments: {
edges: [],
__typename: 'AttachmentConnection',
},
name: {
firstName: '',
lastName: '',
__typename: 'FullName',
},
avatarUrl: '',
updatedAt: '2023-12-05T16:45:11.840Z',
createdAt: '2023-12-05T16:45:11.840Z',
city: '',
linkedinLink: {
label: '',
url: '',
__typename: 'Link',
},
__typename: 'Person',
};
}
if (objectMetadataItem.nameSingular === 'opportunity') {
return {
id,
pipelineStepId: '30b14887-d592-427d-bd97-6e670158db02',
closeDate: null,
companyId: '04b2e9f5-0713-40a5-8216-82802401d33e',
updatedAt: '2023-12-05T16:46:27.621Z',
pipelineStep: {
id: '30b14887-d592-427d-bd97-6e670158db02',
position: 2,
name: 'Meeting',
updatedAt: '2023-12-05T11:29:21.485Z',
createdAt: '2023-12-05T11:29:21.485Z',
color: 'sky',
__typename: 'PipelineStep',
},
probability: '0',
pointOfContactId: null,
personId: null,
amount: {
amountMicros: null,
currencyCode: null,
__typename: 'Currency',
},
createdAt: '2023-12-05T16:46:27.621Z',
pointOfContact: null,
person: null,
company: {
id: '04b2e9f5-0713-40a5-8216-82802401d33e',
domainName: 'qonto.com',
accountOwnerId: null,
createdAt: '2023-12-05T11:29:21.484Z',
address: '',
xLink: {
label: '',
url: '',
__typename: 'Link',
},
idealCustomerProfile: null,
annualRecurringRevenue: {
amountMicros: null,
currencyCode: null,
__typename: 'Currency',
},
updatedAt: '2023-12-05T11:29:21.484Z',
employees: null,
name: 'Qonto',
linkedinLink: {
label: '',
url: '',
__typename: 'Link',
},
__typename: 'Company',
},
__typename: 'Opportunity',
};
}
return {};
};
return {
generateEmptyRecord: generateEmptyRecord,
};
};

View File

@ -0,0 +1,104 @@
import { useCallback } from 'react';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { Company } from '@/companies/types/Company';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults';
import { Opportunity } from '@/pipeline/types/Opportunity';
import { PipelineStep } from '@/pipeline/types/PipelineStep';
import { turnFiltersIntoWhereClause } from '@/ui/object/object-filter-dropdown/utils/turnFiltersIntoWhereClause';
import { turnSortsIntoOrderBy } from '@/ui/object/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { useRecordBoardScopedStates } from '@/ui/object/record-board/hooks/internal/useRecordBoardScopedStates';
import { useFindManyRecords } from './useFindManyRecords';
export const useObjectRecordBoard = () => {
const objectNameSingular = 'opportunity';
const { objectMetadataItem: foundObjectMetadataItem } = useObjectMetadataItem(
{
objectNameSingular,
},
);
const {
isBoardLoadedState,
boardFiltersState,
boardSortsState,
savedCompaniesState,
savedOpportunitiesState,
savedPipelineStepsState,
} = useRecordBoardScopedStates();
const setIsBoardLoaded = useSetRecoilState(isBoardLoadedState);
const boardFilters = useRecoilValue(boardFiltersState);
const boardSorts = useRecoilValue(boardSortsState);
const setSavedCompanies = useSetRecoilState(savedCompaniesState);
const [savedOpportunities] = useRecoilState(savedOpportunitiesState);
const [savedPipelineSteps, setSavedPipelineSteps] = useRecoilState(
savedPipelineStepsState,
);
const filter = turnFiltersIntoWhereClause(
boardFilters,
foundObjectMetadataItem?.fields ?? [],
);
const orderBy = turnSortsIntoOrderBy(
boardSorts,
foundObjectMetadataItem?.fields ?? [],
);
useFindManyRecords({
objectNameSingular: 'pipelineStep',
filter: {},
onCompleted: useCallback(
(data: PaginatedRecordTypeResults<PipelineStep>) => {
setSavedPipelineSteps(data.edges.map((edge) => edge.node));
},
[setSavedPipelineSteps],
),
});
const {
records: opportunities,
loading,
fetchMoreRecords: fetchMoreOpportunities,
} = useFindManyRecords<Opportunity>({
skip: !savedPipelineSteps.length,
objectNameSingular: 'opportunity',
filter: filter,
orderBy: orderBy,
onCompleted: useCallback(() => {
setIsBoardLoaded(true);
}, [setIsBoardLoaded]),
});
const { fetchMoreRecords: fetchMoreCompanies } = useFindManyRecords({
skip: !savedOpportunities.length,
objectNameSingular: 'company',
filter: {
id: {
in: savedOpportunities.map(
(opportunity) => opportunity.companyId || '',
),
},
},
onCompleted: useCallback(
(data: PaginatedRecordTypeResults<Company>) => {
setSavedCompanies(data.edges.map((edge) => edge.node));
},
[setSavedCompanies],
),
});
return {
opportunities,
loading,
fetchMoreOpportunities,
fetchMoreCompanies,
};
};

View File

@ -9,13 +9,11 @@ import { PipelineStep } from '@/pipeline/types/PipelineStep';
import { turnFiltersIntoWhereClause } from '@/ui/object/object-filter-dropdown/utils/turnFiltersIntoWhereClause';
import { turnSortsIntoOrderBy } from '@/ui/object/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { useRecordBoardScopedStates } from '@/ui/object/record-board/hooks/internal/useRecordBoardScopedStates';
import { useUpdateCompanyBoardCardIdsInternal } from '@/ui/object/record-board/hooks/internal/useUpdateCompanyBoardCardIdsInternal';
import { useFindManyRecords } from './useFindManyRecords';
export const useObjectRecordBoard = () => {
const objectNameSingular = 'opportunity';
const updateCompanyBoardCardIds = useUpdateCompanyBoardCardIdsInternal();
const { objectMetadataItem: foundObjectMetadataItem } = useObjectMetadataItem(
{
@ -71,24 +69,14 @@ export const useObjectRecordBoard = () => {
records: opportunities,
loading,
fetchMoreRecords: fetchMoreOpportunities,
} = useFindManyRecords({
} = useFindManyRecords<Opportunity>({
skip: !savedPipelineSteps.length,
objectNameSingular: 'opportunity',
filter: filter,
orderBy: orderBy,
onCompleted: useCallback(
(data: PaginatedRecordTypeResults<Opportunity>) => {
const pipelineProgresses: Array<Opportunity> = data.edges.map(
(edge) => edge.node,
);
updateCompanyBoardCardIds(pipelineProgresses);
setSavedOpportunities(pipelineProgresses);
setIsBoardLoaded(true);
},
[setIsBoardLoaded, setSavedOpportunities, updateCompanyBoardCardIds],
),
onCompleted: useCallback(() => {
setIsBoardLoaded(true);
}, [setIsBoardLoaded]),
});
const { fetchMoreRecords: fetchMoreCompanies } = useFindManyRecords({

View File

@ -1,6 +1,5 @@
import { useRecoilValue } from 'recoil';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { turnFiltersIntoWhereClause } from '@/ui/object/object-filter-dropdown/utils/turnFiltersIntoWhereClause';
@ -8,8 +7,6 @@ import { turnSortsIntoOrderBy } from '@/ui/object/object-sort-dropdown/utils/tur
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 { useFindManyRecords } from './useFindManyRecords';
export const useObjectRecordTable = () => {
@ -25,10 +22,6 @@ export const useObjectRecordTable = () => {
},
);
const { registerOptimisticEffect } = useOptimisticEffect({
objectNameSingular,
});
const { tableFiltersState, tableSortsState } = useRecordTableScopedStates();
const tableFilters = useRecoilValue(tableFiltersState);
@ -47,25 +40,12 @@ export const useObjectRecordTable = () => {
objectNameSingular,
filter,
orderBy,
onCompleted: (data) => {
const entities = data.edges.map((edge) => edge.node) ?? [];
setRecordTableData(entities);
if (foundObjectMetadataItem) {
registerOptimisticEffect({
variables: { orderBy, filter, limit: 60 },
definition: getRecordOptimisticEffectDefinition({
objectMetadataItem: foundObjectMetadataItem,
}),
});
}
},
});
return {
records,
loading,
fetchMoreRecords,
setRecordTableData,
};
};

View File

@ -12,7 +12,6 @@ import { ContextMenuEntry } from '@/ui/navigation/context-menu/types/ContextMenu
import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable';
import { RecordTableScopeInternalContext } from '@/ui/object/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
import { selectedRowIdsSelector } from '@/ui/object/record-table/states/selectors/selectedRowIdsSelector';
import { tableRowIdsState } from '@/ui/object/record-table/states/tableRowIdsState';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
type useRecordTableContextMenuEntriesProps = {
@ -31,7 +30,6 @@ export const useRecordTableContextMenuEntries = (
const setContextMenuEntries = useSetRecoilState(contextMenuEntriesState);
const setActionBarEntriesState = useSetRecoilState(actionBarEntriesState);
const setTableRowIds = useSetRecoilState(tableRowIdsState);
const selectedRowIds = useRecoilValue(selectedRowIdsSelector);
const { scopeId: objectNamePlural, resetTableRowSelection } = useRecordTable({
@ -76,16 +74,11 @@ export const useRecordTableContextMenuEntries = (
.getValue();
resetTableRowSelection();
if (deleteOneRecord) {
for (const rowId of rowIdsToDelete) {
await Promise.all(
rowIdsToDelete.map(async (rowId) => {
await deleteOneRecord(rowId);
}
setTableRowIds((tableRowIds) =>
tableRowIds.filter((id) => !rowIdsToDelete.includes(id)),
);
}
}),
);
});
return {

View File

@ -1,46 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { entityFieldsFamilyState } from '@/ui/object/field/states/entityFieldsFamilyState';
import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable';
import { isFetchingRecordTableDataState } from '@/ui/object/record-table/states/isFetchingRecordTableDataState';
import { numberOfTableRowsState } from '@/ui/object/record-table/states/numberOfTableRowsState';
import { tableRowIdsState } from '@/ui/object/record-table/states/tableRowIdsState';
import { useViewBar } from '@/views/hooks/useViewBar';
export const useSetRecordTableData = () => {
const { resetTableRowSelection } = useRecordTable();
const { setEntityCountInCurrentView } = useViewBar();
return useRecoilCallback(
({ set, snapshot }) =>
<T extends { id: string } & Record<string, any>>(newEntityArray: T[]) => {
for (const entity of newEntityArray) {
const currentEntity = snapshot
.getLoadable(entityFieldsFamilyState(entity.id))
.valueOrThrow();
if (JSON.stringify(currentEntity) !== JSON.stringify(entity)) {
set(entityFieldsFamilyState(entity.id), entity);
}
}
const entityIds = newEntityArray.map((entity) => entity.id);
set(tableRowIdsState, (currentRowIds) => {
if (JSON.stringify(currentRowIds) !== JSON.stringify(entityIds)) {
return entityIds;
}
return currentRowIds;
});
resetTableRowSelection();
set(numberOfTableRowsState, entityIds.length);
setEntityCountInCurrentView(entityIds.length);
set(isFetchingRecordTableDataState, false);
},
[resetTableRowSelection, setEntityCountInCurrentView],
);
};

View File

@ -1,5 +1,4 @@
import { useMutation } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import { useApolloClient } from '@apollo/client';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
@ -17,7 +16,7 @@ export const useUpdateOneRecord = <T>({
objectNameSingular,
});
const [mutateUpdateOneRecord] = useMutation(updateOneRecordMutation);
const apolloClient = useApolloClient();
const updateOneRecord = async ({
idToUpdate,
@ -30,7 +29,8 @@ export const useUpdateOneRecord = <T>({
}) => {
const cachedRecord = getRecordFromCache(idToUpdate);
const updatedRecord = await mutateUpdateOneRecord({
const updatedRecord = await apolloClient.mutate({
mutation: updateOneRecordMutation,
variables: {
idToUpdate: idToUpdate,
input: {
@ -43,12 +43,12 @@ export const useUpdateOneRecord = <T>({
...input,
},
},
refetchQueries: forceRefetch
? [getOperationName(findManyRecordsQuery) ?? '']
: undefined,
awaitRefetchQueries: forceRefetch,
});
if (!updatedRecord?.data) {
return null;
}
return updatedRecord.data[
`update${capitalize(objectMetadataItem.nameSingular)}`
] as T;

View File

@ -3,6 +3,7 @@ export type PaginatedRecordTypeEdge<
> = {
node: RecordType;
cursor: string;
__typename?: string;
};
export type PaginatedRecordTypeResults<