From 294b290939fb364b63826208d3d8934efbc298a8 Mon Sep 17 00:00:00 2001 From: Sammy Teillet Date: Wed, 21 Jun 2023 04:27:02 +0200 Subject: [PATCH] 338 on opportunities page when i associate a new company to a pipelinestage its persisted in db (#339) * feature: add navigation for opportunities * chore: add companies in pipeline seed * feature: make the board scrollable * feature: make the board scrollable vertically * feature: remove board container * feature: fix newButton style * feature: add onClickNew method on board * feature: call backend with hardcoded id for new pipeline progressable * feature: refetch board on click on new * feature: use pipelineProgressId instead of entityId to ensure unicity of itemKey * feature: avoid rerender of columns when refetching --- front/src/AppNavbar.tsx | 7 ++ front/src/generated/graphql.tsx | 48 +++++++++++++ .../opportunities/components/Board.tsx | 69 +++++++++++++------ .../components/__stories__/Board.stories.tsx | 2 +- .../modules/opportunities/hooks/useBoard.ts | 27 +++++--- .../modules/opportunities/queries/index.ts | 20 ++++++ .../src/modules/ui/components/board/Board.tsx | 3 + .../ui/components/board/BoardColumn.tsx | 13 +++- .../ui/components/board/BoardNewButton.tsx | 14 +++- .../src/pages/opportunities/Opportunities.tsx | 63 ++++++++++++++--- server/src/database/seeds/pipelines.ts | 45 +++++++++++- 11 files changed, 263 insertions(+), 48 deletions(-) diff --git a/front/src/AppNavbar.tsx b/front/src/AppNavbar.tsx index 3b61b876e..206ce3a2b 100644 --- a/front/src/AppNavbar.tsx +++ b/front/src/AppNavbar.tsx @@ -7,6 +7,7 @@ import { IconInbox, IconSearch, IconSettings, + IconTargetArrow, IconUser, } from '@/ui/icons/index'; import NavItemsContainer from '@/ui/layout/navbar/NavItemsContainer'; @@ -57,6 +58,12 @@ export function AppNavbar() { icon={} active={currentPath === '/companies'} /> + } + active={currentPath === '/opportunities'} + /> ) : ( diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 10c220b54..4295f50cc 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -1639,6 +1639,16 @@ export type UpdateOnePipelineProgressMutationVariables = Exact<{ export type UpdateOnePipelineProgressMutation = { __typename?: 'Mutation', updateOnePipelineProgress?: { __typename?: 'PipelineProgress', id: string } | null }; +export type CreateOnePipelineProgressMutationVariables = Exact<{ + entityType: PipelineProgressableType; + entityId: Scalars['String']; + pipelineId: Scalars['String']; + pipelineStageId: Scalars['String']; +}>; + + +export type CreateOnePipelineProgressMutation = { __typename?: 'Mutation', createOnePipelineProgress: { __typename?: 'PipelineProgress', id: string } }; + export type GetPeopleQueryVariables = Exact<{ orderBy?: InputMaybe | PersonOrderByWithRelationInput>; where?: InputMaybe; @@ -2292,6 +2302,44 @@ export function useUpdateOnePipelineProgressMutation(baseOptions?: Apollo.Mutati export type UpdateOnePipelineProgressMutationHookResult = ReturnType; export type UpdateOnePipelineProgressMutationResult = Apollo.MutationResult; export type UpdateOnePipelineProgressMutationOptions = Apollo.BaseMutationOptions; +export const CreateOnePipelineProgressDocument = gql` + mutation CreateOnePipelineProgress($entityType: PipelineProgressableType!, $entityId: String!, $pipelineId: String!, $pipelineStageId: String!) { + createOnePipelineProgress( + data: {progressableType: $entityType, progressableId: $entityId, pipeline: {connect: {id: $pipelineId}}, pipelineStage: {connect: {id: $pipelineStageId}}} + ) { + id + } +} + `; +export type CreateOnePipelineProgressMutationFn = Apollo.MutationFunction; + +/** + * __useCreateOnePipelineProgressMutation__ + * + * To run a mutation, you first call `useCreateOnePipelineProgressMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateOnePipelineProgressMutation` 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 [createOnePipelineProgressMutation, { data, loading, error }] = useCreateOnePipelineProgressMutation({ + * variables: { + * entityType: // value for 'entityType' + * entityId: // value for 'entityId' + * pipelineId: // value for 'pipelineId' + * pipelineStageId: // value for 'pipelineStageId' + * }, + * }); + */ +export function useCreateOnePipelineProgressMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateOnePipelineProgressDocument, options); + } +export type CreateOnePipelineProgressMutationHookResult = ReturnType; +export type CreateOnePipelineProgressMutationResult = Apollo.MutationResult; +export type CreateOnePipelineProgressMutationOptions = Apollo.BaseMutationOptions; export const GetPeopleDocument = gql` query GetPeople($orderBy: [PersonOrderByWithRelationInput!], $where: PersonWhereInput, $limit: Int) { people: findManyPerson(orderBy: $orderBy, where: $where, take: $limit) { diff --git a/front/src/modules/opportunities/components/Board.tsx b/front/src/modules/opportunities/components/Board.tsx index 3088026ad..8ca9fe157 100644 --- a/front/src/modules/opportunities/components/Board.tsx +++ b/front/src/modules/opportunities/components/Board.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { DragDropContext, Draggable, @@ -10,11 +10,13 @@ import { BoardItemKey, Column, getOptimisticlyUpdatedBoard, + Item, Items, StyledBoard, } from '../../ui/components/board/Board'; import { ItemsContainer, + ScrollableColumn, StyledColumn, StyledColumnTitle, } from '../../ui/components/board/BoardColumn'; @@ -24,21 +26,43 @@ import { NewButton } from '../../ui/components/board/BoardNewButton'; import { BoardCard } from './BoardCard'; type BoardProps = { + columns: Omit[]; initialBoard: Column[]; items: Items; onUpdate?: (itemKey: BoardItemKey, columnId: Column['id']) => Promise; + onClickNew?: ( + columnId: Column['id'], + newItem: Partial & { id: string }, + ) => void; }; -export const Board = ({ initialBoard, items, onUpdate }: BoardProps) => { +export const Board = ({ + columns, + initialBoard, + items, + onUpdate, + onClickNew, +}: BoardProps) => { const [board, setBoard] = useState(initialBoard); + const onClickFunctions = useMemo< + Record & { id: string }) => void> + >(() => { + return board.reduce((acc, column) => { + acc[column.id] = (newItem: Partial & { id: string }) => { + onClickNew && onClickNew(column.id, newItem); + }; + return acc; + }, {} as Record & { id: string }) => void>); + }, [board, onClickNew]); + const onDragEnd: OnDragEndResponder = useCallback( async (result) => { const newBoard = getOptimisticlyUpdatedBoard(board, result); if (!newBoard) return; setBoard(newBoard); try { - const draggedEntityId = items[result.draggableId]?.id; + const draggedEntityId = result.draggableId; const destinationColumnId = result.destination?.droppableId; draggedEntityId && destinationColumnId && @@ -48,35 +72,38 @@ export const Board = ({ initialBoard, items, onUpdate }: BoardProps) => { console.error(e); } }, - [board, onUpdate, items], + [board, onUpdate], ); + console.log('board', board); return ( - {board.map((column) => ( + {columns.map((column, columnIndex) => ( {(droppableProvided) => ( • {column.title} - - {column.itemKeys.map((itemKey, index) => ( - - {(draggableProvided) => ( - - - - )} - - ))} - - + + + {board[columnIndex].itemKeys.map((itemKey, index) => ( + + {(draggableProvided) => ( + + + + )} + + ))} + + + )} diff --git a/front/src/modules/opportunities/components/__stories__/Board.stories.tsx b/front/src/modules/opportunities/components/__stories__/Board.stories.tsx index 21468690e..89d3d1957 100644 --- a/front/src/modules/opportunities/components/__stories__/Board.stories.tsx +++ b/front/src/modules/opportunities/components/__stories__/Board.stories.tsx @@ -16,7 +16,7 @@ type Story = StoryObj; export const OneColumnBoard: Story = { render: () => ( - + ), }; diff --git a/front/src/modules/opportunities/hooks/useBoard.ts b/front/src/modules/opportunities/hooks/useBoard.ts index 081a0537f..c002b8bf7 100644 --- a/front/src/modules/opportunities/hooks/useBoard.ts +++ b/front/src/modules/opportunities/hooks/useBoard.ts @@ -29,7 +29,7 @@ export const useBoard = () => { colorCode: pipelineStage.color, itemKeys: pipelineStage.pipelineProgresses?.map( - (item) => item.progressableId as BoardItemKey, + (item) => item.id as BoardItemKey, ) || [], })) || []; @@ -44,15 +44,15 @@ export const useBoard = () => { [] as { entityId: string; pipelineProgressId: string }[], ); - const pipelineEntityIdsMapper = (entityId: string) => { - const pipelineProgressId = pipelineEntityIds?.find( - (item) => item.entityId === entityId, - )?.pipelineProgressId; + const pipelineProgressableIdsMapper = (pipelineProgressId: string) => { + const entityId = pipelineEntityIds?.find( + (item) => item.pipelineProgressId === pipelineProgressId, + )?.entityId; - return pipelineProgressId; + return entityId; }; - const pipelineEntityType: 'Person' | 'Company' | undefined = + const pipelineEntityType = pipelines.data?.findManyPipeline[0].pipelineProgressableType; const query = @@ -69,7 +69,7 @@ export const useBoard = () => { [entity.id]: entity, }); - const items: Items | undefined = entitiesQueryResult.data + const entityItems = entitiesQueryResult.data ? isGetCompaniesQuery(entitiesQueryResult.data) ? entitiesQueryResult.data.companies.reduce(indexByIdReducer, {} as Items) : isGetPeopleQuery(entitiesQueryResult.data) @@ -77,11 +77,20 @@ export const useBoard = () => { : undefined : undefined; + const items = pipelineEntityIds?.reduce((acc, item) => { + const entityId = pipelineProgressableIdsMapper(item.pipelineProgressId); + if (entityId) { + acc[item.pipelineProgressId] = entityItems?.[entityId]; + } + return acc; + }, {} as Items); + return { initialBoard, items, loading: pipelines.loading || entitiesQueryResult.loading, error: pipelines.error || entitiesQueryResult.error, - pipelineEntityIdsMapper, + pipelineId: pipelines.data?.findManyPipeline[0].id, + pipelineEntityType, }; }; diff --git a/front/src/modules/opportunities/queries/index.ts b/front/src/modules/opportunities/queries/index.ts index 17bc09d1e..140c42c90 100644 --- a/front/src/modules/opportunities/queries/index.ts +++ b/front/src/modules/opportunities/queries/index.ts @@ -30,3 +30,23 @@ export const UPDATE_PIPELINE_STAGE = gql` } } `; + +export const ADD_ENTITY_TO_PIPELINE = gql` + mutation CreateOnePipelineProgress( + $entityType: PipelineProgressableType! + $entityId: String! + $pipelineId: String! + $pipelineStageId: String! + ) { + createOnePipelineProgress( + data: { + progressableType: $entityType + progressableId: $entityId + pipeline: { connect: { id: $pipelineId } } + pipelineStage: { connect: { id: $pipelineStageId } } + } + ) { + id + } + } +`; diff --git a/front/src/modules/ui/components/board/Board.tsx b/front/src/modules/ui/components/board/Board.tsx index e82072437..2664a40f8 100644 --- a/front/src/modules/ui/components/board/Board.tsx +++ b/front/src/modules/ui/components/board/Board.tsx @@ -2,9 +2,12 @@ import styled from '@emotion/styled'; import { DropResult } from '@hello-pangea/dnd'; // Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350 export const StyledBoard = styled.div` + border-radius: ${({ theme }) => theme.spacing(2)}; display: flex; flex-direction: row; height: 100%; + overflow-x: auto; + width: 100%; `; export type BoardItemKey = string; diff --git a/front/src/modules/ui/components/board/BoardColumn.tsx b/front/src/modules/ui/components/board/BoardColumn.tsx index 5c77ad7d5..43d0b1579 100644 --- a/front/src/modules/ui/components/board/BoardColumn.tsx +++ b/front/src/modules/ui/components/board/BoardColumn.tsx @@ -6,8 +6,13 @@ export const StyledColumn = styled.div` background-color: ${({ theme }) => theme.primaryBackground}; display: flex; flex-direction: column; + min-width: 300px; padding: ${({ theme }) => theme.spacing(2)}; - width: 300px; +`; + +export const ScrollableColumn = styled.div` + max-height: calc(100vh - 120px); + overflow-y: auto; `; export const StyledColumnTitle = styled.h3` @@ -21,6 +26,10 @@ export const StyledColumnTitle = styled.h3` margin-bottom: ${({ theme }) => theme.spacing(2)}; `; +const StyledPlaceholder = styled.div` + min-height: 1px; +`; + export const StyledItemContainer = styled.div``; export const ItemsContainer = ({ @@ -36,7 +45,7 @@ export const ItemsContainer = ({ {...droppableProvided?.droppableProps} > {children} - {droppableProvided?.placeholder} + {droppableProvided?.placeholder} ); }; diff --git a/front/src/modules/ui/components/board/BoardNewButton.tsx b/front/src/modules/ui/components/board/BoardNewButton.tsx index 4613c338d..7e9e9bca5 100644 --- a/front/src/modules/ui/components/board/BoardNewButton.tsx +++ b/front/src/modules/ui/components/board/BoardNewButton.tsx @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; @@ -14,17 +15,24 @@ const StyledButton = styled.button` display: flex; gap: ${({ theme }) => theme.spacing(1)}; justify-content: center; - transition: background-color 0.2s ease-in-out; + padding: ${(props) => props.theme.spacing(1)}; &:hover { background-color: ${({ theme }) => theme.secondaryBackground}; } `; -export const NewButton = () => { +export const NewButton = ({ + onClick, +}: { + onClick?: (...args: any[]) => void; +}) => { const theme = useTheme(); + const onInnerClick = useCallback(() => { + onClick && onClick({ id: 'twenty-aaffcfbd-f86b-419f-b794-02319abe8637' }); + }, [onClick]); return ( - + New diff --git a/front/src/pages/opportunities/Opportunities.tsx b/front/src/pages/opportunities/Opportunities.tsx index 3e842e90a..f30fce70f 100644 --- a/front/src/pages/opportunities/Opportunities.tsx +++ b/front/src/pages/opportunities/Opportunities.tsx @@ -1,4 +1,6 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; +import { getOperationName } from '@apollo/client/utilities'; +import { useTheme } from '@emotion/react'; import { IconTargetArrow } from '@/ui/icons/index'; import { WithTopBarContainer } from '@/ui/layout/containers/WithTopBarContainer'; @@ -6,36 +8,79 @@ import { WithTopBarContainer } from '@/ui/layout/containers/WithTopBarContainer' import { PipelineProgress, PipelineStage, + useCreateOnePipelineProgressMutation, useUpdateOnePipelineProgressMutation, } from '../../generated/graphql'; import { Board } from '../../modules/opportunities/components/Board'; import { useBoard } from '../../modules/opportunities/hooks/useBoard'; +import { GET_PIPELINES } from '../../modules/opportunities/queries'; export function Opportunities() { - const { initialBoard, items, loading, error, pipelineEntityIdsMapper } = + const theme = useTheme(); + + const { initialBoard, items, error, pipelineId, pipelineEntityType } = useBoard(); + const columns = useMemo( + () => + initialBoard?.map(({ id, colorCode, title }) => ({ + id, + colorCode, + title, + })), + [initialBoard], + ); const [updatePipelineProgress] = useUpdateOnePipelineProgressMutation(); + const [createPipelineProgress] = useCreateOnePipelineProgressMutation(); const onUpdate = useCallback( async ( - entityId: NonNullable, + pipelineProgressId: NonNullable, pipelineStageId: NonNullable, ) => { - const pipelineProgressId = pipelineEntityIdsMapper(entityId); updatePipelineProgress({ variables: { id: pipelineProgressId, pipelineStageId }, }); }, - [updatePipelineProgress, pipelineEntityIdsMapper], + [updatePipelineProgress], + ); + + const onClickNew = useCallback( + ( + columnId: PipelineStage['id'], + newItem: Partial & { id: string }, + ) => { + if (!pipelineId || !pipelineEntityType) return; + const variables = { + pipelineStageId: columnId, + pipelineId, + entityId: newItem.id, + entityType: pipelineEntityType, + }; + createPipelineProgress({ + variables, + refetchQueries: [getOperationName(GET_PIPELINES) ?? ''], + }); + }, + [pipelineId, pipelineEntityType, createPipelineProgress], ); - if (loading) return
Loading...
; if (error) return
Error...
; - if (!initialBoard || !items) + if (!initialBoard || !items) { return
Initial board or items not found
; + } + return ( - }> - + } + > + ); } diff --git a/server/src/database/seeds/pipelines.ts b/server/src/database/seeds/pipelines.ts index c27b19333..31b922afa 100644 --- a/server/src/database/seeds/pipelines.ts +++ b/server/src/database/seeds/pipelines.ts @@ -8,7 +8,7 @@ export const seedPipelines = async (prisma: PrismaClient) => { name: 'Sales pipeline', icon: '💰', workspaceId: 'twenty-7ed9d212-1c25-4d02-bf25-6aeccf7ea419', - pipelineProgressableType: 'Person', + pipelineProgressableType: 'Company', }, }); @@ -84,8 +84,47 @@ export const seedPipelines = async (prisma: PrismaClient) => { id: 'twenty-fe256b39-3ec3-4fe7-8998-b76aa0bfb600', pipelineId: 'twenty-fe256b39-3ec3-4fe3-8997-b75aa0bfb400', pipelineStageId: 'twenty-fe256b39-3ec3-4fe3-8998-b76aa0bfb600', - progressableType: 'Person', - progressableId: 'twenty-755035db-623d-41fe-92e7-dd45b7c568e1', + progressableType: 'Company', + progressableId: 'twenty-fe256b39-3ec3-4fe3-8997-b76aa0bfa408', + workspaceId: 'twenty-7ed9d212-1c25-4d02-bf25-6aeccf7ea419', + }, + }); + + await prisma.pipelineProgress.upsert({ + where: { id: 'twenty-4a886c90-f4f2-4984-8222-882ebbb905d6' }, + update: {}, + create: { + id: 'twenty-4a886c90-f4f2-4984-8222-882ebbb905d6', + pipelineId: 'twenty-fe256b39-3ec3-4fe3-8997-b75aa0bfb400', + pipelineStageId: 'twenty-fe256b39-3ec3-4fe4-8998-b76aa0bfb600', + progressableType: 'Company', + progressableId: 'twenty-118995f3-5d81-46d6-bf83-f7fd33ea6102', + workspaceId: 'twenty-7ed9d212-1c25-4d02-bf25-6aeccf7ea419', + }, + }); + + await prisma.pipelineProgress.upsert({ + where: { id: 'twenty-af92f3eb-d51d-4528-9b97-b8f132865b00' }, + update: {}, + create: { + id: 'twenty-af92f3eb-d51d-4528-9b97-b8f132865b00', + pipelineId: 'twenty-fe256b39-3ec3-4fe3-8997-b75aa0bfb400', + pipelineStageId: 'twenty-fe256b39-3ec3-4fe5-8998-b76aa0bfb600', + progressableType: 'Company', + progressableId: 'twenty-04b2e9f5-0713-40a5-8216-82802401d33e', + workspaceId: 'twenty-7ed9d212-1c25-4d02-bf25-6aeccf7ea419', + }, + }); + + await prisma.pipelineProgress.upsert({ + where: { id: 'twenty-08369b1a-acdb-43d6-95f9-67ac7436941a' }, + update: {}, + create: { + id: 'twenty-08369b1a-acdb-43d6-95f9-67ac7436941a', + pipelineId: 'twenty-fe256b39-3ec3-4fe3-8997-b75aa0bfb400', + pipelineStageId: 'twenty-fe256b39-3ec3-4fe5-8998-b76aa0bfb600', + progressableType: 'Company', + progressableId: 'twenty-460b6fb1-ed89-413a-b31a-962986e67bb4', workspaceId: 'twenty-7ed9d212-1c25-4d02-bf25-6aeccf7ea419', }, });