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
This commit is contained in:
Sammy Teillet
2023-06-21 04:27:02 +02:00
committed by GitHub
parent 8790369f72
commit 294b290939
11 changed files with 263 additions and 48 deletions

View File

@ -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={<IconBuildingSkyscraper size={theme.iconSizeMedium} />}
active={currentPath === '/companies'}
/>
<NavItem
label="Opportunities"
to="/opportunities"
icon={<IconTargetArrow size={theme.iconSizeMedium} />}
active={currentPath === '/opportunities'}
/>
</NavItemsContainer>
</>
) : (

View File

@ -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<Array<PersonOrderByWithRelationInput> | PersonOrderByWithRelationInput>;
where?: InputMaybe<PersonWhereInput>;
@ -2292,6 +2302,44 @@ export function useUpdateOnePipelineProgressMutation(baseOptions?: Apollo.Mutati
export type UpdateOnePipelineProgressMutationHookResult = ReturnType<typeof useUpdateOnePipelineProgressMutation>;
export type UpdateOnePipelineProgressMutationResult = Apollo.MutationResult<UpdateOnePipelineProgressMutation>;
export type UpdateOnePipelineProgressMutationOptions = Apollo.BaseMutationOptions<UpdateOnePipelineProgressMutation, UpdateOnePipelineProgressMutationVariables>;
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<CreateOnePipelineProgressMutation, CreateOnePipelineProgressMutationVariables>;
/**
* __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<CreateOnePipelineProgressMutation, CreateOnePipelineProgressMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateOnePipelineProgressMutation, CreateOnePipelineProgressMutationVariables>(CreateOnePipelineProgressDocument, options);
}
export type CreateOnePipelineProgressMutationHookResult = ReturnType<typeof useCreateOnePipelineProgressMutation>;
export type CreateOnePipelineProgressMutationResult = Apollo.MutationResult<CreateOnePipelineProgressMutation>;
export type CreateOnePipelineProgressMutationOptions = Apollo.BaseMutationOptions<CreateOnePipelineProgressMutation, CreateOnePipelineProgressMutationVariables>;
export const GetPeopleDocument = gql`
query GetPeople($orderBy: [PersonOrderByWithRelationInput!], $where: PersonWhereInput, $limit: Int) {
people: findManyPerson(orderBy: $orderBy, where: $where, take: $limit) {

View File

@ -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<Column, 'itemKeys'>[];
initialBoard: Column[];
items: Items;
onUpdate?: (itemKey: BoardItemKey, columnId: Column['id']) => Promise<void>;
onClickNew?: (
columnId: Column['id'],
newItem: Partial<Item> & { id: string },
) => void;
};
export const Board = ({ initialBoard, items, onUpdate }: BoardProps) => {
export const Board = ({
columns,
initialBoard,
items,
onUpdate,
onClickNew,
}: BoardProps) => {
const [board, setBoard] = useState<Column[]>(initialBoard);
const onClickFunctions = useMemo<
Record<Column['id'], (newItem: Partial<Item> & { id: string }) => void>
>(() => {
return board.reduce((acc, column) => {
acc[column.id] = (newItem: Partial<Item> & { id: string }) => {
onClickNew && onClickNew(column.id, newItem);
};
return acc;
}, {} as Record<Column['id'], (newItem: Partial<Item> & { 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 (
<StyledBoard>
<DragDropContext onDragEnd={onDragEnd}>
{board.map((column) => (
{columns.map((column, columnIndex) => (
<Droppable key={column.id} droppableId={column.id}>
{(droppableProvided) => (
<StyledColumn>
<StyledColumnTitle color={column.colorCode}>
{column.title}
</StyledColumnTitle>
<ItemsContainer droppableProvided={droppableProvided}>
{column.itemKeys.map((itemKey, index) => (
<Draggable
key={itemKey}
draggableId={itemKey}
index={index}
>
{(draggableProvided) => (
<BoardItem draggableProvided={draggableProvided}>
<BoardCard item={items[itemKey]} />
</BoardItem>
)}
</Draggable>
))}
</ItemsContainer>
<NewButton />
<ScrollableColumn>
<ItemsContainer droppableProvided={droppableProvided}>
{board[columnIndex].itemKeys.map((itemKey, index) => (
<Draggable
key={itemKey}
draggableId={itemKey}
index={index}
>
{(draggableProvided) => (
<BoardItem draggableProvided={draggableProvided}>
<BoardCard item={items[itemKey]} />
</BoardItem>
)}
</Draggable>
))}
</ItemsContainer>
<NewButton onClick={onClickFunctions[column.id]} />
</ScrollableColumn>
</StyledColumn>
)}
</Droppable>

View File

@ -16,7 +16,7 @@ type Story = StoryObj<typeof Board>;
export const OneColumnBoard: Story = {
render: () => (
<StrictMode>
<Board initialBoard={initialBoard} items={items} />
<Board columns={initialBoard} initialBoard={initialBoard} items={items} />
</StrictMode>
),
};

View File

@ -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,
};
};

View File

@ -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
}
}
`;

View File

@ -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;

View File

@ -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}
<StyledPlaceholder>{droppableProvided?.placeholder}</StyledPlaceholder>
</StyledItemContainer>
);
};

View File

@ -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 (
<StyledButton>
<StyledButton onClick={onInnerClick}>
<IconPlus size={theme.iconSizeMedium} />
New
</StyledButton>

View File

@ -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<PipelineProgress['progressableId']>,
pipelineProgressId: NonNullable<PipelineProgress['id']>,
pipelineStageId: NonNullable<PipelineStage['id']>,
) => {
const pipelineProgressId = pipelineEntityIdsMapper(entityId);
updatePipelineProgress({
variables: { id: pipelineProgressId, pipelineStageId },
});
},
[updatePipelineProgress, pipelineEntityIdsMapper],
[updatePipelineProgress],
);
const onClickNew = useCallback(
(
columnId: PipelineStage['id'],
newItem: Partial<PipelineProgress> & { 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 <div>Loading...</div>;
if (error) return <div>Error...</div>;
if (!initialBoard || !items)
if (!initialBoard || !items) {
return <div>Initial board or items not found</div>;
}
return (
<WithTopBarContainer title="Opportunities" icon={<IconTargetArrow />}>
<Board initialBoard={initialBoard} items={items} onUpdate={onUpdate} />
<WithTopBarContainer
title="Opportunities"
icon={<IconTargetArrow size={theme.iconSizeMedium} />}
>
<Board
columns={columns || []}
initialBoard={initialBoard}
items={items}
onUpdate={onUpdate}
onClickNew={onClickNew}
/>
</WithTopBarContainer>
);
}

View File

@ -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',
},
});