Enable opportunity card deletion (#490)

* Add checkbox

* Add state management for selected opportunities

* Use recoil for selected items state, show action bar

* Deduplicate code

* Add delete action

* Enable delete

* Add color for selected cards

* update board state on delete

* Add stories

* Enable empty board

* Fix story

* Handle dark mdoe

* Nits

* Rename module

* Better naming

* Fix naming confusion process<>progress
This commit is contained in:
Emilien Chauvet
2023-07-03 14:11:39 -07:00
committed by GitHub
parent c871d1cc10
commit db5dfb3bdf
22 changed files with 275 additions and 81 deletions

View File

@ -0,0 +1,167 @@
import { useCallback, useEffect, 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/components/board/BoardColumn';
import { Company } from '~/generated/graphql';
import {
Column,
getOptimisticlyUpdatedBoard,
StyledBoard,
} from '../../ui/components/board/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 = Pick<
Company,
'id' | 'name' | 'domainName' | 'createdAt'
>;
export type CompanyProgressDict = {
[key: string]: CompanyProgress;
};
type BoardProps = {
pipelineId: string;
columns: Omit<Column, 'itemKeys'>[];
initialBoard: Column[];
initialItems: CompanyProgressDict;
onUpdate?: (itemKey: string, columnId: Column['id']) => 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,
onUpdate,
pipelineId,
}: BoardProps) {
const [board, setBoard] = useRecoilState(boardColumnsState);
const [boardItems, setBoardItems] = useRecoilState(boardItemsState);
const [selectedBoardItems, setSelectedBoardItems] = useRecoilState(
selectedBoardItemsState,
);
const [isInitialBoardLoaded, setIsInitialBoardLoaded] = useState(false);
useEffect(() => {
setBoard(initialBoard);
if (Object.keys(initialItems).length === 0 || isInitialBoardLoaded) return;
setBoardItems(initialItems);
setIsInitialBoardLoaded(true);
}, [
initialBoard,
setBoard,
initialItems,
setBoardItems,
setIsInitialBoardLoaded,
isInitialBoardLoaded,
]);
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 &&
onUpdate &&
(await onUpdate(draggedEntityId, destinationColumnId));
} catch (e) {
console.error(e);
}
},
[board, onUpdate, 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} 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]}
selected={selectedBoardItems.includes(itemKey)}
onSelect={() => handleSelect(itemKey)}
/>
</div>
)}
</Draggable>
),
)}
</BoardColumnCardsContainer>
<NewButton pipelineId={pipelineId} columnId={column.id} />
</BoardColumn>
)}
</Droppable>
))}
</DragDropContext>
</StyledBoard>
) : (
<></>
);
}

View File

@ -0,0 +1,49 @@
import { getOperationName } from '@apollo/client/utilities';
import { useRecoilState } from 'recoil';
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';
export function BoardActionBarButtonDeletePipelineProgress() {
const [selectedBoardItems, setSelectedBoardItems] = useRecoilState(
selectedBoardItemsState,
);
const [boardItems, setBoardItems] = useRecoilState(boardItemsState);
const [deletePipelineProgress] = useDeleteManyPipelineProgressMutation({
refetchQueries: [getOperationName(GET_PIPELINES) ?? ''],
});
async function handleDeleteClick() {
await deletePipelineProgress({
variables: {
ids: selectedBoardItems,
},
});
console.log('boardItems', boardItems);
setBoardItems(
Object.fromEntries(
Object.entries(boardItems).filter(
([key]) => !selectedBoardItems.includes(key),
),
),
);
setSelectedBoardItems([]);
}
return (
<EntityTableActionBarButton
label="Delete"
icon={<IconTrash size={16} />}
type="warning"
onClick={handleDeleteClick}
/>
);
}

View File

