Refacto board (#661)

* Refacto pipeline progress board to be entity agnostic

* Abstract hooks as well

* Move files

* Pass specific components as props

* Move board hook to the generic component

* Make dnd and update logic part of the board

* Remove useless call and getch pipelineProgress from hook

* Minot

* improve typing

* Revert "improve typing"

This reverts commit 49bf7929b6231747cc460cbb98f68c3c10424659.

* wip

* Get board from initial component

* Move files again

* Lint

* Fix story

* Lint

* Mock pipeline progress

* Fix storybook

* WIP refactor recoil

* Checkpoint: compilation

* Fix dnd

* Fix unselect card

* Checkpoint: compilation

* Checkpoint: New card OK

* Checkpoint: feature complete

* Fix latency for delete

* Linter

* Fix rebase

* Move files

* lint

* Update Stories tests

* lint

* Fix test

* Refactor hook for company progress indexing

* Remove useless type

* Move boardState

* remove gardcoded Id

* Nit

* Fix

* Rename state
This commit is contained in:
Emilien Chauvet
2023-07-14 17:51:16 -07:00
committed by GitHub
parent e93a96b3b1
commit 0a319bcf86
47 changed files with 975 additions and 730 deletions

View File

@ -3366,7 +3366,14 @@ export type GetPipelinesQueryVariables = Exact<{
}>;
export type GetPipelinesQuery = { __typename?: 'Query', findManyPipeline: Array<{ __typename?: 'Pipeline', id: string, name: string, pipelineProgressableType: PipelineProgressableType, pipelineStages?: Array<{ __typename?: 'PipelineStage', id: string, name: string, color: string, index?: number | null, pipelineProgresses?: Array<{ __typename?: 'PipelineProgress', id: string, progressableType: PipelineProgressableType, progressableId: string, amount?: number | null, closeDate?: string | null }> | null }> | null }> };
export type GetPipelinesQuery = { __typename?: 'Query', findManyPipeline: Array<{ __typename?: 'Pipeline', id: string, name: string, pipelineProgressableType: PipelineProgressableType, pipelineStages?: Array<{ __typename?: 'PipelineStage', id: string, name: string, color: string, index?: number | null, pipelineProgresses?: Array<{ __typename?: 'PipelineProgress', id: string }> | null }> | null }> };
export type GetPipelineProgressQueryVariables = Exact<{
where?: InputMaybe<PipelineProgressWhereInput>;
}>;
export type GetPipelineProgressQuery = { __typename?: 'Query', findManyPipelineProgress: Array<{ __typename?: 'PipelineProgress', id: string, progressableType: PipelineProgressableType, progressableId: string, amount?: number | null, closeDate?: string | null }> };
export type UpdateOnePipelineProgressMutationVariables = Exact<{
id?: InputMaybe<Scalars['String']>;
@ -4959,10 +4966,6 @@ export const GetPipelinesDocument = gql`
index
pipelineProgresses {
id
progressableType
progressableId
amount
closeDate
}
}
}
@ -4996,6 +4999,45 @@ export function useGetPipelinesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptio
export type GetPipelinesQueryHookResult = ReturnType<typeof useGetPipelinesQuery>;
export type GetPipelinesLazyQueryHookResult = ReturnType<typeof useGetPipelinesLazyQuery>;
export type GetPipelinesQueryResult = Apollo.QueryResult<GetPipelinesQuery, GetPipelinesQueryVariables>;
export const GetPipelineProgressDocument = gql`
query GetPipelineProgress($where: PipelineProgressWhereInput) {
findManyPipelineProgress(where: $where) {
id
progressableType
progressableId
amount
closeDate
}
}
`;
/**
* __useGetPipelineProgressQuery__
*
* To run a query within a React component, call `useGetPipelineProgressQuery` and pass it any options that fit your needs.
* When your component renders, `useGetPipelineProgressQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetPipelineProgressQuery({
* variables: {
* where: // value for 'where'
* },
* });
*/
export function useGetPipelineProgressQuery(baseOptions?: Apollo.QueryHookOptions<GetPipelineProgressQuery, GetPipelineProgressQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetPipelineProgressQuery, GetPipelineProgressQueryVariables>(GetPipelineProgressDocument, options);
}
export function useGetPipelineProgressLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetPipelineProgressQuery, GetPipelineProgressQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetPipelineProgressQuery, GetPipelineProgressQueryVariables>(GetPipelineProgressDocument, options);
}
export type GetPipelineProgressQueryHookResult = ReturnType<typeof useGetPipelineProgressQuery>;
export type GetPipelineProgressLazyQueryHookResult = ReturnType<typeof useGetPipelineProgressLazyQuery>;
export type GetPipelineProgressQueryResult = Apollo.QueryResult<GetPipelineProgressQuery, GetPipelineProgressQueryVariables>;
export const UpdateOnePipelineProgressDocument = gql`
mutation UpdateOnePipelineProgress($id: String, $amount: Int, $closeDate: DateTime) {
updateOnePipelineProgress(

View File

@ -0,0 +1,25 @@
import { Meta, StoryObj } from '@storybook/react';
import { companyBoardOptions } from '@/companies/components/companyBoardOptions';
import { EntityBoard } from '@/pipeline-progress/components/EntityBoard';
import { BoardDecorator } from '~/testing/decorators';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
const meta: Meta<typeof EntityBoard> = {
title: 'UI/Board/Board',
component: EntityBoard,
decorators: [BoardDecorator],
};
export default meta;
type Story = StoryObj<typeof EntityBoard>;
export const OneColumnBoard: Story = {
render: getRenderWrapperForComponent(
<EntityBoard boardOptions={companyBoardOptions} />,
),
parameters: {
msw: graphqlMocks,
},
};

View File

@ -0,0 +1,26 @@
import { Meta, StoryObj } from '@storybook/react';
import { CompanyBoardCard } from '@/companies/components/CompanyBoardCard';
import { BoardCardDecorator } from '~/testing/decorators';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
const meta: Meta<typeof CompanyBoardCard> = {
title: 'UI/Board/CompanyBoardCard',
component: CompanyBoardCard,
decorators: [BoardCardDecorator],
};
export default meta;
type Story = StoryObj<typeof CompanyBoardCard>;
const FakeSelectableCompanyBoardCard = () => {
return <CompanyBoardCard />;
};
export const CompanyCompanyBoardCard: Story = {
render: getRenderWrapperForComponent(<FakeSelectableCompanyBoardCard />),
parameters: {
msw: graphqlMocks,
},
};

View File

@ -4,7 +4,7 @@ import type { Meta, StoryObj } from '@storybook/react';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { CompanyChip } from '../CompanyChip';
import { CompanyChip } from '../components/CompanyChip';
const meta: Meta<typeof CompanyChip> = {
title: 'Modules/Companies/CompanyChip',

View File

@ -0,0 +1,42 @@
import { Pipeline } from '~/generated/graphql';
export const pipeline = {
id: 'pipeline-1',
name: 'pipeline-1',
pipelineStages: [
{
id: 'pipeline-stage-1',
name: 'New',
index: 0,
color: '#B76796',
pipelineProgresses: [
{ id: 'fe256b39-3ec3-4fe7-8998-b76aa0bfb600' },
{ id: '4a886c90-f4f2-4984-8222-882ebbb905d6' },
],
},
{
id: 'pipeline-stage-2',
name: 'Screening',
index: 1,
color: '#CB912F',
},
{
id: 'pipeline-stage-3',
name: 'Meeting',
index: 2,
color: '#9065B0',
},
{
id: 'pipeline-stage-4',
name: 'Proposal',
index: 3,
color: '#337EA9',
},
{
id: 'pipeline-stage-5',
name: 'Customer',
index: 4,
color: '#079039',
},
],
} as Pipeline;

View File

@ -1,14 +1,25 @@
import { useCallback } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCurrencyDollar } from '@tabler/icons-react';
import { useRecoilState } from 'recoil';
import { GET_PIPELINES } from '@/pipeline-progress/queries';
import { BoardCardContext } from '@/pipeline-progress/states/BoardCardContext';
import { pipelineProgressIdScopedState } from '@/pipeline-progress/states/pipelineProgressIdScopedState';
import { selectedBoardCardsState } from '@/pipeline-progress/states/selectedBoardCardsState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { BoardCardEditableFieldDate } from '@/ui/board-card-field-inputs/components/BoardCardEditableFieldDate';
import { BoardCardEditableFieldText } from '@/ui/board-card-field-inputs/components/BoardCardEditableFieldText';
import { Company, PipelineProgress } from '../../../generated/graphql';
import { Checkbox } from '../../ui/components/form/Checkbox';
import { IconCalendarEvent } from '../../ui/icons';
import { getLogoUrlFromDomainName } from '../../utils/utils';
import { Checkbox } from '@/ui/components/form/Checkbox';
import { IconCalendarEvent } from '@/ui/icons';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import {
PipelineProgress,
useUpdateOnePipelineProgressMutation,
} from '~/generated/graphql';
import { companyProgressesFamilyState } from '~/pages/opportunities/companyProgressesFamilyState';
const StyledBoardCard = styled.div<{ selected: boolean }>`
background-color: ${({ theme, selected }) =>
@ -59,30 +70,58 @@ const StyledBoardCardBody = styled.div`
}
`;
type CompanyProp = Pick<
Company,
'id' | 'name' | 'domainName' | 'employees' | 'accountOwner'
>;
type PipelineProgressProp = Pick<
PipelineProgress,
'id' | 'amount' | 'closeDate'
>;
export function CompanyBoardCard({
company,
pipelineProgress,
selected,
onSelect,
onCardUpdate,
}: {
company: CompanyProp;
pipelineProgress: PipelineProgressProp;
selected: boolean;
onSelect: (company: CompanyProp) => void;
onCardUpdate: (pipelineProgress: PipelineProgressProp) => Promise<void>;
}) {
export function CompanyBoardCard() {
const theme = useTheme();
const [updatePipelineProgress] = useUpdateOnePipelineProgressMutation();
const [pipelineProgressId] = useRecoilScopedState(
pipelineProgressIdScopedState,
BoardCardContext,
);
const [companyProgress] = useRecoilState(
companyProgressesFamilyState(pipelineProgressId || ''),
);
const { pipelineProgress, company } = companyProgress || {};
const [selectedBoardCards, setSelectedBoardCards] = useRecoilState(
selectedBoardCardsState,
);
const selected = selectedBoardCards.includes(pipelineProgressId || '');
function setSelected(isSelected: boolean) {
if (isSelected) {
setSelectedBoardCards([...selectedBoardCards, pipelineProgressId || '']);
} else {
setSelectedBoardCards(
selectedBoardCards.filter((id) => id !== pipelineProgressId),
);
}
}
const handleCardUpdate = useCallback(
async (
pipelineProgress: Pick<PipelineProgress, 'id' | 'amount' | 'closeDate'>,
) => {
await updatePipelineProgress({
variables: {
id: pipelineProgress.id,
amount: pipelineProgress.amount,
closeDate: pipelineProgress.closeDate || null,
},
refetchQueries: [getOperationName(GET_PIPELINES) ?? ''],
});
},
[updatePipelineProgress],
);
const handleCheckboxChange = () => {
setSelected(!selected);
};
if (!company || !pipelineProgress) {
return null;
}
return (
<StyledBoardCardWrapper>
<StyledBoardCard selected={selected}>
@ -93,7 +132,9 @@ export function CompanyBoardCard({
/>
<span>{company.name}</span>
<div style={{ display: 'flex', flex: 1 }} />
<Checkbox checked={selected} onChange={() => onSelect(company)} />
<div onClick={handleCheckboxChange}>
<Checkbox checked={selected} />
</div>
</StyledBoardCardHeader>
<StyledBoardCardBody>
<span>
@ -102,7 +143,7 @@ export function CompanyBoardCard({
value={pipelineProgress.amount?.toString() || ''}
placeholder="Opportunity amount"
onChange={(value) =>
onCardUpdate({
handleCardUpdate({
...pipelineProgress,
amount: parseInt(value),
})
@ -114,7 +155,7 @@ export function CompanyBoardCard({
<BoardCardEditableFieldDate
value={new Date(pipelineProgress.closeDate || Date.now())}
onChange={(value) => {
onCardUpdate({
handleCardUpdate({
...pipelineProgress,
closeDate: value.toISOString(),
});

View File

@ -1,22 +1,13 @@
import { useCallback } from 'react';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import {
CommentableType,
Company,
useSearchCompanyQuery,
} from '~/generated/graphql';
import { CommentableType, useSearchCompanyQuery } from '~/generated/graphql';
type OwnProps = {
onEntitySelect: (
company: Pick<Company, 'id' | 'name' | 'domainName'>,
) => void;
onCancel: () => void;
};
export function NewCompanyBoardCard({ onEntitySelect, onCancel }: OwnProps) {
export function NewCompanyBoardCard() {
const [searchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
);
@ -37,10 +28,18 @@ export function NewCompanyBoardCard({ onEntitySelect, onCancel }: OwnProps) {
searchOnFields: ['name'],
});
const handleEntitySelect = useCallback(async (companyId: string) => {
return;
}, []);
function handleCancel() {
return;
}
return (
<SingleEntitySelect
onEntitySelected={(value) => onEntitySelect(value)}
onCancel={onCancel}
onEntitySelected={(value) => handleEntitySelect(value.id)}
onCancel={handleCancel}
entities={{
entitiesToSelect: companies.entitiesToSelect,
selectedEntity: companies.selectedEntities[0],

View File

@ -0,0 +1,135 @@
import { useCallback, useRef, useState } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { useRecoilState } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { usePreviousHotkeyScope } from '@/lib/hotkeys/hooks/usePreviousHotkeyScope';
import { GET_PIPELINES } from '@/pipeline-progress/queries';
import { BoardColumnContext } from '@/pipeline-progress/states/BoardColumnContext';
import { boardState } from '@/pipeline-progress/states/boardState';
import { pipelineStageIdScopedState } from '@/pipeline-progress/states/pipelineStageIdScopedState';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState';
import { RelationPickerHotkeyScope } from '@/relation-picker/types/RelationPickerHotkeyScope';
import { BoardPipelineStageColumn } from '@/ui/board/components/Board';
import { NewButton } from '@/ui/board/components/NewButton';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import {
CommentableType,
PipelineProgressableType,
useCreateOnePipelineProgressMutation,
useSearchCompanyQuery,
} from '~/generated/graphql';
import { currentPipelineState } from '~/pages/opportunities/currentPipelineState';
export function NewCompanyProgressButton() {
const containerRef = useRef<HTMLDivElement>(null);
const [isCreatingCard, setIsCreatingCard] = useState(false);
const [board, setBoard] = useRecoilState(boardState);
const [pipeline] = useRecoilState(currentPipelineState);
const [pipelineStageId] = useRecoilScopedState(
pipelineStageIdScopedState,
BoardColumnContext,
);
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const [createOnePipelineProgress] = useCreateOnePipelineProgressMutation({
refetchQueries: [getOperationName(GET_PIPELINES) ?? ''],
});
const handleEntitySelect = useCallback(
async (company: any) => {
if (!company) return;
setIsCreatingCard(false);
goBackToPreviousHotkeyScope();
const newUuid = uuidv4();
const newBoard = JSON.parse(JSON.stringify(board));
const destinationColumnIndex = newBoard.findIndex(
(column: BoardPipelineStageColumn) =>
column.pipelineStageId === pipelineStageId,
);
newBoard[destinationColumnIndex].pipelineProgressIds.push(newUuid);
setBoard(newBoard);
await createOnePipelineProgress({
variables: {
uuid: newUuid,
pipelineStageId: pipelineStageId || '',
pipelineId: pipeline?.id || '',
entityId: company.id || '',
entityType: PipelineProgressableType.Company,
},
});
},
[
goBackToPreviousHotkeyScope,
board,
setBoard,
createOnePipelineProgress,
pipelineStageId,
pipeline?.id,
],
);
const handleNewClick = useCallback(() => {
setIsCreatingCard(true);
setHotkeyScopeAndMemorizePreviousScope(
RelationPickerHotkeyScope.RelationPicker,
);
}, [setIsCreatingCard, setHotkeyScopeAndMemorizePreviousScope]);
function handleCancel() {
goBackToPreviousHotkeyScope();
setIsCreatingCard(false);
}
const [searchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
);
const companies = useFilteredSearchEntityQuery({
queryHook: useSearchCompanyQuery,
selectedIds: [],
searchFilter: searchFilter,
mappingFunction: (company) => ({
entityType: CommentableType.Company,
id: company.id,
name: company.name,
domainName: company.domainName,
avatarType: 'squared',
avatarUrl: getLogoUrlFromDomainName(company.domainName),
}),
orderByField: 'name',
searchOnFields: ['name'],
});
return (
<>
{isCreatingCard && (
<RecoilScope>
<div ref={containerRef}>
<div ref={containerRef}>
<SingleEntitySelect
onEntitySelected={(value) => handleEntitySelect(value)}
onCancel={handleCancel}
entities={{
entitiesToSelect: companies.entitiesToSelect,
selectedEntity: companies.selectedEntities[0],
loading: companies.loading,
}}
/>
</div>
</div>
</RecoilScope>
)}
<NewButton onClick={handleNewClick} />
</>
);
}

View File

@ -0,0 +1,9 @@
import { CompanyBoardCard } from '@/companies/components/CompanyBoardCard';
import { NewCompanyProgressButton } from '@/companies/components/NewCompanyProgressButton';
import { BoardOptions } from '../../pipeline-progress/types/BoardOptions';
export const companyBoardOptions: BoardOptions = {
newCardComponent: <NewCompanyProgressButton />,
cardComponent: <CompanyBoardCard />,
};

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const CompanyBoardContext = createContext<string | null>(null);

View File

@ -0,0 +1,11 @@
import { atomFamily } from 'recoil';
import { CompanyProgress } from '@/companies/types/CompanyProgress';
export const companyBoardIndexState = atomFamily<
CompanyProgress | undefined,
string
>({
key: 'currentPipelineState',
default: undefined,
});

View File

@ -0,0 +1,16 @@
import { Company, PipelineProgress } from '~/generated/graphql';
export type CompanyForBoard = Pick<Company, 'id' | 'name' | 'domainName'>;
export type PipelineProgressForBoard = Pick<
PipelineProgress,
'id' | 'amount' | 'closeDate' | 'progressableId'
>;
export type CompanyProgress = {
company: CompanyForBoard;
pipelineProgress: PipelineProgressForBoard;
};
export type CompanyProgressDict = {
[key: string]: CompanyProgress;
};

View File

@ -1,205 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import styled from '@emotion/styled';
import {
DragDropContext,
Draggable,
Droppable,
DroppableProvided,
OnDragEndResponder,
} 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
import { useRecoilState } from 'recoil';
import { BoardColumn } from '@/ui/board/components/BoardColumn';
import { Company, PipelineProgress } from '~/generated/graphql';
import {
Column,
getOptimisticlyUpdatedBoard,
StyledBoard,
} from '../../ui/board/components/Board';
import { boardColumnsState } from '../states/boardColumnsState';
import { boardItemsState } from '../states/boardItemsState';
import { selectedBoardItemsState } from '../states/selectedBoardItemsState';
import { CompanyBoardCard } from './CompanyBoardCard';
import { NewButton } from './NewButton';
export type CompanyProgress = {
company: Pick<Company, 'id' | 'name' | 'domainName'>;
pipelineProgress: Pick<PipelineProgress, 'id' | 'amount' | 'closeDate'>;
};
export type CompanyProgressDict = {
[key: string]: CompanyProgress;
};
type BoardProps = {
pipelineId: string;
columns: Omit<Column, 'itemKeys'>[];
initialBoard: Column[];
initialItems: CompanyProgressDict;
onCardMove?: (itemKey: string, columnId: Column['id']) => Promise<void>;
onCardUpdate: (
pipelineProgress: Pick<PipelineProgress, 'id' | 'amount' | 'closeDate'>,
) => Promise<void>;
};
const StyledPlaceholder = styled.div`
min-height: 1px;
`;
const BoardColumnCardsContainer = ({
children,
droppableProvided,
}: {
children: React.ReactNode;
droppableProvided: DroppableProvided;
}) => {
return (
<div
ref={droppableProvided?.innerRef}
{...droppableProvided?.droppableProps}
>
{children}
<StyledPlaceholder>{droppableProvided?.placeholder}</StyledPlaceholder>
</div>
);
};
export function Board({
columns,
initialBoard,
initialItems,
onCardMove,
onCardUpdate,
pipelineId,
}: BoardProps) {
const [board, setBoard] = useRecoilState(boardColumnsState);
const [boardItems, setBoardItems] = useRecoilState(boardItemsState);
const [selectedBoardItems, setSelectedBoardItems] = useRecoilState(
selectedBoardItemsState,
);
const [isInitialBoardLoaded, setIsInitialBoardLoaded] = useState(false);
useEffect(() => {
if (!isInitialBoardLoaded) {
setBoard(initialBoard);
}
if (Object.keys(initialItems).length > 0) {
setBoardItems(initialItems);
setIsInitialBoardLoaded(true);
}
}, [
initialBoard,
setBoard,
initialItems,
setBoardItems,
setIsInitialBoardLoaded,
isInitialBoardLoaded,
]);
const calculateColumnTotals = (
columns: Column[],
items: {
[key: string]: CompanyProgress;
},
): { [key: string]: number } => {
return columns.reduce<{ [key: string]: number }>((acc, column) => {
acc[column.id] = column.itemKeys.reduce(
(total: number, itemKey: string) => {
return total + (items[itemKey]?.pipelineProgress?.amount || 0);
},
0,
);
return acc;
}, {});
};
const columnTotals = useMemo(
() => calculateColumnTotals(board, boardItems),
[board, boardItems],
);
const onDragEnd: OnDragEndResponder = useCallback(
async (result) => {
const newBoard = getOptimisticlyUpdatedBoard(board, result);
if (!newBoard) return;
setBoard(newBoard);
try {
const draggedEntityId = result.draggableId;
const destinationColumnId = result.destination?.droppableId;
draggedEntityId &&
destinationColumnId &&
onCardMove &&
(await onCardMove(draggedEntityId, destinationColumnId));
} catch (e) {
console.error(e);
}
},
[board, onCardMove, setBoard],
);
function handleSelect(itemKey: string) {
if (selectedBoardItems.includes(itemKey)) {
setSelectedBoardItems(
selectedBoardItems.filter((key) => key !== itemKey),
);
} else {
setSelectedBoardItems([...selectedBoardItems, itemKey]);
}
}
return board.length > 0 ? (
<StyledBoard>
<DragDropContext onDragEnd={onDragEnd}>
{columns.map((column, columnIndex) => (
<Droppable key={column.id} droppableId={column.id}>
{(droppableProvided) => (
<BoardColumn
title={`${column.title} `}
amount={columnTotals[column.id]}
colorCode={column.colorCode}
>
<BoardColumnCardsContainer
droppableProvided={droppableProvided}
>
{board[columnIndex].itemKeys.map(
(itemKey, index) =>
boardItems[itemKey] && (
<Draggable
key={itemKey}
draggableId={itemKey}
index={index}
>
{(draggableProvided) => (
<div
ref={draggableProvided?.innerRef}
{...draggableProvided?.dragHandleProps}
{...draggableProvided?.draggableProps}
>
<CompanyBoardCard
company={boardItems[itemKey].company}
pipelineProgress={
boardItems[itemKey].pipelineProgress
}
selected={selectedBoardItems.includes(itemKey)}
onCardUpdate={onCardUpdate}
onSelect={() => handleSelect(itemKey)}
/>
</div>
)}
</Draggable>
),
)}
</BoardColumnCardsContainer>
<NewButton pipelineId={pipelineId} columnId={column.id} />
</BoardColumn>
)}
</Droppable>
))}
</DragDropContext>
</StyledBoard>
) : (
<></>
);
}

View File

@ -1,39 +1,41 @@
import { getOperationName } from '@apollo/client/utilities';
import { useRecoilState } from 'recoil';
import { boardState } from '@/pipeline-progress/states/boardState';
import { EntityTableActionBarButton } from '@/ui/components/table/action-bar/EntityTableActionBarButton';
import { IconTrash } from '@/ui/icons/index';
import { useDeleteManyPipelineProgressMutation } from '~/generated/graphql';
import { GET_PIPELINES } from '../queries';
import { boardItemsState } from '../states/boardItemsState';
import { selectedBoardItemsState } from '../states/selectedBoardItemsState';
import { selectedBoardCardsState } from '../states/selectedBoardCardsState';
export function BoardActionBarButtonDeletePipelineProgress() {
const [selectedBoardItems, setSelectedBoardItems] = useRecoilState(
selectedBoardItemsState,
selectedBoardCardsState,
);
const [boardItems, setBoardItems] = useRecoilState(boardItemsState);
const [board, setBoard] = useRecoilState(boardState);
const [deletePipelineProgress] = useDeleteManyPipelineProgressMutation({
refetchQueries: [getOperationName(GET_PIPELINES) ?? ''],
});
async function handleDeleteClick() {
setBoard(
board?.map((pipelineStage) => ({
...pipelineStage,
pipelineProgressIds: pipelineStage.pipelineProgressIds.filter(
(pipelineProgressId) =>
!selectedBoardItems.includes(pipelineProgressId),
),
})),
);
setSelectedBoardItems([]);
await deletePipelineProgress({
variables: {
ids: selectedBoardItems,
},
});
setBoardItems(
Object.fromEntries(
Object.entries(boardItems).filter(
([key]) => !selectedBoardItems.includes(key),
),
),
);
setSelectedBoardItems([]);
}
return (

View File

@ -0,0 +1,81 @@
import { useCallback } from 'react';
import { DragDropContext, OnDragEndResponder } 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
import { useRecoilState } from 'recoil';
import { boardState } from '@/pipeline-progress/states/boardState';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import {
PipelineProgress,
PipelineStage,
useUpdateOnePipelineProgressStageMutation,
} from '~/generated/graphql';
import {
getOptimisticlyUpdatedBoard,
StyledBoard,
} from '../../ui/board/components/Board';
import { BoardColumnContext } from '../states/BoardColumnContext';
import { BoardOptions } from '../types/BoardOptions';
import { EntityBoardColumn } from './EntityBoardColumn';
export function EntityBoard({ boardOptions }: { boardOptions: BoardOptions }) {
const [board, setBoard] = useRecoilState(boardState);
const [updatePipelineProgressStage] =
useUpdateOnePipelineProgressStageMutation();
const updatePipelineProgressStageInDB = useCallback(
async (
pipelineProgressId: NonNullable<PipelineProgress['id']>,
pipelineStageId: NonNullable<PipelineStage['id']>,
) => {
updatePipelineProgressStage({
variables: {
id: pipelineProgressId,
pipelineStageId,
},
});
},
[updatePipelineProgressStage],
);
const onDragEnd: OnDragEndResponder = useCallback(
async (result) => {
if (!board) return;
const newBoard = getOptimisticlyUpdatedBoard(board, result);
if (!newBoard) return;
setBoard(newBoard);
try {
const draggedEntityId = result.draggableId;
const destinationColumnId = result.destination?.droppableId;
draggedEntityId &&
destinationColumnId &&
updatePipelineProgressStageInDB &&
(await updatePipelineProgressStageInDB(
draggedEntityId,
destinationColumnId,
));
} catch (e) {
console.error(e);
}
},
[board, updatePipelineProgressStageInDB, setBoard],
);
return (board?.length ?? 0) > 0 ? (
<StyledBoard>
<DragDropContext onDragEnd={onDragEnd}>
{board?.map((column) => (
<RecoilScope
SpecificContext={BoardColumnContext}
key={column.pipelineStageId}
>
<EntityBoardColumn boardOptions={boardOptions} column={column} />
</RecoilScope>
))}
</DragDropContext>
</StyledBoard>
) : (
<></>
);
}

View File

@ -3,13 +3,13 @@ import { useRecoilValue } from 'recoil';
import { ActionBar } from '@/ui/components/action-bar/ActionBar';
import { selectedBoardItemsState } from '../states/selectedBoardItemsState';
import { selectedBoardCardsState } from '../states/selectedBoardCardsState';
type OwnProps = {
children: React.ReactNode | React.ReactNode[];
};
export function EntityBoardActionBar({ children }: OwnProps) {
const selectedBoardItems = useRecoilValue(selectedBoardItemsState);
return <ActionBar selectedIds={selectedBoardItems}>{children}</ActionBar>;
const selectedBoardCards = useRecoilValue(selectedBoardCardsState);
return <ActionBar selectedIds={selectedBoardCards}>{children}</ActionBar>;
}

View File

@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { Draggable } from '@hello-pangea/dnd';
import { BoardCardContext } from '@/pipeline-progress/states/BoardCardContext';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { pipelineProgressIdScopedState } from '../states/pipelineProgressIdScopedState';
import { BoardOptions } from '../types/BoardOptions';
export function EntityBoardCard({
boardOptions,
pipelineProgressId,
index,
}: {
boardOptions: BoardOptions;
pipelineProgressId: string;
index: number;
}) {
const [pipelineProgressIdFromRecoil, setPipelineProgressId] =
useRecoilScopedState(pipelineProgressIdScopedState, BoardCardContext);
useEffect(() => {
if (pipelineProgressIdFromRecoil !== pipelineProgressId) {
setPipelineProgressId(pipelineProgressId);
}
}, [pipelineProgressId, setPipelineProgressId, pipelineProgressIdFromRecoil]);
return (
<Draggable
key={pipelineProgressId}
draggableId={pipelineProgressId}
index={index}
>
{(draggableProvided) => (
<div
ref={draggableProvided?.innerRef}
{...draggableProvided?.dragHandleProps}
{...draggableProvided?.draggableProps}
>
{boardOptions.cardComponent}
</div>
)}
</Draggable>
);
}

View File

@ -0,0 +1,80 @@
import { useEffect } from 'react';
import styled from '@emotion/styled';
import { Droppable, DroppableProvided } from '@hello-pangea/dnd';
import { BoardCardContext } from '@/pipeline-progress/states/BoardCardContext';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { BoardPipelineStageColumn } from '@/ui/board/components/Board';
import { BoardColumn } from '@/ui/board/components/BoardColumn';
import { BoardColumnContext } from '../states/BoardColumnContext';
import { pipelineStageIdScopedState } from '../states/pipelineStageIdScopedState';
import { BoardOptions } from '../types/BoardOptions';
import { EntityBoardCard } from './EntityBoardCard';
const StyledPlaceholder = styled.div`
min-height: 1px;
`;
const BoardColumnCardsContainer = ({
children,
droppableProvided,
}: {
children: React.ReactNode;
droppableProvided: DroppableProvided;
}) => {
return (
<div
ref={droppableProvided?.innerRef}
{...droppableProvided?.droppableProps}
>
{children}
<StyledPlaceholder>{droppableProvided?.placeholder}</StyledPlaceholder>
</div>
);
};
export function EntityBoardColumn({
column,
boardOptions,
}: {
column: BoardPipelineStageColumn;
boardOptions: BoardOptions;
}) {
const [pipelineStageId, setPipelineStageId] = useRecoilScopedState(
pipelineStageIdScopedState,
BoardColumnContext,
);
useEffect(() => {
if (pipelineStageId !== column.pipelineStageId) {
setPipelineStageId(column.pipelineStageId);
}
}, [column, setPipelineStageId, pipelineStageId]);
return (
<Droppable droppableId={column.pipelineStageId}>
{(droppableProvided) => (
<BoardColumn title={`${column.title} `} colorCode={column.colorCode}>
<BoardColumnCardsContainer droppableProvided={droppableProvided}>
{column.pipelineProgressIds.map((pipelineProgressId, index) => (
<RecoilScope
SpecificContext={BoardCardContext}
key={pipelineProgressId}
>
<EntityBoardCard
index={index}
pipelineProgressId={pipelineProgressId}
boardOptions={boardOptions}
/>
</RecoilScope>
))}
</BoardColumnCardsContainer>
<RecoilScope>{boardOptions.newCardComponent}</RecoilScope>
</BoardColumn>
)}
</Droppable>
);
}

View File

@ -1,116 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { useRecoilState } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { usePreviousHotkeyScope } from '@/lib/hotkeys/hooks/usePreviousHotkeyScope';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { RelationPickerHotkeyScope } from '@/relation-picker/types/RelationPickerHotkeyScope';
import { Column } from '@/ui/board/components/Board';
import { NewButton as UINewButton } from '@/ui/board/components/NewButton';
import {
Company,
PipelineProgressableType,
useCreateOnePipelineProgressMutation,
} from '~/generated/graphql';
import { GET_PIPELINES } from '../queries';
import { boardColumnsState } from '../states/boardColumnsState';
import { boardItemsState } from '../states/boardItemsState';
import { NewCompanyBoardCard } from './NewCompanyBoardCard';
type OwnProps = {
pipelineId: string;
columnId: string;
};
export function NewButton({ pipelineId, columnId }: OwnProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isCreatingCard, setIsCreatingCard] = useState(false);
const [board, setBoard] = useRecoilState(boardColumnsState);
const [boardItems, setBoardItems] = useRecoilState(boardItemsState);
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const [createOnePipelineProgress] = useCreateOnePipelineProgressMutation({
refetchQueries: [getOperationName(GET_PIPELINES) ?? ''],
});
const handleEntitySelect = useCallback(
async (company: Pick<Company, 'id' | 'name' | 'domainName'>) => {
if (!company) return;
setIsCreatingCard(false);
goBackToPreviousHotkeyScope();
const newUuid = uuidv4();
const newBoard = JSON.parse(JSON.stringify(board));
const destinationColumnIndex = newBoard.findIndex(
(column: Column) => column.id === columnId,
);
newBoard[destinationColumnIndex].itemKeys.push(newUuid);
setBoardItems({
...boardItems,
[newUuid]: {
company,
pipelineProgress: {
id: newUuid,
amount: 0,
},
},
});
setBoard(newBoard);
await createOnePipelineProgress({
variables: {
uuid: newUuid,
pipelineStageId: columnId,
pipelineId,
entityId: company.id,
entityType: PipelineProgressableType.Company,
},
});
},
[
createOnePipelineProgress,
columnId,
pipelineId,
board,
setBoard,
boardItems,
setBoardItems,
goBackToPreviousHotkeyScope,
],
);
const handleNewClick = useCallback(() => {
setIsCreatingCard(true);
setHotkeyScopeAndMemorizePreviousScope(
RelationPickerHotkeyScope.RelationPicker,
);
}, [setIsCreatingCard, setHotkeyScopeAndMemorizePreviousScope]);
function handleCancel() {
goBackToPreviousHotkeyScope();
setIsCreatingCard(false);
}
return (
<>
{isCreatingCard && (
<RecoilScope>
<div ref={containerRef}>
<NewCompanyBoardCard
onEntitySelect={handleEntitySelect}
onCancel={handleCancel}
/>
</div>
</RecoilScope>
)}
<UINewButton onClick={handleNewClick} />
</>
);
}

View File

@ -1,27 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { Board } from '../Board';
import { initialBoard, items } from './mock-data';
const meta: Meta<typeof Board> = {
title: 'UI/Board/Board',
component: Board,
};
export default meta;
type Story = StoryObj<typeof Board>;
export const OneColumnBoard: Story = {
render: getRenderWrapperForComponent(
<Board
pipelineId={'xxx-test'}
columns={initialBoard}
initialBoard={initialBoard}
initialItems={items}
onCardUpdate={async (_) => {}} // eslint-disable-line @typescript-eslint/no-empty-function
/>,
),
};

View File

@ -1,35 +0,0 @@
import { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { Company } from '../../../../generated/graphql';
import { mockedCompaniesData } from '../../../../testing/mock-data/companies';
import { mockedPipelineProgressData } from '../../../../testing/mock-data/pipeline-progress';
import { CompanyBoardCard } from '../CompanyBoardCard';
const meta: Meta<typeof CompanyBoardCard> = {
title: 'UI/Board/CompanyBoardCard',
component: CompanyBoardCard,
};
export default meta;
type Story = StoryObj<typeof CompanyBoardCard>;
const FakeSelectableCompanyBoardCard = () => {
const [selected, setSelected] = useState<boolean>(false);
return (
<CompanyBoardCard
company={mockedCompaniesData[0] as Company}
pipelineProgress={mockedPipelineProgressData[0]}
selected={selected}
onSelect={() => setSelected(!selected)}
onCardUpdate={async (_) => {}} // eslint-disable-line @typescript-eslint/no-empty-function
/>
);
};
export const CompanyCompanyBoardCard: Story = {
render: getRenderWrapperForComponent(<FakeSelectableCompanyBoardCard />),
};

View File

@ -1,64 +0,0 @@
import { Column } from '@/ui/board/components/Board';
import { mockedCompaniesData } from '~/testing/mock-data/companies';
import { CompanyProgressDict } from '../Board';
export const items: CompanyProgressDict = {
'item-1': {
company: mockedCompaniesData[0],
pipelineProgress: { id: '0', amount: 1 },
},
'item-2': {
company: mockedCompaniesData[1],
pipelineProgress: { id: '1', amount: 1 },
},
'item-3': {
company: mockedCompaniesData[2],
pipelineProgress: { id: '2', amount: 1 },
},
'item-4': {
company: mockedCompaniesData[3],
pipelineProgress: { id: '3', amount: 1 },
},
};
export const initialBoard = [
{
id: 'column-1',
title: 'New',
colorCode: '#B76796',
itemKeys: [
'item-1',
'item-2',
'item-3',
'item-4',
'item-7',
'item-8',
'item-9',
],
},
{
id: 'column-2',
title: 'Screening',
colorCode: '#CB912F',
itemKeys: ['item-5', 'item-6'],
},
{
id: 'column-3',
colorCode: '#9065B0',
title: 'Meeting',
itemKeys: [],
},
{
id: 'column-4',
title: 'Proposal',
colorCode: '#337EA9',
itemKeys: [],
},
{
id: 'column-5',
colorCode: '#079039',
title: 'Customer',
itemKeys: [],
},
] satisfies Column[];

View File

@ -1,89 +0,0 @@
import {
Company,
PipelineProgress,
useGetCompaniesQuery,
useGetPipelinesQuery,
} from '../../../generated/graphql';
import { Column } from '../../ui/board/components/Board';
type ItemCompany = Pick<Company, 'id' | 'name' | 'domainName'>;
type ItemPipelineProgress = Pick<
PipelineProgress,
'id' | 'amount' | 'progressableId'
>;
type Item = {
company: ItemCompany;
pipelineProgress: ItemPipelineProgress;
};
type Items = { [key: string]: Item };
export function useBoard(pipelineId: string) {
const pipelines = useGetPipelinesQuery({
variables: { where: { id: { equals: pipelineId } } },
skip: pipelineId === '',
});
const pipelineStages = pipelines.data?.findManyPipeline[0]?.pipelineStages;
const orderedPipelineStages = pipelineStages
? [...pipelineStages].sort((a, b) => {
if (!a.index || !b.index) return 0;
return a.index - b.index;
})
: [];
const initialBoard: Column[] =
orderedPipelineStages?.map((pipelineStage) => ({
id: pipelineStage.id,
title: pipelineStage.name,
colorCode: pipelineStage.color,
itemKeys:
pipelineStage.pipelineProgresses?.map((item) => item.id as string) ||
[],
})) || [];
const pipelineProgresses = orderedPipelineStages?.reduce(
(acc, pipelineStage) => [
...acc,
...(pipelineStage.pipelineProgresses || []),
],
[] as ItemPipelineProgress[],
);
const entitiesQueryResult = useGetCompaniesQuery({
variables: {
where: {
id: { in: pipelineProgresses?.map((item) => item.progressableId) },
},
},
});
const indexCompanyByIdReducer = (
acc: { [key: string]: ItemCompany },
entity: ItemCompany,
) => ({
...acc,
[entity.id]: entity,
});
const companiesDict = entitiesQueryResult.data?.companies.reduce(
indexCompanyByIdReducer,
{} as { [key: string]: ItemCompany },
);
const items = pipelineProgresses?.reduce((acc, pipelineProgress) => {
if (companiesDict?.[pipelineProgress.progressableId]) {
acc[pipelineProgress.id] = {
pipelineProgress,
company: companiesDict[pipelineProgress.progressableId],
};
}
return acc;
}, {} as Items);
return {
initialBoard,
items,
loading: pipelines.loading || entitiesQueryResult.loading,
error: pipelines.error || entitiesQueryResult.error,
};
}

View File

@ -13,16 +13,24 @@ export const GET_PIPELINES = gql`
index
pipelineProgresses {
id
progressableType
progressableId
amount
closeDate
}
}
}
}
`;
export const GET_PIPELINE_PROGRESS = gql`
query GetPipelineProgress($where: PipelineProgressWhereInput) {
findManyPipelineProgress(where: $where) {
id
progressableType
progressableId
amount
closeDate
}
}
`;
export const UPDATE_PIPELINE_PROGRESS = gql`
mutation UpdateOnePipelineProgress(
$id: String

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const BoardCardContext = createContext<string | null>(null);

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const BoardColumnContext = createContext<string | null>(null);

View File

@ -1,8 +1,8 @@
import { atom } from 'recoil';
import { Column } from '@/ui/board/components/Board';
import { BoardPipelineStageColumn } from '@/ui/board/components/Board';
export const boardColumnsState = atom<Column[]>({
export const boardColumnsState = atom<BoardPipelineStageColumn[]>({
key: 'boardColumnsState',
default: [],
});

View File

@ -1,8 +0,0 @@
import { atom } from 'recoil';
import { CompanyProgressDict } from '../components/Board';
export const boardItemsState = atom<CompanyProgressDict>({
key: 'boardItemsState',
default: {},
});

View File

@ -0,0 +1,8 @@
import { atom } from 'recoil';
import { BoardPipelineStageColumn } from '@/ui/board/components/Board';
export const boardState = atom<BoardPipelineStageColumn[] | undefined>({
key: 'boardState',
default: undefined,
});

View File

@ -0,0 +1,8 @@
import { atom } from 'recoil';
import { Pipeline } from '~/generated/graphql';
export const currentPipelineState = atom<Pipeline | undefined>({
key: 'currentPipelineState',
default: undefined,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isBoardLoadedState = atom<boolean>({
key: 'isBoardLoadedState',
default: false,
});

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const pipelineProgressIdScopedState = atomFamily<string | null, string>({
key: 'pipelineProgressIdScopedState',
default: null,
});

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const pipelineStageIdScopedState = atomFamily<string | null, string>({
key: 'pipelineStageIdScopedState',
default: null,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const selectedBoardCardsState = atom<string[]>({
key: 'isBoardCardSelectedFamilyState',
default: [],
});

View File

@ -1,6 +0,0 @@
import { atom } from 'recoil';
export const selectedBoardItemsState = atom<string[]>({
key: 'selectedBoardItemsState',
default: [],
});

View File

@ -0,0 +1,4 @@
export type BoardOptions = {
newCardComponent: React.ReactNode;
cardComponent: React.ReactNode;
};

View File

@ -10,43 +10,46 @@ export const StyledBoard = styled.div`
width: 100%;
`;
export type Column = {
id: string;
export type BoardPipelineStageColumn = {
pipelineStageId: string;
title: string;
colorCode?: string;
itemKeys: string[];
pipelineProgressIds: string[];
};
export function getOptimisticlyUpdatedBoard(
board: Column[],
board: BoardPipelineStageColumn[],
result: DropResult,
) {
// TODO: review any types
const newBoard = JSON.parse(JSON.stringify(board));
const { destination, source } = result;
if (!destination) return;
const sourceColumnIndex = newBoard.findIndex(
(column: Column) => column.id === source.droppableId,
(column: BoardPipelineStageColumn) =>
column.pipelineStageId === source.droppableId,
);
const sourceColumn = newBoard[sourceColumnIndex];
const destinationColumnIndex = newBoard.findIndex(
(column: Column) => column.id === destination.droppableId,
(column: BoardPipelineStageColumn) =>
column.pipelineStageId === destination.droppableId,
);
const destinationColumn = newBoard[destinationColumnIndex];
if (!destinationColumn || !sourceColumn) return;
const sourceItems = sourceColumn.itemKeys;
const destinationItems = destinationColumn.itemKeys;
const sourceItems = sourceColumn.pipelineProgressIds;
const destinationItems = destinationColumn.pipelineProgressIds;
const [removed] = sourceItems.splice(source.index, 1);
destinationItems.splice(destination.index, 0, removed);
const newSourceColumn = {
const newSourceColumn: BoardPipelineStageColumn = {
...sourceColumn,
itemKeys: sourceItems,
pipelineProgressIds: sourceItems,
};
const newDestinationColumn = {
...destinationColumn,
itemKeys: destinationItems,
pipelineProgressIds: destinationItems,
};
newBoard.splice(sourceColumnIndex, 1, newSourceColumn);

View File

@ -33,16 +33,14 @@ export const StyledAmount = styled.div`
type OwnProps = {
colorCode?: string;
title: string;
amount: number;
children: React.ReactNode;
};
export function BoardColumn({ colorCode, title, amount, children }: OwnProps) {
export function BoardColumn({ colorCode, title, children }: OwnProps) {
return (
<StyledColumn>
<StyledHeader>
<StyledColumnTitle color={colorCode}> {title}</StyledColumnTitle>
{!!amount && <StyledAmount>${amount}</StyledAmount>}
</StyledHeader>
{children}
</StyledColumn>

View File

@ -25,12 +25,14 @@ describe('getOptimisticlyUpdatedBoard', () => {
{
id: 'column-1',
title: 'My Column',
itemKeys: initialColumn1,
pipelineStageId: 'column-1',
pipelineProgressIds: initialColumn1,
},
{
id: 'column-2',
title: 'My Column',
itemKeys: initialColumn2,
pipelineStageId: 'column-2',
pipelineProgressIds: initialColumn2,
},
];
@ -40,12 +42,14 @@ describe('getOptimisticlyUpdatedBoard', () => {
{
id: 'column-1',
title: 'My Column',
itemKeys: finalColumn1,
pipelineStageId: 'column-1',
pipelineProgressIds: finalColumn1,
},
{
id: 'column-2',
title: 'My Column',
itemKeys: finalColumn2,
pipelineStageId: 'column-2',
pipelineProgressIds: finalColumn2,
},
];

View File

@ -0,0 +1,138 @@
import { useEffect } from 'react';
import { useRecoilCallback, useRecoilState } from 'recoil';
import {
CompanyForBoard,
CompanyProgress,
PipelineProgressForBoard,
} from '@/companies/types/CompanyProgress';
import { BoardPipelineStageColumn } from '@/ui/board/components/Board';
import {
Pipeline,
PipelineStage,
useGetCompaniesQuery,
useGetPipelineProgressQuery,
useGetPipelinesQuery,
} from '~/generated/graphql';
import { boardState } from '../../modules/pipeline-progress/states/boardState';
import { companyProgressesFamilyState } from './companyProgressesFamilyState';
import { currentPipelineState } from './currentPipelineState';
import { isBoardLoadedState } from './isBoardLoadedState';
export function HookCompanyBoard() {
const [currentPipeline, setCurrentPipeline] =
useRecoilState(currentPipelineState);
const [, setBoard] = useRecoilState(boardState);
const [, setIsBoardLoaded] = useRecoilState(isBoardLoadedState);
useGetPipelinesQuery({
onCompleted: async (data) => {
const pipeline = data?.findManyPipeline[0] as Pipeline;
setCurrentPipeline(pipeline);
const pipelineStages = pipeline?.pipelineStages;
const orderedPipelineStages = pipelineStages
? [...pipelineStages].sort((a, b) => {
if (!a.index || !b.index) return 0;
return a.index - b.index;
})
: [];
const initialBoard: BoardPipelineStageColumn[] =
orderedPipelineStages?.map((pipelineStage) => ({
pipelineStageId: pipelineStage.id,
title: pipelineStage.name,
colorCode: pipelineStage.color,
pipelineProgressIds:
pipelineStage.pipelineProgresses?.map(
(item) => item.id as string,
) || [],
})) || [];
setBoard(initialBoard);
setIsBoardLoaded(true);
},
});
const pipelineProgressIds = currentPipeline?.pipelineStages
?.map((pipelineStage: PipelineStage) =>
(
pipelineStage.pipelineProgresses?.map((item) => item.id as string) || []
).flat(),
)
.flat();
const pipelineProgressesQuery = useGetPipelineProgressQuery({
variables: {
where: {
id: { in: pipelineProgressIds },
},
},
});
const pipelineProgresses =
pipelineProgressesQuery.data?.findManyPipelineProgress || [];
const entitiesQueryResult = useGetCompaniesQuery({
variables: {
where: {
id: {
in: pipelineProgresses.map((item) => item.progressableId),
},
},
},
});
const indexCompanyByIdReducer = (
acc: { [key: string]: CompanyForBoard },
company: CompanyForBoard,
) => ({
...acc,
[company.id]: company,
});
const companiesDict =
entitiesQueryResult.data?.companies.reduce(
indexCompanyByIdReducer,
{} as { [key: string]: CompanyForBoard },
) || {};
const indexPipelineProgressByIdReducer = (
acc: {
[key: string]: CompanyProgress;
},
pipelineProgress: PipelineProgressForBoard,
) => {
const company = companiesDict[pipelineProgress.progressableId];
return {
...acc,
[pipelineProgress.id]: {
pipelineProgress,
company,
},
};
};
const companyBoardIndex = pipelineProgresses.reduce(
indexPipelineProgressByIdReducer,
{} as { [key: string]: CompanyProgress },
);
const synchronizeCompanyProgresses = useRecoilCallback(
({ set }) =>
(companyBoardIndex: { [key: string]: CompanyProgress }) => {
Object.entries(companyBoardIndex).forEach(([id, companyProgress]) => {
set(companyProgressesFamilyState(id), companyProgress);
});
},
[],
);
const loading =
entitiesQueryResult.loading || pipelineProgressesQuery.loading;
useEffect(() => {
!loading && synchronizeCompanyProgresses(companyBoardIndex);
}, [loading, companyBoardIndex, synchronizeCompanyProgresses]);
return <></>;
}

View File

@ -1,97 +1,31 @@
import { useCallback, useMemo } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { useTheme } from '@emotion/react';
import { companyBoardOptions } from '@/companies/components/companyBoardOptions';
import { CompanyBoardContext } from '@/companies/states/CompanyBoardContext';
import { BoardActionBarButtonDeletePipelineProgress } from '@/pipeline-progress/components/BoardActionBarButtonDeletePipelineProgress';
import { EntityBoard } from '@/pipeline-progress/components/EntityBoard';
import { EntityBoardActionBar } from '@/pipeline-progress/components/EntityBoardActionBar';
import { GET_PIPELINES } from '@/pipeline-progress/queries';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { IconTargetArrow } from '@/ui/icons/index';
import { WithTopBarContainer } from '@/ui/layout/containers/WithTopBarContainer';
import {
PipelineProgress,
PipelineStage,
useGetPipelinesQuery,
useUpdateOnePipelineProgressMutation,
useUpdateOnePipelineProgressStageMutation,
} from '../../generated/graphql';
import { Board } from '../../modules/pipeline-progress/components/Board';
import { useBoard } from '../../modules/pipeline-progress/hooks/useBoard';
import { HookCompanyBoard } from './HookCompanyBoard';
export function Opportunities() {
const theme = useTheme();
const pipelines = useGetPipelinesQuery();
const pipelineId = pipelines.data?.findManyPipeline[0]?.id;
const { initialBoard, items } = useBoard(pipelineId || '');
const columns = useMemo(
() =>
initialBoard?.map(({ id, colorCode, title }) => ({
id,
colorCode,
title,
})),
[initialBoard],
);
const [updatePipelineProgress] = useUpdateOnePipelineProgressMutation();
const [updatePipelineProgressStage] =
useUpdateOnePipelineProgressStageMutation();
const handleCardUpdate = useCallback(
async (
pipelineProgress: Pick<PipelineProgress, 'id' | 'amount' | 'closeDate'>,
) => {
await updatePipelineProgress({
variables: {
id: pipelineProgress.id,
amount: pipelineProgress.amount,
closeDate: pipelineProgress.closeDate || null,
},
refetchQueries: [getOperationName(GET_PIPELINES) ?? ''],
});
},
[updatePipelineProgress],
);
const handleCardMove = useCallback(
async (
pipelineProgressId: NonNullable<PipelineProgress['id']>,
pipelineStageId: NonNullable<PipelineStage['id']>,
) => {
updatePipelineProgressStage({
variables: {
id: pipelineProgressId,
pipelineStageId,
},
});
},
[updatePipelineProgressStage],
);
return (
<WithTopBarContainer
title="Opportunities"
icon={<IconTargetArrow size={theme.icon.size.md} />}
>
{items && pipelineId ? (
<>
<Board
pipelineId={pipelineId}
columns={columns || []}
initialBoard={initialBoard}
initialItems={items}
onCardMove={handleCardMove}
onCardUpdate={handleCardUpdate}
/>
<EntityBoardActionBar>
<BoardActionBarButtonDeletePipelineProgress />
</EntityBoardActionBar>
</>
) : (
<></>
)}
<HookCompanyBoard />
<RecoilScope SpecificContext={CompanyBoardContext}>
<EntityBoard boardOptions={companyBoardOptions} />
<EntityBoardActionBar>
<BoardActionBarButtonDeletePipelineProgress />
</EntityBoardActionBar>
</RecoilScope>
</WithTopBarContainer>
);
}

View File

@ -0,0 +1,11 @@
import { atomFamily } from 'recoil';
import { CompanyProgress } from '@/companies/types/CompanyProgress';
export const companyProgressesFamilyState = atomFamily<
CompanyProgress | undefined,
string
>({
key: 'companyProgressesFamilyState',
default: undefined,
});

View File

@ -0,0 +1,8 @@
import { atom } from 'recoil';
import { Pipeline } from '~/generated/graphql';
export const currentPipelineState = atom<Pipeline | undefined>({
key: 'currentPipelineState',
default: undefined,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isBoardLoadedState = atom<boolean>({
key: 'isBoardLoadedState',
default: false,
});

View File

@ -1,7 +1,16 @@
import { useEffect } from 'react';
import { ApolloProvider } from '@apollo/client';
import { Decorator } from '@storybook/react';
import { RecoilRoot } from 'recoil';
import { pipeline } from '@/companies/__stories__/mock-data';
import { CompanyBoardContext } from '@/companies/states/CompanyBoardContext';
import { BoardCardContext } from '@/pipeline-progress/states/BoardCardContext';
import { BoardColumnContext } from '@/pipeline-progress/states/BoardColumnContext';
import { pipelineProgressIdScopedState } from '@/pipeline-progress/states/pipelineProgressIdScopedState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { HookCompanyBoard } from '~/pages/opportunities/HookCompanyBoard';
import { RecoilScope } from '../modules/recoil-scope/components/RecoilScope';
import { CellContext } from '../modules/ui/tables/states/CellContext';
import { RowContext } from '../modules/ui/tables/states/RowContext';
@ -30,3 +39,41 @@ export const CellPositionDecorator: Decorator = (Story) => (
</RecoilScope>
</RecoilScope>
);
export const BoardDecorator: Decorator = (Story) => (
<>
<HookCompanyBoard />
<RecoilScope SpecificContext={CompanyBoardContext}>
<Story />
</RecoilScope>
</>
);
function HookLoadFakeBoardContextState() {
const [, setPipelineProgressId] = useRecoilScopedState(
pipelineProgressIdScopedState,
BoardCardContext,
);
const pipelineProgress =
pipeline?.pipelineStages?.[0]?.pipelineProgresses?.[0];
useEffect(() => {
setPipelineProgressId(pipelineProgress?.id || '');
}, [pipelineProgress?.id, setPipelineProgressId]);
return <></>;
}
export const BoardCardDecorator: Decorator = (Story) => {
return (
<>
<HookCompanyBoard />
<RecoilScope SpecificContext={CompanyBoardContext}>
<RecoilScope SpecificContext={BoardColumnContext}>
<RecoilScope SpecificContext={BoardCardContext}>
<HookLoadFakeBoardContextState />
<Story />
</RecoilScope>
</RecoilScope>
</RecoilScope>
</>
);
};

View File

@ -5,7 +5,10 @@ import { CREATE_EVENT } from '@/analytics/services';
import { GET_CLIENT_CONFIG } from '@/client-config/queries';
import { GET_COMPANIES } from '@/companies/services';
import { GET_PEOPLE, UPDATE_PERSON } from '@/people/services';
import { GET_PIPELINES } from '@/pipeline-progress/queries';
import {
GET_PIPELINE_PROGRESS,
GET_PIPELINES,
} from '@/pipeline-progress/queries';
import {
SEARCH_COMPANY_QUERY,
SEARCH_USER_QUERY,
@ -20,6 +23,7 @@ import {
import { mockedCompaniesData } from './mock-data/companies';
import { mockedPeopleData } from './mock-data/people';
import { mockedPipelineProgressData } from './mock-data/pipeline-progress';
import { mockedPipelinesData } from './mock-data/pipelines';
import { mockedUsersData } from './mock-data/users';
import { filterAndSortData, updateOneFromData } from './mock-data';
@ -113,6 +117,16 @@ export const graphqlMocks = [
}),
);
}),
graphql.query(
getOperationName(GET_PIPELINE_PROGRESS) ?? '',
(req, res, ctx) => {
return res(
ctx.data({
findManyPipelineProgress: mockedPipelineProgressData,
}),
);
},
),
graphql.mutation(getOperationName(CREATE_EVENT) ?? '', (req, res, ctx) => {
return res(
ctx.data({

View File

@ -2,7 +2,7 @@ import { PipelineProgress, User } from '../../generated/graphql';
type MockedPipelineProgress = Pick<
PipelineProgress,
'id' | 'amount' | 'closeDate'
'id' | 'amount' | 'closeDate' | 'progressableId'
> & {
accountOwner: Pick<
User,
@ -10,17 +10,34 @@ type MockedPipelineProgress = Pick<
> | null;
};
const accountOwner = {
email: 'charles@test.com',
displayName: 'Charles Test',
firstName: 'Charles',
lastName: 'Test',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
};
export const mockedPipelineProgressData: Array<MockedPipelineProgress> = [
{
id: '0ac8761c-1ad6-11ee-be56-0242ac120002',
amount: 78,
accountOwner: {
email: 'charles@test.com',
displayName: 'Charles Test',
firstName: 'Charles',
lastName: 'Test',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
__typename: 'User',
},
closeDate: '2021-10-01T00:00:00.000Z',
progressableId: '0',
accountOwner: accountOwner,
},
{
id: 'fe256b39-3ec3-4fe7-8998-b76aa0bfb600',
progressableId: '89bb825c-171e-4bcc-9cf7-43448d6fb278',
amount: 7,
closeDate: '2021-10-01T00:00:00.000Z',
accountOwner,
},
{
id: '4a886c90-f4f2-4984-8222-882ebbb905d6',
progressableId: 'b396e6b9-dc5c-4643-bcff-61b6cf7523ae',
amount: 100,
closeDate: '2021-10-01T00:00:00.000Z',
accountOwner,
},
];