Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix favorites #3138

Merged
merged 12 commits into from
Jan 3, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ export const useOptimisticEffect = ({
variables,
});

if (!existingData) {
if (
!existingData &&
(isNonEmptyArray(updatedRecords) ||
charlesBochet marked this conversation as resolved.
Show resolved Hide resolved
isNonEmptyArray(deletedRecordIds))
) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ const StyledContainer = styled(NavigationDrawerSection)`
`;

export const Favorites = () => {
const { favorites, handleReorderFavorite } = useFavorites({
objectNamePlural: 'companies',
});
const { favorites, handleReorderFavorite } = useFavorites();

if (!favorites || favorites.length === 0) return <></>;

Expand Down
290 changes: 120 additions & 170 deletions packages/twenty-front/src/modules/favorites/hooks/useFavorites.ts
Original file line number Diff line number Diff line change
@@ -1,215 +1,165 @@
import { useApolloClient } from '@apollo/client';
import { useMemo } from 'react';
import { OnDragEndResponder } from '@hello-pangea/dnd';
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { useRecoilValue } from 'recoil';

import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { useOptimisticEvict } from '@/apollo/optimistic-effect/hooks/useOptimisticEvict';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { Favorite } from '@/favorites/types/Favorite';
import { mapFavorites } from '@/favorites/utils/mapFavorites';
import { useGetObjectRecordIdentifierByNameSingular } from '@/object-metadata/hooks/useGetObjectRecordIdentifierByNameSingular';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { isStandardObject } from '@/object-metadata/utils/isStandardObject';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';

import { favoritesState } from '../states/favoritesState';

export const useFavorites = ({
objectNamePlural,
}: {
objectNamePlural: string;
}) => {
export const useFavorites = () => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);

const [favorites, setFavorites] = useRecoilState(favoritesState);
const favoriteObjectNameSingular = 'favorite';

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel we could introduce an enum here for all standard objects

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that you already have one: StandardObjectNameSingular

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's in another PR

const { objectMetadataItem: favoriteObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: favoriteObjectNameSingular,
});

const {
updateOneRecordMutation: updateOneFavoriteMutation,
createOneRecordMutation: createOneFavoriteMutation,
deleteOneRecordMutation: deleteOneFavoriteMutation,
} = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Favorite,
const { deleteOneRecord } = useDeleteOneRecord({
objectNameSingular: favoriteObjectNameSingular,
});

const { triggerOptimisticEffects } = useOptimisticEffect({
objectNameSingular: CoreObjectNameSingular.Favorite,
const { updateOneRecord: updateOneFavorite } = useUpdateOneRecord({
objectNameSingular: favoriteObjectNameSingular,
});
const { performOptimisticEvict } = useOptimisticEvict();

const { objectNameSingular } = useObjectNameSingularFromPlural({
objectNamePlural,
const { createOneRecord: createOneFavorite } = useCreateOneRecord({
objectNameSingular: favoriteObjectNameSingular,
});

const { objectMetadataItem: favoriteTargetObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular,
});
const { records: favorites } = useFindManyRecords<Favorite>({
objectNameSingular: favoriteObjectNameSingular,
});

const apolloClient = useApolloClient();
const favoriteRelationFields = useMemo(
() =>
charlesBochet marked this conversation as resolved.
Show resolved Hide resolved
favoriteObjectMetadataItem.fields.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.Relation,
),
[favoriteObjectMetadataItem.fields],
);

useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.Favorite,
onCompleted: useRecoilCallback(
({ snapshot, set }) =>
async (data: PaginatedRecordTypeResults<Required<Favorite>>) => {
const favorites = snapshot.getLoadable(favoritesState).getValue();
const getObjectRecordIdentifierByNameSingular =
useGetObjectRecordIdentifierByNameSingular();

const queriedFavorites = mapFavorites(
data.edges.map((edge) => edge.node),
);
const favoritesSorted = useMemo(() => {
return favorites
.map((favorite) => {
const nonSystemRelationFields = favoriteRelationFields.filter(
(relationField) =>
!relationField.isSystem || isStandardObject(relationField.name),
);

if (!isDeeplyEqual(favorites, queriedFavorites)) {
set(favoritesState, queriedFavorites);
for (const relationField of nonSystemRelationFields) {
if (isDefined(favorite[relationField.name])) {
const relationObject = favorite[relationField.name];

const relationObjectNameSingular =
relationField.toRelationMetadata?.fromObjectMetadata
.nameSingular ?? '';

const objectRecordIdentifier =
getObjectRecordIdentifierByNameSingular(
relationObject,
relationObjectNameSingular,
);

return {
id: favorite.id,
recordId: objectRecordIdentifier.id,
position: favorite.position,
avatarType: objectRecordIdentifier.avatarType,
avatarUrl: objectRecordIdentifier.avatarUrl,
labelIdentifier: objectRecordIdentifier.name,
link: objectRecordIdentifier.linkToShowPage,
} as Favorite;
}
},
[],
),
});

const createFavorite = useRecoilCallback(
({ snapshot, set }) =>
async (favoriteTargetObjectId: string, additionalData?: any) => {
const favorites = snapshot.getLoadable(favoritesState).getValue();

if (!favoriteTargetObjectMetadataItem) {
return;
}
const targetObjectName = favoriteTargetObjectMetadataItem.nameSingular;

const result = await apolloClient.mutate({
mutation: createOneFavoriteMutation,
variables: {
input: {
[`${targetObjectName}Id`]: favoriteTargetObjectId,
position: favorites.length + 1,
workspaceMemberId: currentWorkspaceMember?.id,
},
},
});

triggerOptimisticEffects({
typename: `FavoriteEdge`,
createdRecords: [result.data[`createFavorite`]],
});

const createdFavorite = result?.data?.createFavorite;

const newFavorite = {
...additionalData,
...createdFavorite,
};

const newFavoritesMapped = mapFavorites([newFavorite]);

if (createdFavorite) {
set(favoritesState, [...favorites, ...newFavoritesMapped]);
}
},
[
apolloClient,
createOneFavoriteMutation,
currentWorkspaceMember?.id,
favoriteTargetObjectMetadataItem,
triggerOptimisticEffects,
],
);

const _updateFavoritePosition = useRecoilCallback(
({ snapshot, set }) =>
async (favoriteToUpdate: Favorite) => {
const favoritesStateFromSnapshot = snapshot.getLoadable(favoritesState);
const favorites = favoritesStateFromSnapshot.getValue();
const result = await apolloClient.mutate({
mutation: updateOneFavoriteMutation,
variables: {
input: {
position: favoriteToUpdate?.position,
},
idToUpdate: favoriteToUpdate?.id,
},
});

const updatedFavorite = result?.data?.updateFavoriteV2;
if (updatedFavorite) {
set(
favoritesState,
favorites.map((favorite: Favorite) =>
favorite.id === updatedFavorite.id ? favoriteToUpdate : favorite,
),
);
}
},
[apolloClient, updateOneFavoriteMutation],
);
return favorite;
})
.toSorted((a, b) => a.position - b.position);
}, [
favorites,
favoriteRelationFields,
getObjectRecordIdentifierByNameSingular,
]);

const createFavorite = (
targetObject: Record<string, any>,
targetObjectNameSingular: string,
) => {
createOneFavorite({
[`${targetObjectNameSingular}Id`]: targetObject.id,
[`${targetObjectNameSingular}`]: targetObject,
position: favorites.length + 1,
workspaceMemberId: currentWorkspaceMember?.id,
});
};

const deleteFavorite = useRecoilCallback(
({ snapshot, set }) =>
async (favoriteIdToDelete: string) => {
const favoritesStateFromSnapshot = snapshot.getLoadable(favoritesState);
const favorites = favoritesStateFromSnapshot.getValue();
const idToDelete = favorites.find(
(favorite: Favorite) => favorite.recordId === favoriteIdToDelete,
)?.id;

await apolloClient.mutate({
mutation: deleteOneFavoriteMutation,
variables: {
idToDelete: idToDelete,
},
});

performOptimisticEvict('Favorite', 'id', idToDelete ?? '');

set(
favoritesState,
favorites.filter((favorite: Favorite) => favorite.id !== idToDelete),
);
},
[apolloClient, deleteOneFavoriteMutation, performOptimisticEvict],
);
const deleteFavorite = (favoriteId: string) => {
deleteOneRecord(favoriteId);
};

const computeNewPosition = (destIndex: number, sourceIndex: number) => {
if (destIndex === 0) {
return favorites[destIndex].position / 2;
}

if (destIndex === favorites.length - 1) {
return favorites[destIndex - 1].position + 1;
}

if (sourceIndex < destIndex) {
const moveToFirstPosition = destIndex === 0;
const moveToLastPosition = destIndex === favoritesSorted.length - 1;
const moveAfterSource = destIndex > sourceIndex;

if (moveToFirstPosition) {
return favoritesSorted[0].position / 2;
} else if (moveToLastPosition) {
thaisguigon marked this conversation as resolved.
Show resolved Hide resolved
return favoritesSorted[destIndex - 1].position + 1;
} else if (moveAfterSource) {
return (
(favoritesSorted[destIndex + 1].position +
favoritesSorted[destIndex].position) /
2
);
} else {
return (
(favorites[destIndex + 1].position + favorites[destIndex].position) / 2
favoritesSorted[destIndex].position -
(favoritesSorted[destIndex].position -
favoritesSorted[destIndex - 1].position) /
2
);
}

return (
(favorites[destIndex - 1].position + favorites[destIndex].position) / 2
);
};

const handleReorderFavorite: OnDragEndResponder = (result) => {
if (!result.destination || !favorites) {
if (!result.destination || !favoritesSorted) {
return;
}

const newPosition = computeNewPosition(
result.destination.index,
result.source.index,
);

const reorderFavorites = Array.from(favorites);
const [removed] = reorderFavorites.splice(result.source.index, 1);
const removedFav = { ...removed, position: newPosition };
reorderFavorites.splice(result.destination.index, 0, removedFav);
setFavorites(reorderFavorites);
_updateFavoritePosition(removedFav);
const updatedFavorite = favoritesSorted[result.source.index];

updateOneFavorite({
idToUpdate: updatedFavorite.id,
updateOneRecordInput: {
position: newPosition,
},
});
};

return {
favorites,
favorites: favoritesSorted,
createFavorite,
deleteFavorite,
handleReorderFavorite,
deleteFavorite,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useRecoilValue } from 'recoil';

import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';

export const useGetObjectRecordIdentifierByNameSingular = () => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);

return (record: any, objectNameSingular: string): ObjectRecordIdentifier => {
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === objectNameSingular,
);

if (!objectMetadataItem) {
throw new Error(
`ObjectMetadataItem not found for objectNameSingular: ${objectNameSingular}`,
);
}

return getObjectRecordIdentifier({
objectMetadataItem,
record,
});
};
};
Loading
Loading