First round of refactor EntityBoards (#1067)

This commit is contained in:
Lucas Bordeau
2023-08-04 16:16:34 +02:00
committed by GitHub
parent 11e7266f8a
commit c790cc5d0c
35 changed files with 513 additions and 414 deletions

View File

@ -1,7 +1,7 @@
import { MemoryRouter } from 'react-router-dom';
import { Meta, StoryObj } from '@storybook/react';
import { EntityBoard } from '@/pipeline/components/EntityBoard';
import { EntityBoard } from '@/ui/board/components/EntityBoard';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { opportunitiesBoardOptions } from '~/pages/opportunities/opportunitiesBoardOptions';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';

View File

@ -1,33 +1,18 @@
import { useEffect } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Meta, StoryObj } from '@storybook/react';
import { CompanyBoardCard } from '@/companies/components/CompanyBoardCard';
import { BoardCardIdContext } from '@/ui/board/states/BoardCardIdContext';
import { BoardColumnContext } from '@/ui/board/states/BoardColumnContext';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedPipelineProgressData } from '~/testing/mock-data/pipeline-progress';
import { defaultPipelineProgressOrderBy } from '../../pipeline/queries';
import { BoardCardContext } from '../../pipeline/states/BoardCardContext';
import { BoardColumnContext } from '../../pipeline/states/BoardColumnContext';
import { pipelineProgressIdScopedState } from '../../pipeline/states/pipelineProgressIdScopedState';
import { HooksCompanyBoard } from '../components/HooksCompanyBoard';
import { CompanyBoardContext } from '../states/CompanyBoardContext';
function HookLoadFakeBoardContextState() {
const [, setPipelineProgressId] = useRecoilScopedState(
pipelineProgressIdScopedState,
BoardCardContext,
);
const pipelineProgress = mockedPipelineProgressData[1];
useEffect(() => {
setPipelineProgressId(pipelineProgress?.id || '');
}, [pipelineProgress?.id, setPipelineProgressId]);
return <></>;
}
const meta: Meta<typeof CompanyBoardCard> = {
title: 'Modules/Companies/CompanyBoardCard',
component: CompanyBoardCard,
@ -39,12 +24,11 @@ const meta: Meta<typeof CompanyBoardCard> = {
orderBy={defaultPipelineProgressOrderBy}
/>
<RecoilScope SpecificContext={BoardColumnContext}>
<RecoilScope SpecificContext={BoardCardContext}>
<HookLoadFakeBoardContextState />
<BoardCardIdContext.Provider value={mockedPipelineProgressData[1].id}>
<MemoryRouter>
<Story />
</MemoryRouter>
</RecoilScope>
</BoardCardIdContext.Provider>
</RecoilScope>
</RecoilScope>
),

View File

@ -1,4 +1,4 @@
import { ReactNode, useCallback } from 'react';
import { ReactNode, useCallback, useContext } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
@ -7,9 +7,8 @@ import { companyProgressesFamilyState } from '@/companies/states/companyProgress
import { PipelineProgressPointOfContactEditableField } from '@/pipeline/editable-field/components/PipelineProgressPointOfContactEditableField';
import { ProbabilityEditableField } from '@/pipeline/editable-field/components/ProbabilityEditableField';
import { GET_PIPELINE_PROGRESS, GET_PIPELINES } from '@/pipeline/queries';
import { BoardCardContext } from '@/pipeline/states/BoardCardContext';
import { pipelineProgressIdScopedState } from '@/pipeline/states/pipelineProgressIdScopedState';
import { selectedBoardCardsState } from '@/pipeline/states/selectedBoardCardsState';
import { BoardCardIdContext } from '@/ui/board/states/BoardCardIdContext';
import { selectedBoardCardIdsState } from '@/ui/board/states/selectedBoardCardIdsState';
import { EntityChipVariant } from '@/ui/chip/components/EntityChip';
import { DateEditableField } from '@/ui/editable-field/variants/components/DateEditableField';
import { NumberEditableField } from '@/ui/editable-field/variants/components/NumberEditableField';
@ -19,7 +18,6 @@ import {
Checkbox,
CheckboxVariant,
} from '@/ui/input/checkbox/components/Checkbox';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useUpdateOnePipelineProgressMutation } from '~/generated/graphql';
import { getLogoUrlFromDomainName } from '~/utils';
@ -110,25 +108,25 @@ const StyledFieldContainer = styled.div`
export function CompanyBoardCard() {
const [updatePipelineProgress] = useUpdateOnePipelineProgressMutation();
const [pipelineProgressId] = useRecoilScopedState(
pipelineProgressIdScopedState,
BoardCardContext,
);
const boardCardId = useContext(BoardCardIdContext);
const [companyProgress] = useRecoilState(
companyProgressesFamilyState(pipelineProgressId || ''),
companyProgressesFamilyState(boardCardId ?? ''),
);
const { pipelineProgress, company } = companyProgress || {};
const { pipelineProgress, company } = companyProgress ?? {};
const [selectedBoardCards, setSelectedBoardCards] = useRecoilState(
selectedBoardCardsState,
selectedBoardCardIdsState,
);
const selected = selectedBoardCards.includes(pipelineProgressId || '');
const selected = selectedBoardCards.includes(boardCardId ?? '');
function setSelected(isSelected: boolean) {
if (isSelected) {
setSelectedBoardCards([...selectedBoardCards, pipelineProgressId || '']);
setSelectedBoardCards([...selectedBoardCards, boardCardId ?? '']);
} else {
setSelectedBoardCards(
selectedBoardCards.filter((id) => id !== pipelineProgressId),
selectedBoardCards.filter((id) => id !== boardCardId),
);
}
}

View File

@ -8,15 +8,17 @@ import {
CompanyProgress,
PipelineProgressForBoard,
} from '@/companies/types/CompanyProgress';
import { boardState } from '@/pipeline/states/boardState';
import { currentPipelineState } from '@/pipeline/states/currentPipelineState';
import { isBoardLoadedState } from '@/pipeline/states/isBoardLoadedState';
import { BoardPipelineStageColumn } from '@/ui/board/components/Board';
import { boardCardIdsByColumnIdFamilyState } from '@/ui/board/states/boardCardIdsByColumnIdFamilyState';
import { boardColumnsState } from '@/ui/board/states/boardColumnsState';
import { isBoardLoadedState } from '@/ui/board/states/isBoardLoadedState';
import { BoardColumnDefinition } from '@/ui/board/types/BoardColumnDefinition';
import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState';
import { FilterDefinition } from '@/ui/filter-n-sort/types/FilterDefinition';
import { turnFilterIntoWhereClause } from '@/ui/filter-n-sort/utils/turnFilterIntoWhereClause';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import {
GetPipelineProgressQuery,
PipelineProgressableType,
PipelineProgressOrderByWithRelationInput as PipelineProgresses_Order_By,
} from '~/generated/graphql';
@ -39,13 +41,52 @@ export function HooksCompanyBoard({
useInitializeCompanyBoardFilters({
availableFilters,
});
const [currentPipeline, setCurrentPipeline] =
useRecoilState(currentPipelineState);
const [board, setBoard] = useRecoilState(boardState);
const [currentPipeline] = useRecoilState(currentPipelineState);
const [, setBoardColumns] = useRecoilState(boardColumnsState);
const [, setIsBoardLoaded] = useRecoilState(isBoardLoadedState);
const updateBoardColumns = useRecoilCallback(
({ set, snapshot }) =>
(pipeline: Pipeline) => {
const currentPipeline = snapshot
.getLoadable(currentPipelineState)
.valueOrThrow();
const currentBoardColumns = snapshot
.getLoadable(boardColumnsState)
.valueOrThrow();
if (JSON.stringify(pipeline) !== JSON.stringify(currentPipeline)) {
set(currentPipelineState, pipeline);
}
const pipelineStages = pipeline?.pipelineStages ?? [];
const orderedPipelineStages = [...pipelineStages].sort((a, b) => {
if (!a.index || !b.index) return 0;
return a.index - b.index;
});
const newBoardColumns: BoardColumnDefinition[] =
orderedPipelineStages?.map((pipelineStage) => ({
id: pipelineStage.id,
title: pipelineStage.name,
colorCode: pipelineStage.color,
index: pipelineStage.index ?? 0,
}));
if (
JSON.stringify(currentBoardColumns) !==
JSON.stringify(newBoardColumns)
) {
setBoardColumns(newBoardColumns);
}
},
[],
);
useGetPipelinesQuery({
variables: {
where: {
@ -54,23 +95,8 @@ export function HooksCompanyBoard({
},
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, i) => ({
pipelineStageId: pipelineStage.id,
title: pipelineStage.name,
colorCode: pipelineStage.color,
index: pipelineStage.index || 0,
pipelineProgressIds: board?.[i].pipelineProgressIds || [],
})) || [];
setBoard(initialBoard);
updateBoardColumns(pipeline);
},
});
@ -89,6 +115,29 @@ export function HooksCompanyBoard({
};
}, [filters, pipelineStageIds]) as any;
const updateBoardCardIds = useRecoilCallback(
({ snapshot, set }) =>
(
pipelineProgresses: GetPipelineProgressQuery['findManyPipelineProgress'],
) => {
const boardColumns = snapshot
.getLoadable(boardColumnsState)
.valueOrThrow();
for (const boardColumn of boardColumns) {
const boardCardIds = pipelineProgresses
.filter(
(pipelineProgressToFilter) =>
pipelineProgressToFilter.pipelineStageId === boardColumn.id,
)
.map((pipelineProgress) => pipelineProgress.id);
set(boardCardIdsByColumnIdFamilyState(boardColumn.id), boardCardIds);
}
},
[],
);
const pipelineProgressesQuery = useGetPipelineProgressQuery({
variables: {
where: whereFilters,
@ -96,18 +145,9 @@ export function HooksCompanyBoard({
},
onCompleted: (data) => {
const pipelineProgresses = data?.findManyPipelineProgress || [];
setBoard((board) =>
board?.map((boardPipelineStage) => ({
...boardPipelineStage,
pipelineProgressIds: pipelineProgresses
.filter(
(pipelineProgress) =>
pipelineProgress.pipelineStageId ===
boardPipelineStage.pipelineStageId,
)
.map((pipelineProgress) => pipelineProgress.id),
})),
);
updateBoardCardIds(pipelineProgresses);
setIsBoardLoaded(true);
},
});

View File

@ -1,15 +1,13 @@
import { useCallback, useState } from 'react';
import { useCallback, useContext, useState } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { useRecoilState } from 'recoil';
import { useRecoilCallback, useRecoilState } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { GET_PIPELINE_PROGRESS, GET_PIPELINES } from '@/pipeline/queries';
import { BoardColumnContext } from '@/pipeline/states/BoardColumnContext';
import { boardState } from '@/pipeline/states/boardState';
import { currentPipelineState } from '@/pipeline/states/currentPipelineState';
import { pipelineStageIdScopedState } from '@/pipeline/states/pipelineStageIdScopedState';
import { BoardPipelineStageColumn } from '@/ui/board/components/Board';
import { NewButton } from '@/ui/board/components/NewButton';
import { boardCardIdsByColumnIdFamilyState } from '@/ui/board/states/boardCardIdsByColumnIdFamilyState';
import { BoardColumnIdContext } from '@/ui/board/states/BoardColumnIdContext';
import { SingleEntitySelect } from '@/ui/input/relation-picker/components/SingleEntitySelect';
import { relationPickerSearchFilterScopedState } from '@/ui/input/relation-picker/states/relationPickerSearchFilterScopedState';
import { RelationPickerHotkeyScope } from '@/ui/input/relation-picker/types/RelationPickerHotkeyScope';
@ -21,12 +19,8 @@ import { useFilteredSearchCompanyQuery } from '../queries';
export function NewCompanyProgressButton() {
const [isCreatingCard, setIsCreatingCard] = useState(false);
const [board, setBoard] = useRecoilState(boardState);
const [pipeline] = useRecoilState(currentPipelineState);
const [pipelineStageId] = useRecoilScopedState(
pipelineStageIdScopedState,
BoardColumnContext,
);
const pipelineStageId = useContext(BoardColumnIdContext);
const {
goBackToPreviousHotkeyScope,
@ -41,34 +35,35 @@ export function NewCompanyProgressButton() {
],
});
const handleEntitySelect = useCallback(
async (company: any) => {
if (!company) return;
const handleEntitySelect = useRecoilCallback(
({ set }) =>
async (company: any) => {
if (!company) return;
setIsCreatingCard(false);
goBackToPreviousHotkeyScope();
if (!pipelineStageId) throw new Error('pipelineStageId is not defined');
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 createOneCompanyPipelineProgress({
variables: {
uuid: newUuid,
pipelineStageId: pipelineStageId || '',
pipelineId: pipeline?.id || '',
companyId: company.id || '',
},
});
},
setIsCreatingCard(false);
goBackToPreviousHotkeyScope();
const newUuid = uuidv4();
set(boardCardIdsByColumnIdFamilyState(pipelineStageId), (oldValue) => [
...oldValue,
newUuid,
]);
await createOneCompanyPipelineProgress({
variables: {
uuid: newUuid,
pipelineStageId: pipelineStageId,
pipelineId: pipeline?.id ?? '',
companyId: company.id ?? '',
},
});
},
[
goBackToPreviousHotkeyScope,
board,
setBoard,
createOneCompanyPipelineProgress,
pipelineStageId,
pipeline?.id,

View File

@ -1,49 +0,0 @@
import { getOperationName } from '@apollo/client/utilities';
import { useRecoilState } from 'recoil';
import { IconTrash } from '@/ui/icon/index';
import { EntityTableActionBarButton } from '@/ui/table/action-bar/components/EntityTableActionBarButton';
import { useDeleteManyPipelineProgressMutation } from '~/generated/graphql';
import { GET_PIPELINES } from '../queries';
import { boardState } from '../states/boardState';
import { selectedBoardCardsState } from '../states/selectedBoardCardsState';
export function BoardActionBarButtonDeletePipelineProgress() {
const [selectedBoardItems, setSelectedBoardItems] = useRecoilState(
selectedBoardCardsState,
);
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,
},
});
}
return (
<EntityTableActionBarButton
label="Delete"
icon={<IconTrash size={16} />}
type="warning"
onClick={handleDeleteClick}
/>
);
}

View File

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

View File

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

View File

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

View File

@ -1,57 +0,0 @@
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: 1;
flex-direction: row;
padding-left: ${({ theme }) => theme.spacing(2)};
`;
export type BoardPipelineStageColumn = {
pipelineStageId: string;
title: string;
index: number;
colorCode?: string;
pipelineProgressIds: string[];
};
export function getOptimisticlyUpdatedBoard(
board: BoardPipelineStageColumn[],
result: DropResult,
) {
const newBoard = JSON.parse(JSON.stringify(board));
const { destination, source } = result;
if (!destination) return;
const sourceColumnIndex = newBoard.findIndex(
(column: BoardPipelineStageColumn) =>
column.pipelineStageId === source.droppableId,
);
const sourceColumn = newBoard[sourceColumnIndex];
const destinationColumnIndex = newBoard.findIndex(
(column: BoardPipelineStageColumn) =>
column.pipelineStageId === destination.droppableId,
);
const destinationColumn = newBoard[destinationColumnIndex];
if (!destinationColumn || !sourceColumn) return;
const sourceItems = sourceColumn.pipelineProgressIds;
const destinationItems = destinationColumn.pipelineProgressIds;
const [removed] = sourceItems.splice(source.index, 1);
destinationItems.splice(destination.index, 0, removed);
const newSourceColumn: BoardPipelineStageColumn = {
...sourceColumn,
pipelineProgressIds: sourceItems,
};
const newDestinationColumn = {
...destinationColumn,
pipelineProgressIds: destinationItems,
};
newBoard.splice(sourceColumnIndex, 1, newSourceColumn);
newBoard.splice(destinationColumnIndex, 1, newDestinationColumn);
return newBoard;
}

