fix: fix Relation field optimistic effect on Record update (#3352)

* fix: fix Relation field optimistic effect on Record update

Related to #3099

* Fix lint

* Fix

* fix

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thaïs
2024-01-13 08:35:30 -03:00
committed by GitHub
parent a8efc17fff
commit 95326b2828
8 changed files with 269 additions and 249 deletions

View File

@ -5,7 +5,12 @@ module.exports = {
}, },
overrides: [ overrides: [
{ {
files: ['.storybook/**/*', '**/*.stories.tsx', '**/*.test.ts'], files: [
'.storybook/**/*',
'**/*.stories.tsx',
'**/*.test.ts',
'**/*.test.tsx',
],
rules: { rules: {
'no-console': 'off', 'no-console': 'off',
}, },

View File

@ -1,35 +1,29 @@
const globalCoverage = { const globalCoverage = {
"statements": 60, statements: 60,
"lines": 60, lines: 60,
"functions": 60, functions: 60,
"exclude": [ exclude: ['src/generated/**/*'],
"src/generated/**/*",
]
}; };
const modulesCoverage = { const modulesCoverage = {
"statements": 50, statements: 50,
"lines": 50, lines: 50,
"functions": 45, functions: 45,
"include": [ include: ['src/modules/**/*'],
"src/modules/**/*",
]
}; };
const pagesCoverage = { const pagesCoverage = {
"statements": 50, statements: 50,
"lines": 50, lines: 50,
"functions": 45, functions: 45,
"exclude": [ exclude: ['src/generated/**/*', 'src/modules/**/*'],
"src/generated/**/*",
"src/modules/**/*",
]
}; };
const storybookStoriesFolders = process.env.STORYBOOK_SCOPE; const storybookStoriesFolders = process.env.STORYBOOK_SCOPE;
module.exports = storybookStoriesFolders === 'pages' module.exports =
? pagesCoverage storybookStoriesFolders === 'pages'
: storybookStoriesFolders === 'modules' ? pagesCoverage
? modulesCoverage : storybookStoriesFolders === 'modules'
: globalCoverage; ? modulesCoverage
: globalCoverage;

View File

@ -146,8 +146,8 @@ export const useOptimisticEffect = ({
({ snapshot }) => ({ snapshot }) =>
({ ({
typename, typename,
createdRecords, createdRecords = [],
updatedRecords, updatedRecords = [],
deletedRecordIds, deletedRecordIds,
}: { }: {
typename: string; typename: string;
@ -162,17 +162,17 @@ export const useOptimisticEffect = ({
for (const optimisticEffect of Object.values(optimisticEffects)) { for (const optimisticEffect of Object.values(optimisticEffects)) {
// We need to update the typename when createObject type differs from listObject types // 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 // It is the case for apiKey, where the creation route returns an ApiKeyToken type
const formattedCreatedRecords = isNonEmptyArray(createdRecords) const formattedCreatedRecords = createdRecords.map((createdRecord) =>
? createdRecords.map((data: any) => { typename.endsWith('Edge')
return { ...data, __typename: typename }; ? createdRecord
}) : { ...createdRecord, __typename: typename },
: []; );
const formattedUpdatedRecords = isNonEmptyArray(updatedRecords) const formattedUpdatedRecords = updatedRecords.map((updatedRecord) =>
? updatedRecords.map((data: any) => { typename.endsWith('Edge')
return { ...data, __typename: typename }; ? updatedRecord
}) : { ...updatedRecord, __typename: typename },
: []; );
if (optimisticEffect.typename === typename) { if (optimisticEffect.typename === typename) {
optimisticEffect.writer({ optimisticEffect.writer({

View File

@ -29,8 +29,13 @@ export const getRecordOptimisticEffectDefinition = ({
if (isNonEmptyArray(createdRecords)) { if (isNonEmptyArray(createdRecords)) {
if (existingDataIsEmpty) { if (existingDataIsEmpty) {
return { return {
__typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, __typename: `${capitalize(
objectMetadataItem.nameSingular,
)}Connection`,
edges: createdRecords.map((createdRecord) => ({ edges: createdRecords.map((createdRecord) => ({
__typename: `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`,
node: createdRecord, node: createdRecord,
cursor: '', cursor: '',
})), })),

View File

@ -1,8 +1,10 @@
import { useApolloClient } from '@apollo/client'; import { Reference, useApolloClient } from '@apollo/client';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
import { capitalize } from '~/utils/string/capitalize'; import { capitalize } from '~/utils/string/capitalize';
type useUpdateOneRecordProps = { type useUpdateOneRecordProps = {
@ -59,6 +61,54 @@ export const useUpdateOneRecord = <T>({
[`update${capitalize(objectMetadataItem.nameSingular)}`]: [`update${capitalize(objectMetadataItem.nameSingular)}`]:
optimisticallyUpdatedRecord, optimisticallyUpdatedRecord,
}, },
update: (cache, { data }) => {
const response =
data?.[`update${capitalize(objectMetadataItem.nameSingular)}`];
if (!response) return;
cache.modify<Record<string, Reference>>({
fields: {
[objectMetadataItem.namePlural]: (
existingConnectionRef,
{ readField, storeFieldName },
) => {
if (
readField('__typename', existingConnectionRef) !==
`${capitalize(objectMetadataItem.nameSingular)}Connection`
)
return existingConnectionRef;
const { variables } = parseApolloStoreFieldName(storeFieldName);
const edges = readField<{ node: Reference }[]>(
'edges',
existingConnectionRef,
);
if (
variables?.filter &&
!isRecordMatchingFilter({
record: response,
filter: variables.filter,
objectMetadataItem,
}) &&
edges?.length
) {
return {
...existingConnectionRef,
edges: edges.filter(
(edge) =>
readField('id', readField('node', edge)) !== response.id,
),
};
}
return existingConnectionRef;
},
},
});
},
}); });
if (!updatedRecord?.data) { if (!updatedRecord?.data) {

View File

@ -261,7 +261,6 @@ describe('useFilterDropdown', () => {
}); });
it('should handle scopeId undefined on initial values', () => { it('should handle scopeId undefined on initial values', () => {
// eslint-disable-next-line no-console
console.error = jest.fn(); console.error = jest.fn();
const renderFunction = () => { const renderFunction = () => {

View File

@ -7,7 +7,6 @@ import {
DateFilter, DateFilter,
FloatFilter, FloatFilter,
FullNameFilter, FullNameFilter,
LeafObjectRecordFilter,
NotObjectRecordFilter, NotObjectRecordFilter,
ObjectRecordQueryFilter, ObjectRecordQueryFilter,
OrObjectRecordFilter, OrObjectRecordFilter,
@ -24,6 +23,18 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { isEmptyObject } from '~/utils/isEmptyObject'; import { isEmptyObject } from '~/utils/isEmptyObject';
const isAndFilter = (
filter: ObjectRecordQueryFilter,
): filter is AndObjectRecordFilter => 'and' in filter && !!filter.and;
const isOrFilter = (
filter: ObjectRecordQueryFilter,
): filter is OrObjectRecordFilter => 'or' in filter && !!filter.or;
const isNotFilter = (
filter: ObjectRecordQueryFilter,
): filter is NotObjectRecordFilter => 'not' in filter && !!filter.not;
export const isRecordMatchingFilter = ({ export const isRecordMatchingFilter = ({
record, record,
filter, filter,
@ -32,238 +43,173 @@ export const isRecordMatchingFilter = ({
record: any; record: any;
filter: ObjectRecordQueryFilter; filter: ObjectRecordQueryFilter;
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
}) => { }): boolean => {
if (Object.keys(filter).length === 0) { if (Object.keys(filter).length === 0) {
return true; return true;
} }
const currentLevelFilterMatches: boolean[] = []; if (isAndFilter(filter)) {
const filterValue = filter.and;
// We consider all the keys at the same level as an "and" if (!Array.isArray(filterValue)) {
for (const filterKey in filter) { throw new Error(
if (filterKey === 'and') { 'Unexpected value for "and" filter : ' + JSON.stringify(filterValue),
const filterValue = (filter as AndObjectRecordFilter).and; );
}
if (!Array.isArray(filterValue)) { return (
throw new Error( filterValue.length === 0 ||
'Unexpected value for "and" filter : ' + JSON.stringify(filterValue), filterValue.every((andFilter) =>
);
}
if (filterValue.length === 0) {
currentLevelFilterMatches.push(true);
continue;
}
const recordIsMatchingAndFilters = filterValue.every((andFilter) =>
isRecordMatchingFilter({ isRecordMatchingFilter({
record, record,
filter: andFilter, filter: andFilter,
objectMetadataItem, objectMetadataItem,
}), }),
); )
);
}
currentLevelFilterMatches.push(recordIsMatchingAndFilters); if (isOrFilter(filter)) {
} else if (filterKey === 'or') { const filterValue = filter.or;
const filterValue = (filter as OrObjectRecordFilter).or;
if (Array.isArray(filterValue)) { if (Array.isArray(filterValue)) {
if (filterValue.length === 0) { return (
currentLevelFilterMatches.push(true); filterValue.length === 0 ||
continue; filterValue.some((orFilter) =>
}
const recordIsMatchingOrFilters = filterValue.some((orFilter) =>
isRecordMatchingFilter({ isRecordMatchingFilter({
record, record,
filter: orFilter, filter: orFilter,
objectMetadataItem, objectMetadataItem,
}), }),
); )
);
}
currentLevelFilterMatches.push(recordIsMatchingOrFilters); if (isObject(filterValue)) {
} else if (isObject(filterValue)) { // The API considers "or" with an object as an "and"
// The API considers "or" with an object as an "and" return isRecordMatchingFilter({
const recordIsMatchingOrFilters = isRecordMatchingFilter({
record,
filter: filterValue,
objectMetadataItem,
});
currentLevelFilterMatches.push(recordIsMatchingOrFilters);
} else {
throw new Error('Unexpected value for "or" filter : ' + filterValue);
}
} else if (filterKey === 'not') {
const filterValue = (filter as NotObjectRecordFilter).not;
if (!isDefined(filterValue)) {
throw new Error('Unexpected value for "not" filter : ' + filterValue);
}
if (isEmptyObject(filterValue)) {
currentLevelFilterMatches.push(true);
continue;
}
const recordIsMatchingNotFilters = !isRecordMatchingFilter({
record, record,
filter: filterValue, filter: filterValue,
objectMetadataItem, objectMetadataItem,
}); });
currentLevelFilterMatches.push(recordIsMatchingNotFilters);
} else {
const filterValue = (filter as LeafObjectRecordFilter)[filterKey];
if (!isDefined(filterValue)) {
throw new Error(
'Unexpected value for filter key "' +
filterKey +
'" : ' +
filterValue,
);
}
if (isEmptyObject(filterValue)) {
currentLevelFilterMatches.push(true);
continue;
}
const objectMetadataField = objectMetadataItem.fields.find(
(field) => field.name === filterKey,
);
if (!isDefined(objectMetadataField)) {
throw new Error(
'Field metadata item "' +
filterKey +
'" not found for object metadata item ' +
objectMetadataItem.nameSingular,
);
}
switch (objectMetadataField.type) {
case FieldMetadataType.Email:
case FieldMetadataType.Phone:
case FieldMetadataType.Text: {
const stringFilter = filterValue as StringFilter;
currentLevelFilterMatches.push(
isMatchingStringFilter({
stringFilter,
value: record[filterKey],
}),
);
break;
}
case FieldMetadataType.Link: {
const urlFilter = filterValue as URLFilter;
if (urlFilter.url !== undefined) {
currentLevelFilterMatches.push(
isMatchingStringFilter({
stringFilter: urlFilter.url,
value: record[filterKey].url,
}),
);
}
if (urlFilter.label !== undefined) {
currentLevelFilterMatches.push(
isMatchingStringFilter({
stringFilter: urlFilter.label,
value: record[filterKey].label,
}),
);
}
break;
}
case FieldMetadataType.FullName: {
const fullNameFilter = filterValue as FullNameFilter;
if (fullNameFilter.firstName !== undefined) {
currentLevelFilterMatches.push(
isMatchingStringFilter({
stringFilter: fullNameFilter.firstName,
value: record[filterKey].firstName,
}),
);
}
if (fullNameFilter.lastName !== undefined) {
currentLevelFilterMatches.push(
isMatchingStringFilter({
stringFilter: fullNameFilter.lastName,
value: record[filterKey].lastName,
}),
);
}
break;
}
case FieldMetadataType.DateTime: {
const dateFilter = filterValue as DateFilter;
currentLevelFilterMatches.push(
isMatchingDateFilter({
dateFilter,
value: record[filterKey],
}),
);
break;
}
case FieldMetadataType.Number:
case FieldMetadataType.Numeric: {
const numberFilter = filterValue as FloatFilter;
currentLevelFilterMatches.push(
isMatchingFloatFilter({
floatFilter: numberFilter,
value: record[filterKey],
}),
);
break;
}
case FieldMetadataType.Uuid: {
const uuidFilter = filterValue as UUIDFilter;
currentLevelFilterMatches.push(
isMatchingUUIDFilter({
uuidFilter,
value: record[filterKey],
}),
);
break;
}
case FieldMetadataType.Boolean: {
const booleanFilter = filterValue as BooleanFilter;
currentLevelFilterMatches.push(
isMatchingBooleanFilter({
booleanFilter,
value: record[filterKey],
}),
);
break;
}
case FieldMetadataType.Relation: {
throw new Error(
`Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`,
);
}
case FieldMetadataType.Currency:
case FieldMetadataType.MultiSelect:
case FieldMetadataType.Select:
case FieldMetadataType.Probability:
case FieldMetadataType.Rating: {
throw new Error('Not implemented yet');
}
}
} }
throw new Error('Unexpected value for "or" filter : ' + filterValue);
} }
return currentLevelFilterMatches.length > 0 if (isNotFilter(filter)) {
? currentLevelFilterMatches.every((match) => !!match) const filterValue = filter.not;
: false;
if (!isDefined(filterValue)) {
throw new Error('Unexpected value for "not" filter : ' + filterValue);
}
return (
isEmptyObject(filterValue) ||
!isRecordMatchingFilter({
record,
filter: filterValue,
objectMetadataItem,
})
);
}
return Object.entries(filter).every(([filterKey, filterValue]) => {
if (!isDefined(filterValue)) {
throw new Error(
'Unexpected value for filter key "' + filterKey + '" : ' + filterValue,
);
}
if (isEmptyObject(filterValue)) return true;
const objectMetadataField = objectMetadataItem.fields.find(
(field) => field.name === filterKey,
);
if (!isDefined(objectMetadataField)) {
throw new Error(
'Field metadata item "' +
filterKey +
'" not found for object metadata item ' +
objectMetadataItem.nameSingular,
);
}
switch (objectMetadataField.type) {
case FieldMetadataType.Email:
case FieldMetadataType.Phone:
case FieldMetadataType.Text: {
return isMatchingStringFilter({
stringFilter: filterValue as StringFilter,
value: record[filterKey],
});
}
case FieldMetadataType.Link: {
const urlFilter = filterValue as URLFilter;
return (
(urlFilter.url === undefined ||
isMatchingStringFilter({
stringFilter: urlFilter.url,
value: record[filterKey].url,
})) &&
(urlFilter.label === undefined ||
isMatchingStringFilter({
stringFilter: urlFilter.label,
value: record[filterKey].label,
}))
);
}
case FieldMetadataType.FullName: {
const fullNameFilter = filterValue as FullNameFilter;
return (
(fullNameFilter.firstName === undefined ||
isMatchingStringFilter({
stringFilter: fullNameFilter.firstName,
value: record[filterKey].firstName,
})) &&
(fullNameFilter.lastName === undefined ||
isMatchingStringFilter({
stringFilter: fullNameFilter.lastName,
value: record[filterKey].lastName,
}))
);
}
case FieldMetadataType.DateTime: {
return isMatchingDateFilter({
dateFilter: filterValue as DateFilter,
value: record[filterKey],
});
}
case FieldMetadataType.Number:
case FieldMetadataType.Numeric: {
return isMatchingFloatFilter({
floatFilter: filterValue as FloatFilter,
value: record[filterKey],
});
}
case FieldMetadataType.Uuid: {
return isMatchingUUIDFilter({
uuidFilter: filterValue as UUIDFilter,
value: record[filterKey],
});
}
case FieldMetadataType.Boolean: {
return isMatchingBooleanFilter({
booleanFilter: filterValue as BooleanFilter,
value: record[filterKey],
});
}
case FieldMetadataType.Relation: {
throw new Error(
`Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`,
);
}
default: {
throw new Error('Not implemented yet');
}
}
});
}; };

View File

@ -0,0 +1,21 @@
// There is a feature request for receiving variables in `cache.modify`:
// @see https://github.com/apollographql/apollo-feature-requests/issues/259
// @see https://github.com/apollographql/apollo-client/issues/7129
// For now we need to parse `storeFieldName` to retrieve the variables.
export const parseApolloStoreFieldName = (storeFieldName: string) => {
const matches = storeFieldName.match(/([a-zA-Z][a-zA-Z0-9 ]*)\((.*)\)/);
if (!matches?.[1]) return {};
const [, fieldName, stringifiedVariables] = matches;
try {
const variables = stringifiedVariables
? (JSON.parse(stringifiedVariables) as Record<string, unknown>)
: undefined;
return { fieldName, variables };
} catch {
return { fieldName };
}
};