@ -0,0 +1,103 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Company } from '../../../generated/graphql';
import { PersonChip } from '../../people/components/PersonChip';
import { Checkbox } from '../../ui/components/form/Checkbox';
import { IconCalendarEvent, IconUser, IconUsers } from '../../ui/icons';
import { getLogoUrlFromDomainName, humanReadableDate } from '../../utils/utils';
const StyledBoardCard = styled.div<{ selected: boolean }>`
background-color: ${({ theme, selected }) =>
selected ? theme.selectedCard : theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: 4px;
box-shadow: ${({ theme }) => theme.boxShadow.light};
color: ${({ theme }) => theme.font.color.primary};
&:hover {
background-color: ${({ theme, selected }) =>
selected ? theme.selectedCardHover : theme.background.tertiary};
}
cursor: pointer;
`;
const StyledBoardCardWrapper = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledBoardCardHeader = styled.div`
align-items: center;
display: flex;
flex-direction: row;
font-weight: ${({ theme }) => theme.font.weight.semiBold};
height: 24px;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
img {
height: ${({ theme }) => theme.icon.size.md}px;
margin-right: ${({ theme }) => theme.spacing(2)};
object-fit: cover;
width: ${({ theme }) => theme.icon.size.md}px;
}
`;
const StyledBoardCardBody = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
span {
align-items: center;
display: flex;
flex-direction: row;
svg {
color: ${({ theme }) => theme.font.color.tertiary};
margin-right: ${({ theme }) => theme.spacing(2)};
}
}
`;
type CompanyProp = Pick<
Company,
'id' | 'name' | 'domainName' | 'employees' | 'createdAt' | 'accountOwner'
>;
export function CompanyBoardCard({
company,
selected,
onSelect,
}: {
company: CompanyProp;
selected: boolean;
onSelect: (company: CompanyProp) => void;
}) {
const theme = useTheme();
return (
<StyledBoardCardWrapper>
<StyledBoardCard selected={selected}>
<StyledBoardCardHeader>
<img
src={getLogoUrlFromDomainName(company.domainName).toString()}
alt={`${company.name}-company-logo`}
/>
<span>{company.name}</span>
<div style={{ display: 'flex', flex: 1 }} />
<Checkbox checked={selected} onChange={() => onSelect(company)} />
</StyledBoardCardHeader>
<StyledBoardCardBody>
<span>
<IconUser size={theme.icon.size.md} />
<PersonChip name={company.accountOwner?.displayName || ''} />
</span>
<span>
<IconUsers size={theme.icon.size.md} /> {company.employees}
</span>
<span>
<IconCalendarEvent size={theme.icon.size.md} />
{humanReadableDate(new Date(company.createdAt as string))}
</span>
</StyledBoardCardBody>
</StyledBoardCard>
</StyledBoardCardWrapper>
);
}

View File

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

View File

@ -0,0 +1,83 @@
import { useCallback, useState } from 'react';
import { useRecoilState } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { Column } from '@/ui/components/board/Board';
import { NewButton as UINewButton } from '@/ui/components/board/NewButton';
import {
Company,
PipelineProgressableType,
useCreateOnePipelineProgressMutation,
} from '~/generated/graphql';
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 [isCreatingCard, setIsCreatingCard] = useState(false);
const [board, setBoard] = useRecoilState(boardColumnsState);
const [boardItems, setBoardItems] = useRecoilState(boardItemsState);
const [createOnePipelineProgress] = useCreateOnePipelineProgressMutation();
const onEntitySelect = useCallback(
async (company: Pick<Company, 'id' | 'name' | 'domainName'>) => {
setIsCreatingCard(false);
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]: {
id: company.id,
name: company.name,
domainName: company.domainName,
createdAt: new Date().toISOString(),
},
});
setBoard(newBoard);
await createOnePipelineProgress({
variables: {
uuid: newUuid,
pipelineStageId: columnId,
pipelineId,
entityId: company.id,
entityType: PipelineProgressableType.Company,
},
});
},
[
createOnePipelineProgress,
columnId,
pipelineId,
board,
setBoard,
boardItems,
setBoardItems,
],
);
const onNewClick = useCallback(() => {
setIsCreatingCard(true);
}, [setIsCreatingCard]);
return (
<>
{isCreatingCard && (
<RecoilScope>
<NewCompanyBoardCard onEntitySelect={onEntitySelect} />
</RecoilScope>
)}
<UINewButton onClick={onNewClick} />
</>
);
}

View File

@ -0,0 +1,48 @@
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';
type OwnProps = {
onEntitySelect: (
company: Pick<Company, 'id' | 'name' | 'domainName'>,
) => void;
};
export function NewCompanyBoardCard({ onEntitySelect }: OwnProps) {
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 (
<SingleEntitySelect
onEntitySelected={(value) => onEntitySelect(value)}
entities={{
entitiesToSelect: companies.entitiesToSelect,
selectedEntity: companies.selectedEntities[0],
}}
/>
);
}

View File

@ -0,0 +1,26 @@
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}
/>,
),
};

View File

@ -0,0 +1,34 @@
import { StrictMode, useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { Company } from '../../../../generated/graphql';
import { mockedCompaniesData } from '../../../../testing/mock-data/companies';
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}
selected={selected}
onSelect={() => setSelected(!selected)}
/>
);
};
export const CompanyCompanyBoardCard: Story = {
render: () => (
<StrictMode>
<FakeSelectableCompanyBoardCard />
</StrictMode>
),
};

View File

@ -0,0 +1,60 @@
import { Column } from '@/ui/components/board/Board';
import { mockedCompaniesData } from '~/testing/mock-data/companies';
import { CompanyProgressDict } from '../Board';
export const items: CompanyProgressDict = {
'item-1': mockedCompaniesData[0],
'item-2': mockedCompaniesData[1],
'item-3': mockedCompaniesData[2],
'item-4': mockedCompaniesData[3],
};
for (let i = 7; i <= 20; i++) {
const key = `item-${i}`;
items[key] = {
...mockedCompaniesData[i % mockedCompaniesData.length],
id: key,
};
}
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

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

View File

@ -0,0 +1,54 @@
import { gql } from '@apollo/client';
export const GET_PIPELINES = gql`
query GetPipelines($where: PipelineWhereInput) {
findManyPipeline(where: $where) {
id
name
pipelineProgressableType
pipelineStages {
id
name
color
pipelineProgresses {
id
progressableType
progressableId
}
}
}
}
`;
export const UPDATE_PIPELINE_STAGE = gql`
mutation UpdateOnePipelineProgress($id: String, $pipelineStageId: String) {
updateOnePipelineProgress(
where: { id: $id }
data: { pipelineStage: { connect: { id: $pipelineStageId } } }
) {
id
}
}
`;
export const ADD_ENTITY_TO_PIPELINE = gql`
mutation CreateOnePipelineProgress(
$uuid: String!
$entityType: PipelineProgressableType!
$entityId: String!
$pipelineId: String!
$pipelineStageId: String!
) {
createOnePipelineProgress(
data: {
id: $uuid
progressableType: $entityType
progressableId: $entityId
pipeline: { connect: { id: $pipelineId } }
pipelineStage: { connect: { id: $pipelineStageId } }
}
) {
id
}
}
`;

View File

@ -0,0 +1 @@
export * from './update';

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const DELETE_PIPELINE_PROGRESS = gql`
mutation DeleteManyPipelineProgress($ids: [String!]) {
deleteManyPipelineProgress(where: { id: { in: $ids } }) {
count
}
}
`;

View File

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

View File

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

View File

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