View File

@ -0,0 +1,61 @@
import { useRecoilCallback } from 'recoil';
import { boardCardIdsByColumnIdFamilyState } from '@/ui/board/states/boardCardIdsByColumnIdFamilyState';
import { boardColumnsState } from '@/ui/board/states/boardColumnsState';
import { selectedBoardCardIdsState } from '@/ui/board/states/selectedBoardCardIdsState';
import { IconTrash } from '@/ui/icon/index';
import { EntityTableActionBarButton } from '@/ui/table/action-bar/components/EntityTableActionBarButton';
export function BoardActionBarButtonDeleteBoardCard({
onDelete,
}: {
onDelete: (deletedCardIds: string[]) => void;
}) {
const deleteBoardCardIds = useRecoilCallback(
({ set, snapshot }) =>
() => {
const boardCardIdsToDelete = snapshot
.getLoadable(selectedBoardCardIdsState)
.getValue();
const boardColumns = snapshot.getLoadable(boardColumnsState).getValue();
for (const boardColumn of boardColumns) {
const boardColumnCardIds = snapshot
.getLoadable(boardCardIdsByColumnIdFamilyState(boardColumn.id))
.getValue();
const newBoardColumnCardIds = boardColumnCardIds.filter(
(cardId) => !boardCardIdsToDelete.includes(cardId),
);
if (newBoardColumnCardIds.length !== boardColumnCardIds.length) {
set(
boardCardIdsByColumnIdFamilyState(boardColumn.id),
newBoardColumnCardIds,
);
}
}
set(selectedBoardCardIdsState, []);
return boardCardIdsToDelete;
},
[],
);
async function handleDeleteClick() {
const deletedCardIds = deleteBoardCardIds();
onDelete(deletedCardIds);
}
return (
<EntityTableActionBarButton
label="Delete"
icon={<IconTrash size={16} />}
type="warning"
onClick={handleDeleteClick}
/>
);
}

