diff --git a/front/src/generated-metadata/graphql.ts b/front/src/generated-metadata/graphql.ts index 7d370da40..03c4c53fb 100644 --- a/front/src/generated-metadata/graphql.ts +++ b/front/src/generated-metadata/graphql.ts @@ -395,6 +395,7 @@ export type Favorite = { id: Scalars['ID']['output']; person?: Maybe; personId?: Maybe; + position: Scalars['Float']['output']; workspaceId?: Maybe; workspaceMember?: Maybe; workspaceMemberId?: Maybe; diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 7b3cdc668..17f40f8e3 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -1226,6 +1226,7 @@ export type Favorite = { id: Scalars['ID']; person?: Maybe; personId?: Maybe; + position: Scalars['Float']; workspaceId?: Maybe; workspaceMember?: Maybe; workspaceMemberId?: Maybe; @@ -1247,16 +1248,24 @@ export type FavoriteListRelationFilter = { export type FavoriteMutationForCompanyArgs = { companyId: Scalars['String']; + position: Scalars['Float']; }; export type FavoriteMutationForPersonArgs = { personId: Scalars['String']; + position: Scalars['Float']; }; export type FavoriteOrderByRelationAggregateInput = { _count?: InputMaybe; }; +export type FavoriteUpdateInput = { + id?: InputMaybe; + position?: InputMaybe; + workspaceId?: InputMaybe; +}; + export type FavoriteUpdateManyWithoutCompanyNestedInput = { connect?: InputMaybe>; disconnect?: InputMaybe>; @@ -1282,6 +1291,7 @@ export type FavoriteWhereInput = { companyId?: InputMaybe; id?: InputMaybe; personId?: InputMaybe; + position?: InputMaybe; workspaceId?: InputMaybe; workspaceMemberId?: InputMaybe; }; @@ -1413,6 +1423,7 @@ export type Mutation = { signUp: LoginToken; updateOneActivity: Activity; updateOneCompany?: Maybe; + updateOneFavorites: Favorite; updateOneField: Field; updateOneObject: Object; updateOnePerson?: Maybe; @@ -1637,6 +1648,12 @@ export type MutationUpdateOneCompanyArgs = { }; +export type MutationUpdateOneFavoritesArgs = { + data: FavoriteUpdateInput; + where: FavoriteWhereUniqueInput; +}; + + export type MutationUpdateOnePersonArgs = { data: PersonUpdateInput; where: PersonWhereUniqueInput; @@ -3854,10 +3871,18 @@ export type InsertPersonFavoriteMutationVariables = Exact<{ export type InsertPersonFavoriteMutation = { __typename?: 'Mutation', createFavoriteForPerson: { __typename?: 'Favorite', id: string, person?: { __typename?: 'Person', id: string, firstName?: string | null, lastName?: string | null, displayName: string } | null } }; +export type UpdateOneFavoriteMutationVariables = Exact<{ + data: FavoriteUpdateInput; + where: FavoriteWhereUniqueInput; +}>; + + +export type UpdateOneFavoriteMutation = { __typename?: 'Mutation', updateOneFavorites: { __typename?: 'Favorite', id: string, person?: { __typename?: 'Person', id: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null } | null, company?: { __typename?: 'Company', id: string, name: string, domainName: string, accountOwner?: { __typename?: 'User', id: string, displayName: string, avatarUrl?: string | null } | null } | null } }; + export type GetFavoritesQueryVariables = Exact<{ [key: string]: never; }>; -export type GetFavoritesQuery = { __typename?: 'Query', findFavorites: Array<{ __typename?: 'Favorite', id: string, person?: { __typename?: 'Person', id: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null } | null, company?: { __typename?: 'Company', id: string, name: string, domainName: string, accountOwner?: { __typename?: 'User', id: string, displayName: string, avatarUrl?: string | null } | null } | null }> }; +export type GetFavoritesQuery = { __typename?: 'Query', findFavorites: Array<{ __typename?: 'Favorite', id: string, position: number, person?: { __typename?: 'Person', id: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null } | null, company?: { __typename?: 'Company', id: string, name: string, domainName: string, accountOwner?: { __typename?: 'User', id: string, displayName: string, avatarUrl?: string | null } | null } | null }> }; export type BasePersonFieldsFragmentFragment = { __typename?: 'Person', id: string, phone?: string | null, email?: string | null, city?: string | null, firstName?: string | null, lastName?: string | null, displayName: string, avatarUrl?: string | null, createdAt: string }; @@ -5589,10 +5614,61 @@ export function useInsertPersonFavoriteMutation(baseOptions?: Apollo.MutationHoo export type InsertPersonFavoriteMutationHookResult = ReturnType; export type InsertPersonFavoriteMutationResult = Apollo.MutationResult; export type InsertPersonFavoriteMutationOptions = Apollo.BaseMutationOptions; +export const UpdateOneFavoriteDocument = gql` + mutation UpdateOneFavorite($data: FavoriteUpdateInput!, $where: FavoriteWhereUniqueInput!) { + updateOneFavorites(data: $data, where: $where) { + id + person { + id + firstName + lastName + avatarUrl + } + company { + id + name + domainName + accountOwner { + id + displayName + avatarUrl + } + } + } +} + `; +export type UpdateOneFavoriteMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateOneFavoriteMutation__ + * + * To run a mutation, you first call `useUpdateOneFavoriteMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateOneFavoriteMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateOneFavoriteMutation, { data, loading, error }] = useUpdateOneFavoriteMutation({ + * variables: { + * data: // value for 'data' + * where: // value for 'where' + * }, + * }); + */ +export function useUpdateOneFavoriteMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateOneFavoriteDocument, options); + } +export type UpdateOneFavoriteMutationHookResult = ReturnType; +export type UpdateOneFavoriteMutationResult = Apollo.MutationResult; +export type UpdateOneFavoriteMutationOptions = Apollo.BaseMutationOptions; export const GetFavoritesDocument = gql` query GetFavorites { findFavorites { id + position person { id firstName diff --git a/front/src/modules/favorites/components/Favorites.tsx b/front/src/modules/favorites/components/Favorites.tsx index 1cae8177e..91f3ed254 100644 --- a/front/src/modules/favorites/components/Favorites.tsx +++ b/front/src/modules/favorites/components/Favorites.tsx @@ -1,11 +1,17 @@ import styled from '@emotion/styled'; +import { useRecoilState } from 'recoil'; +import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; +import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import NavItem from '@/ui/navigation/navbar/components/NavItem'; import NavTitle from '@/ui/navigation/navbar/components/NavTitle'; import { Avatar } from '@/users/components/Avatar'; import { useGetFavoritesQuery } from '~/generated/graphql'; import { getLogoUrlFromDomainName } from '~/utils'; +import { useFavorites } from '../hooks/useFavorites'; +import { favoritesState } from '../states/favoritesState'; + const StyledContainer = styled.div` display: flex; flex-direction: column; @@ -14,46 +20,94 @@ const StyledContainer = styled.div` `; export const Favorites = () => { - const { data } = useGetFavoritesQuery(); - const favorites = data?.findFavorites; + const [favorites, setFavorites] = useRecoilState(favoritesState); + const { handleReorderFavorite } = useFavorites(); + + useGetFavoritesQuery({ + onCompleted: (data) => + setFavorites( + data?.findFavorites.map((favorite) => { + return { + id: favorite.id, + person: favorite.person + ? { + id: favorite.person.id, + firstName: favorite.person.firstName, + lastName: favorite.person.lastName, + avatarUrl: favorite.person.avatarUrl, + } + : undefined, + company: favorite.company + ? { + id: favorite.company.id, + name: favorite.company.name, + domainName: favorite.company.domainName, + } + : undefined, + position: favorite.position, + }; + }) ?? [], + ), + }); if (!favorites || favorites.length === 0) return <>; return ( - {favorites.map( - ({ id, person, company }) => - (person && ( - ( - + {favorites.map((favorite, index) => { + const { id, person, company } = favorite; + return ( + + {person && ( + ( + + )} + to={`/person/${person.id}`} + /> + )} + {company && ( + ( + + )} + to={`/companies/${company.id}`} + /> + )} + + } /> - )} - to={`/person/${person.id}`} - /> - )) ?? - (company && ( - ( - - )} - to={`/companies/${company.id}`} - /> - )), - )} + ); + })} + + } + /> ); }; diff --git a/front/src/modules/favorites/graphql/mutations/updateOneFavorite.ts b/front/src/modules/favorites/graphql/mutations/updateOneFavorite.ts new file mode 100644 index 000000000..f31aa5602 --- /dev/null +++ b/front/src/modules/favorites/graphql/mutations/updateOneFavorite.ts @@ -0,0 +1,28 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_FAVORITE = gql` + mutation UpdateOneFavorite( + $data: FavoriteUpdateInput! + $where: FavoriteWhereUniqueInput! + ) { + updateOneFavorites(data: $data, where: $where) { + id + person { + id + firstName + lastName + avatarUrl + } + company { + id + name + domainName + accountOwner { + id + displayName + avatarUrl + } + } + } + } +`; diff --git a/front/src/modules/favorites/graphql/queries/getFavorites.ts b/front/src/modules/favorites/graphql/queries/getFavorites.ts index a41de5b08..7102bed1e 100644 --- a/front/src/modules/favorites/graphql/queries/getFavorites.ts +++ b/front/src/modules/favorites/graphql/queries/getFavorites.ts @@ -4,6 +4,7 @@ export const GET_FAVORITES = gql` query GetFavorites { findFavorites { id + position person { id firstName diff --git a/front/src/modules/favorites/hooks/useFavorites.ts b/front/src/modules/favorites/hooks/useFavorites.ts index a57ef2785..3dc348260 100644 --- a/front/src/modules/favorites/hooks/useFavorites.ts +++ b/front/src/modules/favorites/hooks/useFavorites.ts @@ -1,25 +1,34 @@ import { getOperationName } from '@apollo/client/utilities'; +import { OnDragEndResponder } from '@hello-pangea/dnd'; +import { useRecoilState } from 'recoil'; import { GET_COMPANY } from '@/companies/graphql/queries/getCompany'; import { GET_PERSON } from '@/people/graphql/queries/getPerson'; import { + Favorite, useDeleteFavoriteMutation, useInsertCompanyFavoriteMutation, useInsertPersonFavoriteMutation, + useUpdateOneFavoriteMutation, } from '~/generated/graphql'; import { GET_FAVORITES } from '../graphql/queries/getFavorites'; +import { favoritesState } from '../states/favoritesState'; export const useFavorites = () => { + const [favorites, setFavorites] = useRecoilState(favoritesState); + const [insertCompanyFavoriteMutation] = useInsertCompanyFavoriteMutation(); const [insertPersonFavoriteMutation] = useInsertPersonFavoriteMutation(); const [deleteFavoriteMutation] = useDeleteFavoriteMutation(); + const [updateOneFavoritesMutation] = useUpdateOneFavoriteMutation(); const insertCompanyFavorite = (companyId: string) => { insertCompanyFavoriteMutation({ variables: { data: { companyId, + position: favorites.length + 1, }, }, refetchQueries: [ @@ -34,6 +43,7 @@ export const useFavorites = () => { variables: { data: { personId, + position: favorites.length + 1, }, }, refetchQueries: [ @@ -43,6 +53,25 @@ export const useFavorites = () => { }); }; + const updateFavoritePosition = async ( + favorites: Pick, + ) => { + await updateOneFavoritesMutation({ + variables: { + data: { + position: favorites?.position, + }, + where: { + id: favorites.id, + }, + }, + refetchQueries: [ + getOperationName(GET_FAVORITES) ?? '', + getOperationName(GET_PERSON) ?? '', + getOperationName(GET_COMPANY) ?? '', + ], + }); + }; const deleteCompanyFavorite = (companyId: string) => { deleteFavoriteMutation({ variables: { @@ -75,10 +104,37 @@ export const useFavorites = () => { }); }; + const computeNewPosition = (destIndex: number) => { + if (destIndex === 0) { + return favorites[destIndex].position / 2; + } + + if (destIndex === favorites.length - 1) { + return favorites[destIndex].position + 1; + } + return ( + (favorites[destIndex - 1].position + favorites[destIndex].position) / 2 + ); + }; + + const handleReorderFavorite: OnDragEndResponder = (result) => { + if (!result.destination || !favorites) { + return; + } + const newPosition = computeNewPosition(result.destination.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); + }; return { insertCompanyFavorite, insertPersonFavorite, deleteCompanyFavorite, deletePersonFavorite, + handleReorderFavorite, }; }; diff --git a/front/src/modules/favorites/states/favoritesState.ts b/front/src/modules/favorites/states/favoritesState.ts new file mode 100644 index 000000000..03a05e92f --- /dev/null +++ b/front/src/modules/favorites/states/favoritesState.ts @@ -0,0 +1,16 @@ +import { atom } from 'recoil'; + +import { Company, Favorite, Person } from '~/generated/graphql'; + +export const favoritesState = atom< + Array< + Pick & { + company?: Pick; + } & { + person?: Pick; + } + > +>({ + key: 'favoritesState', + default: [], +}); diff --git a/front/src/modules/ui/navigation/navbar/components/NavItem.tsx b/front/src/modules/ui/navigation/navbar/components/NavItem.tsx index 28f78ca86..c5cfbc143 100644 --- a/front/src/modules/ui/navigation/navbar/components/NavItem.tsx +++ b/front/src/modules/ui/navigation/navbar/components/NavItem.tsx @@ -27,7 +27,7 @@ type StyledItemProps = { soon?: boolean; }; -const StyledItem = styled.button` +const StyledItem = styled.div` align-items: center; background: ${(props) => props.active ? props.theme.background.transparent.light : 'inherit'}; diff --git a/server/src/ability/ability.factory.ts b/server/src/ability/ability.factory.ts index d4cdcd936..6a5bdd759 100644 --- a/server/src/ability/ability.factory.ts +++ b/server/src/ability/ability.factory.ts @@ -201,6 +201,7 @@ export class AbilityFactory { // Favorite can(AbilityAction.Read, 'Favorite', { workspaceId: workspace.id }); can(AbilityAction.Create, 'Favorite'); + can(AbilityAction.Update, 'Favorite', { workspaceId: workspace.id }); can(AbilityAction.Delete, 'Favorite', { workspaceId: workspace.id }); return build(); diff --git a/server/src/ability/ability.module.ts b/server/src/ability/ability.module.ts index 05dca18ca..4f13ffc7c 100644 --- a/server/src/ability/ability.module.ts +++ b/server/src/ability/ability.module.ts @@ -103,6 +103,7 @@ import { CreateFavoriteAbilityHandler, ReadFavoriteAbilityHandler, DeleteFavoriteAbilityHandler, + UpdateFavoriteAbilityHandler, } from './handlers/favorite.ability-handler'; import { CreateViewSortAbilityHandler, @@ -219,6 +220,7 @@ import { //Favorite ReadFavoriteAbilityHandler, CreateFavoriteAbilityHandler, + UpdateFavoriteAbilityHandler, DeleteFavoriteAbilityHandler, // View ReadViewAbilityHandler, diff --git a/server/src/ability/handlers/favorite.ability-handler.ts b/server/src/ability/handlers/favorite.ability-handler.ts index e989e4d36..65fc4b3e1 100644 --- a/server/src/ability/handlers/favorite.ability-handler.ts +++ b/server/src/ability/handlers/favorite.ability-handler.ts @@ -57,6 +57,34 @@ export class CreateFavoriteAbilityHandler implements IAbilityHandler { } } +@Injectable() +export class UpdateFavoriteAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + + const favorite = await this.prismaService.client.favorite.findFirst({ + where: args.where, + }); + assert(favorite, '', NotFoundException); + + const allowed = await relationAbilityChecker( + 'Favorite', + ability, + this.prismaService.client, + args, + ); + + if (!allowed) { + return false; + } + + return ability.can(AbilityAction.Update, 'Favorite'); + } +} + @Injectable() export class DeleteFavoriteAbilityHandler implements IAbilityHandler { constructor(private readonly prismaService: PrismaService) {} diff --git a/server/src/core/favorite/resolvers/favorite.resolver.ts b/server/src/core/favorite/resolvers/favorite.resolver.ts index 71955ae99..7ad1ee88f 100644 --- a/server/src/core/favorite/resolvers/favorite.resolver.ts +++ b/server/src/core/favorite/resolvers/favorite.resolver.ts @@ -17,21 +17,28 @@ import { CreateFavoriteAbilityHandler, DeleteFavoriteAbilityHandler, ReadFavoriteAbilityHandler, + UpdateFavoriteAbilityHandler, } from 'src/ability/handlers/favorite.ability-handler'; import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; import { FavoriteService } from 'src/core/favorite/services/favorite.service'; import { FavoriteWhereInput } from 'src/core/@generated/favorite/favorite-where.input'; +import { SortOrder } from 'src/core/@generated/prisma/sort-order.enum'; +import { UpdateOneFavoriteArgs } from 'src/core/@generated/favorite/update-one-favorite.args'; @InputType() class FavoriteMutationForPersonArgs { @Field(() => String) personId: string; + @Field(() => Number) + position: number; } @InputType() class FavoriteMutationForCompanyArgs { @Field(() => String) companyId: string; + @Field(() => Number) + position: number; } @UseGuards(JwtAuthGuard) @@ -49,6 +56,7 @@ export class FavoriteResolver { where: { workspaceId: workspace.id, }, + orderBy: [{ position: SortOrder.asc }], include: { person: true, company: { @@ -86,6 +94,7 @@ export class FavoriteResolver { connect: { id: args.personId }, }, workspaceId: workspace.id, + position: args.position, }, select: prismaSelect.value, }); @@ -115,11 +124,29 @@ export class FavoriteResolver { connect: { id: args.companyId }, }, workspaceId: workspace.id, + position: args.position, }, select: prismaSelect.value, }); } + @Mutation(() => Favorite, { + nullable: false, + }) + @UseGuards(AbilityGuard) + @CheckAbilities(UpdateFavoriteAbilityHandler) + async updateOneFavorites( + @Args() args: UpdateOneFavoriteArgs, + @PrismaSelector({ modelName: 'Favorite' }) + prismaSelect: PrismaSelect<'Favorite'>, + ): Promise> { + return this.favoriteService.update({ + data: args.data, + where: args.where, + select: prismaSelect.value, + }); + } + @Mutation(() => Favorite, { nullable: false, }) diff --git a/server/src/database/migrations/20231019210329_add_position_to_favorite/migration.sql b/server/src/database/migrations/20231019210329_add_position_to_favorite/migration.sql new file mode 100644 index 000000000..ecd0b314b --- /dev/null +++ b/server/src/database/migrations/20231019210329_add_position_to_favorite/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `position` to the `favorites` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "favorites" ADD COLUMN "position" DOUBLE PRECISION NOT NULL; diff --git a/server/src/database/schema.prisma b/server/src/database/schema.prisma index c19e0e6d8..9084aee00 100644 --- a/server/src/database/schema.prisma +++ b/server/src/database/schema.prisma @@ -789,6 +789,7 @@ model Favorite { /// @TypeGraphQL.omit(input: true, output: false) workspaceMember WorkspaceMember? @relation(fields: [workspaceMemberId], references: [id]) workspaceMemberId String? + position Float @@map("favorites") } @@ -892,19 +893,19 @@ model ViewField { model ApiKey { /// @Validator.IsString() /// @Validator.IsOptional() - id String @id @default(uuid()) - name String + id String @id @default(uuid()) + name String /// @TypeGraphQL.omit(input: true, output: true) workspace Workspace @relation(fields: [workspaceId], references: [id]) /// @TypeGraphQL.omit(input: true, output: true) workspaceId String - expiresAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt /// @TypeGraphQL.omit(input: true, output: true) - deletedAt DateTime? + deletedAt DateTime? /// @TypeGraphQL.omit(input: true, output: true) - revokedAt DateTime? + revokedAt DateTime? @@map("api_keys") }