View File

@ -8,6 +8,9 @@ import { useRecoilState } from 'recoil';
import { CompanyBoardContext } from '@/companies/states/CompanyBoardContext';
import { BoardHeader } from '@/ui/board/components/BoardHeader';
import { StyledBoard } from '@/ui/board/components/StyledBoard';
import { useUpdateBoardCardIds } from '@/ui/board/hooks/useUpdateBoardCardIds';
import { BoardColumnIdContext } from '@/ui/board/states/BoardColumnIdContext';
import { SelectedSortType } from '@/ui/filter-n-sort/types/interface';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import {
@ -17,13 +20,9 @@ import {
useUpdateOnePipelineProgressStageMutation,
} from '~/generated/graphql';
import {
getOptimisticlyUpdatedBoard,
StyledBoard,
} from '../../ui/board/components/Board';
import { GET_PIPELINE_PROGRESS } from '../queries';
import { GET_PIPELINE_PROGRESS } from '../../../pipeline/queries';
import { BoardColumnContext } from '../states/BoardColumnContext';
import { boardState } from '../states/boardState';
import { boardColumnsState } from '../states/boardColumnsState';
import { BoardOptions } from '../types/BoardOptions';
import { EntityBoardColumn } from './EntityBoardColumn';
@ -38,13 +37,17 @@ const StyledBoardWithHeader = styled.div`
export function EntityBoard({
boardOptions,
updateSorts,
onEditColumnTitle,
onEditColumnColor,
}: {
boardOptions: BoardOptions;
updateSorts: (
sorts: Array<SelectedSortType<PipelineProgressOrderByWithRelationInput>>,
) => void;
onEditColumnTitle: (columnId: string, title: string) => void;
onEditColumnColor: (columnId: string, color: string) => void;
}) {
const [board, setBoard] = useRecoilState(boardState);
const [boardColumns] = useRecoilState(boardColumnsState);
const theme = useTheme();
const [updatePipelineProgressStage] =
useUpdateOnePipelineProgressStageMutation();
@ -65,36 +68,41 @@ export function EntityBoard({
[updatePipelineProgressStage],
);
const updateBoardCardIds = useUpdateBoardCardIds();
const onDragEnd: OnDragEndResponder = useCallback(
async (result) => {
if (!board) return;
const newBoard = getOptimisticlyUpdatedBoard(board, result);
if (!newBoard) return;
setBoard(newBoard);
if (!boardColumns) return;
updateBoardCardIds(result);
try {
const draggedEntityId = result.draggableId;
const destinationColumnId = result.destination?.droppableId;
draggedEntityId &&
// TODO: abstract
if (
draggedEntityId &&
destinationColumnId &&
updatePipelineProgressStageInDB &&
(await updatePipelineProgressStageInDB(
updatePipelineProgressStageInDB
) {
await updatePipelineProgressStageInDB(
draggedEntityId,
destinationColumnId,
));
);
}
} catch (e) {
console.error(e);
}
},
[board, updatePipelineProgressStageInDB, setBoard],
[boardColumns, updatePipelineProgressStageInDB, updateBoardCardIds],
);
const sortedBoard = board
? [...board].sort((a, b) => {
return a.index - b.index;
})
: [];
const sortedBoardColumns = [...boardColumns].sort((a, b) => {
return a.index - b.index;
});
return (board?.length ?? 0) > 0 ? (
return (boardColumns?.length ?? 0) > 0 ? (
<StyledBoardWithHeader>
<BoardHeader
viewName="All opportunities"
@ -105,13 +113,17 @@ export function EntityBoard({
/>
<StyledBoard>
<DragDropContext onDragEnd={onDragEnd}>
{sortedBoard.map((column) => (
<RecoilScope
SpecificContext={BoardColumnContext}
key={column.pipelineStageId}
>
<EntityBoardColumn boardOptions={boardOptions} column={column} />
</RecoilScope>
{sortedBoardColumns.map((column) => (
<BoardColumnIdContext.Provider value={column.id} key={column.id}>
<RecoilScope SpecificContext={BoardColumnContext} key={column.id}>
<EntityBoardColumn
boardOptions={boardOptions}
column={column}
onEditColumnTitle={onEditColumnTitle}
onEditColumnColor={onEditColumnColor}
/>
</RecoilScope>
</BoardColumnIdContext.Provider>
))}
</DragDropContext>
</StyledBoard>

View File

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

View File

@ -1,10 +1,5 @@
import { useEffect } from 'react';
import { Draggable } from '@hello-pangea/dnd';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { BoardCardContext } from '../states/BoardCardContext';
import { pipelineProgressIdScopedState } from '../states/pipelineProgressIdScopedState';
import { BoardOptions } from '../types/BoardOptions';
export function EntityBoardCard({
@ -16,15 +11,6 @@ export function EntityBoardCard({
pipelineProgressId: string;
index: number;
}) {
const [pipelineProgressIdFromRecoil, setPipelineProgressId] =
useRecoilScopedState(pipelineProgressIdScopedState, BoardCardContext);
useEffect(() => {
if (pipelineProgressIdFromRecoil !== pipelineProgressId) {
setPipelineProgressId(pipelineProgressId);
}
}, [pipelineProgressId, setPipelineProgressId, pipelineProgressIdFromRecoil]);
return (
<Draggable
key={pipelineProgressId}

View File

@ -1,20 +1,16 @@
import { useEffect } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { useContext } from 'react';
import styled from '@emotion/styled';
import { Draggable, Droppable, DroppableProvided } from '@hello-pangea/dnd';
import { useRecoilValue } from 'recoil';
import { BoardPipelineStageColumn } from '@/ui/board/components/Board';
import { BoardColumn } from '@/ui/board/components/BoardColumn';
import { BoardCardIdContext } from '@/ui/board/states/BoardCardIdContext';
import { BoardColumnIdContext } from '@/ui/board/states/BoardColumnIdContext';
import { BoardColumnDefinition } from '@/ui/board/types/BoardColumnDefinition';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useUpdatePipelineStageMutation } from '~/generated/graphql';
import { GET_PIPELINES } from '../queries';
import { BoardCardContext } from '../states/BoardCardContext';
import { BoardColumnContext } from '../states/BoardColumnContext';
import { boardCardIdsByColumnIdFamilyState } from '../states/boardCardIdsByColumnIdFamilyState';
import { boardColumnTotalsFamilySelector } from '../states/boardColumnTotalsFamilySelector';
import { pipelineStageIdScopedState } from '../states/pipelineStageIdScopedState';
import { BoardOptions } from '../types/BoardOptions';
import { EntityBoardCard } from './EntityBoardCard';
@ -54,75 +50,56 @@ const BoardColumnCardsContainer = ({
export function EntityBoardColumn({
column,
boardOptions,
onEditColumnTitle,
onEditColumnColor,
}: {
column: BoardPipelineStageColumn;
column: BoardColumnDefinition;
boardOptions: BoardOptions;
onEditColumnTitle: (columnId: string, title: string) => void;
onEditColumnColor: (columnId: string, color: string) => void;
}) {
const [pipelineStageId, setPipelineStageId] = useRecoilScopedState(
pipelineStageIdScopedState,
BoardColumnContext,
);
const boardColumnId = useContext(BoardColumnIdContext) ?? '';
const boardColumnTotal = useRecoilValue(
boardColumnTotalsFamilySelector(column.pipelineStageId),
boardColumnTotalsFamilySelector(column.id),
);
useEffect(() => {
if (pipelineStageId !== column.pipelineStageId) {
setPipelineStageId(column.pipelineStageId);
}
}, [column, setPipelineStageId, pipelineStageId]);
const cardIds = useRecoilValue(
boardCardIdsByColumnIdFamilyState(boardColumnId ?? ''),
);
const [updatePipelineStage] = useUpdatePipelineStageMutation();
function handleEditColumnTitle(value: string) {
updatePipelineStage({
variables: {
id: pipelineStageId,
data: { name: value },
},
refetchQueries: [getOperationName(GET_PIPELINES) || ''],
});
onEditColumnTitle(boardColumnId, value);
}
function handleEditColumnColor(value: string) {
updatePipelineStage({
variables: {
id: pipelineStageId,
data: { color: value },
},
refetchQueries: [getOperationName(GET_PIPELINES) || ''],
});
function handleEditColumnColor(newColor: string) {
onEditColumnColor(boardColumnId, newColor);
}
return (
<Droppable droppableId={column.pipelineStageId}>
<Droppable droppableId={column.id}>
{(droppableProvided) => (
<BoardColumn
onColumnColorEdit={handleEditColumnColor}
onTitleEdit={handleEditColumnTitle}
title={column.title}
color={column.colorCode}
pipelineStageId={column.pipelineStageId}
pipelineStageId={column.id}
totalAmount={boardColumnTotal}
isFirstColumn={column.index === 0}
numChildren={column.pipelineProgressIds.length}
numChildren={cardIds.length}
>
<BoardColumnCardsContainer droppableProvided={droppableProvided}>
{column.pipelineProgressIds.map((pipelineProgressId, index) => (
<RecoilScope
SpecificContext={BoardCardContext}
key={pipelineProgressId}
>
{cardIds.map((cardId, index) => (
<BoardCardIdContext.Provider value={cardId} key={cardId}>
<EntityBoardCard
index={index}
pipelineProgressId={pipelineProgressId}
pipelineProgressId={cardId}
boardOptions={boardOptions}
/>
</RecoilScope>
</BoardCardIdContext.Provider>
))}
<Draggable
draggableId={`new-${column.pipelineStageId}`}
index={column.pipelineProgressIds.length}
>
<Draggable draggableId={`new-${column.id}`} index={cardIds.length}>
{(draggableProvided) => (
<div
ref={draggableProvided?.innerRef}

View File

@ -0,0 +1,9 @@
import styled from '@emotion/styled';
export const StyledBoard = styled.div`
border-radius: ${({ theme }) => theme.spacing(2)};
display: flex;
flex: 1;
flex-direction: row;
padding-left: ${({ theme }) => theme.spacing(2)};
`;

View File

@ -1,59 +1,53 @@
import { DropResult } from '@hello-pangea/dnd';
import { getOptimisticlyUpdatedBoard } from '../Board';
// TODO: refactor this test with Recoil
describe('getOptimisticlyUpdatedBoard', () => {
it('should return a new board with the updated cell', () => {
const initialColumn1: string[] = ['item-1', 'item-2', 'item-3'];
const initialColumn2: string[] = ['item-4', 'item-5'];
const finalColumn1: string[] = ['item-2', 'item-3'];
const finalColumn2: string[] = ['item-4', 'item-1', 'item-5'];
const dropResult = {
source: {
droppableId: 'column-1',
index: 0,
},
destination: {
droppableId: 'column-2',
index: 1,
},
} as DropResult;
const initialBoard = [
{
id: 'column-1',
title: 'My Column',
pipelineStageId: 'column-1',
pipelineProgressIds: initialColumn1,
},
{
id: 'column-2',
title: 'My Column',
pipelineStageId: 'column-2',
pipelineProgressIds: initialColumn2,
},
];
const updatedBoard = getOptimisticlyUpdatedBoard(initialBoard, dropResult);
const finalBoard = [
{
id: 'column-1',
title: 'My Column',
pipelineStageId: 'column-1',
pipelineProgressIds: finalColumn1,
},
{
id: 'column-2',
title: 'My Column',
pipelineStageId: 'column-2',
pipelineProgressIds: finalColumn2,
},
];
expect(updatedBoard).toEqual(finalBoard);
expect(updatedBoard).not.toBe(initialBoard);
// const initialColumn1: string[] = ['item-1', 'item-2', 'item-3'];
// const initialColumn2: string[] = ['item-4', 'item-5'];
// const finalColumn1: string[] = ['item-2', 'item-3'];
// const finalColumn2: string[] = ['item-4', 'item-1', 'item-5'];
// const dropResult = {
// source: {
// droppableId: 'column-1',
// index: 0,
// },
// destination: {
// droppableId: 'column-2',
// index: 1,
// },
// } as DropResult;
// const initialBoard = [
// {
// id: 'column-1',
// title: 'My Column',
// pipelineStageId: 'column-1',
// pipelineProgressIds: initialColumn1,
// },
// {
// id: 'column-2',
// title: 'My Column',
// pipelineStageId: 'column-2',
// pipelineProgressIds: initialColumn2,
// },
// ];
// const updatedBoard = u(
// initialBoard,
// dropResult,
// );
// const finalBoard = [
// {
// id: 'column-1',
// title: 'My Column',
// pipelineStageId: 'column-1',
// pipelineProgressIds: finalColumn1,
// },
// {
// id: 'column-2',
// title: 'My Column',
// pipelineStageId: 'column-2',
// pipelineProgressIds: finalColumn2,
// },
// ];
// expect(updatedBoard).toEqual(finalBoard);
// expect(updatedBoard).not.toBe(initialBoard);
});
});

View File

@ -0,0 +1,86 @@
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
import { useRecoilCallback } from 'recoil';
import { boardCardIdsByColumnIdFamilyState } from '../states/boardCardIdsByColumnIdFamilyState';
import { boardColumnsState } from '../states/boardColumnsState';
import { BoardColumnDefinition } from '../types/BoardColumnDefinition';
export function useUpdateBoardCardIds() {
return useRecoilCallback(
({ snapshot, set }) =>
(result: DropResult) => {
const currentBoardColumns = snapshot
.getLoadable(boardColumnsState)
.valueOrThrow();
const newBoardColumns = [...currentBoardColumns];
const { destination, source } = result;
if (!destination) return;
const sourceColumnIndex = newBoardColumns.findIndex(
(boardColumn: BoardColumnDefinition) =>
boardColumn.id === source.droppableId,
);
const sourceColumn = newBoardColumns[sourceColumnIndex];
const destinationColumnIndex = newBoardColumns.findIndex(
(boardColumn: BoardColumnDefinition) =>
boardColumn.id === destination.droppableId,
);
const destinationColumn = newBoardColumns[destinationColumnIndex];
if (!destinationColumn || !sourceColumn) return;
const sourceCardIds = [
...snapshot
.getLoadable(boardCardIdsByColumnIdFamilyState(sourceColumn.id))
.valueOrThrow(),
];
const destinationCardIds = [
...snapshot
.getLoadable(
boardCardIdsByColumnIdFamilyState(destinationColumn.id),
)
.valueOrThrow(),
];
const destinationIndex =
destination.index >= destinationCardIds.length
? destinationCardIds.length - 1
: destination.index;
if (sourceColumn.id === destinationColumn.id) {
const [deletedCardId] = sourceCardIds.splice(source.index, 1);
sourceCardIds.splice(destinationIndex, 0, deletedCardId);
set(
boardCardIdsByColumnIdFamilyState(sourceColumn.id),
sourceCardIds,
);
} else {
const [removedCardId] = sourceCardIds.splice(source.index, 1);
destinationCardIds.splice(destinationIndex, 0, removedCardId);
set(
boardCardIdsByColumnIdFamilyState(sourceColumn.id),
sourceCardIds,
);
set(
boardCardIdsByColumnIdFamilyState(destinationColumn.id),
destinationCardIds,
);
}
return newBoardColumns;
},
[],
);
}

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { createContext } from 'react';
import { BoardOptions } from '@/ui/board/types/BoardOptions';
export const BoardOptionsContext = createContext<BoardOptions | null>(null);

View File

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

View File

@ -1,25 +1,22 @@
import { selectorFamily } from 'recoil';
import { companyProgressesFamilyState } from '@/companies/states/companyProgressesFamilyState';
import { BoardPipelineStageColumn } from '@/ui/board/components/Board';
import { boardState } from './boardState';
import { boardCardIdsByColumnIdFamilyState } from './boardCardIdsByColumnIdFamilyState';
// TODO: this state should be computed during the synchronization hook and put in a generic
// boardColumnTotalsFamilyState indexed by columnId.
export const boardColumnTotalsFamilySelector = selectorFamily({
key: 'boardColumnTotalsFamilySelector',
get:
(pipelineStageId: string) =>
({ get }) => {
const board = get(boardState);
const pipelineStage = board?.find(
(pipelineStage: BoardPipelineStageColumn) =>
pipelineStage.pipelineStageId === pipelineStageId,
const cardIds = get(boardCardIdsByColumnIdFamilyState(pipelineStageId));
const pipelineProgresses = cardIds.map((pipelineProgressId: string) =>
get(companyProgressesFamilyState(pipelineProgressId)),
);
const pipelineProgresses = pipelineStage?.pipelineProgressIds.map(
(pipelineProgressId: string) =>
get(companyProgressesFamilyState(pipelineProgressId)),
);
const pipelineStageTotal: number =
pipelineProgresses?.reduce(
(acc: number, curr: any) => acc + curr?.pipelineProgress.amount,

View File

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

View File

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

View File

@ -0,0 +1,6 @@
export type BoardColumnDefinition = {
id: string;
title: string;
index: number;
colorCode?: string;
};

View File

@ -0,0 +1,8 @@
import { atomFamily } from 'recoil';
import { Filter } from '../types/Filter';
export const sortScopedState = atomFamily<Filter[], string>({
key: 'sortScopedState',
default: [],
});

View File

@ -42,8 +42,6 @@ export function GenericEditableNumberCellEditMode({ viewField }: OwnProps) {
throw new Error('Number too big');
}
console.log({ numberValue });
setFieldValue(numberValue.toString());
if (currentRowEntityId && updateField) {

View File

@ -1,20 +1,27 @@
import { useCallback, useState } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { useTheme } from '@emotion/react';
import { HooksCompanyBoard } from '@/companies/components/HooksCompanyBoard';
import { CompanyBoardContext } from '@/companies/states/CompanyBoardContext';
import { BoardActionBarButtonDeletePipelineProgress } from '@/pipeline/components/BoardActionBarButtonDeletePipelineProgress';
import { EntityBoard } from '@/pipeline/components/EntityBoard';
import { EntityBoardActionBar } from '@/pipeline/components/EntityBoardActionBar';
import {
defaultPipelineProgressOrderBy,
GET_PIPELINES,
PipelineProgressesSelectedSortType,
} from '@/pipeline/queries';
import { BoardActionBarButtonDeleteBoardCard } from '@/ui/board/components/BoardActionBarButtonDeleteBoardCard';
import { EntityBoard } from '@/ui/board/components/EntityBoard';
import { EntityBoardActionBar } from '@/ui/board/components/EntityBoardActionBar';
import { BoardOptionsContext } from '@/ui/board/states/BoardOptionsContext';
import { reduceSortsToOrderBy } from '@/ui/filter-n-sort/helpers';
import { IconTargetArrow } from '@/ui/icon/index';
import { WithTopBarContainer } from '@/ui/layout/components/WithTopBarContainer';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { PipelineProgressOrderByWithRelationInput } from '~/generated/graphql';
import {
PipelineProgressOrderByWithRelationInput,
useDeleteManyPipelineProgressMutation,
useUpdatePipelineStageMutation,
} from '~/generated/graphql';
import { opportunitiesBoardOptions } from '~/pages/opportunities/opportunitiesBoardOptions';
export function Opportunities() {
@ -35,24 +42,62 @@ export function Opportunities() {
[],
);
const [updatePipelineStage] = useUpdatePipelineStageMutation();
function handleEditColumnTitle(boardColumnId: string, newTitle: string) {
updatePipelineStage({
variables: {
id: boardColumnId,
data: { name: newTitle },
},
refetchQueries: [getOperationName(GET_PIPELINES) || ''],
});
}
function handleEditColumnColor(boardColumnId: string, newColor: string) {
updatePipelineStage({
variables: {
id: boardColumnId,
data: { color: newColor },
},
refetchQueries: [getOperationName(GET_PIPELINES) || ''],
});
}
const [deletePipelineProgress] = useDeleteManyPipelineProgressMutation({
refetchQueries: [getOperationName(GET_PIPELINES) ?? ''],
});
async function handleDelete(cardIdsToDelete: string[]) {
await deletePipelineProgress({
variables: {
ids: cardIdsToDelete,
},
});
}
return (
<WithTopBarContainer
title="Opportunities"
icon={<IconTargetArrow size={theme.icon.size.md} />}
>
<RecoilScope SpecificContext={CompanyBoardContext}>
<HooksCompanyBoard
availableFilters={opportunitiesBoardOptions.filters}
orderBy={orderBy}
/>
<EntityBoard
boardOptions={opportunitiesBoardOptions}
updateSorts={updateSorts}
/>
<EntityBoardActionBar>
<BoardActionBarButtonDeletePipelineProgress />
</EntityBoardActionBar>
</RecoilScope>
<BoardOptionsContext.Provider value={opportunitiesBoardOptions}>
<RecoilScope SpecificContext={CompanyBoardContext}>
<HooksCompanyBoard
availableFilters={opportunitiesBoardOptions.filters}
orderBy={orderBy}
/>
<EntityBoard
boardOptions={opportunitiesBoardOptions}
updateSorts={updateSorts}
onEditColumnColor={handleEditColumnColor}
onEditColumnTitle={handleEditColumnTitle}
/>
<EntityBoardActionBar>
<BoardActionBarButtonDeleteBoardCard onDelete={handleDelete} />
</EntityBoardActionBar>
</RecoilScope>
</BoardOptionsContext.Provider>
</WithTopBarContainer>
);
}

View File

@ -1,6 +1,6 @@
import { CompanyBoardCard } from '@/companies/components/CompanyBoardCard';
import { NewCompanyProgressButton } from '@/companies/components/NewCompanyProgressButton';
import { BoardOptions } from '@/pipeline/types/BoardOptions';
import { BoardOptions } from '@/ui/board/types/BoardOptions';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { opportunitiesFilters } from './opportunities-filters';