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

@ -0,0 +1,169 @@
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 { 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 }) =>
selected ? theme.selectedCard : theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
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;
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)};
}
}
`;
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}>
<StyledBoardCardHeader>
<img
src={getLogoUrlFromDomainName(company.domainName).toString()}
alt={`${company.name}-company-logo`}
/>
<span>{company.name}</span>
<div style={{ display: 'flex', flex: 1 }} />
<div onClick={handleCheckboxChange}>
<Checkbox checked={selected} />
</div>
</StyledBoardCardHeader>
<StyledBoardCardBody>
<span>
<IconCurrencyDollar size={theme.icon.size.md} />
<BoardCardEditableFieldText
value={pipelineProgress.amount?.toString() || ''}
placeholder="Opportunity amount"
onChange={(value) =>
handleCardUpdate({
...pipelineProgress,
amount: parseInt(value),
})
}
/>
</span>
<span>
<IconCalendarEvent size={theme.icon.size.md} />
<BoardCardEditableFieldDate
value={new Date(pipelineProgress.closeDate || Date.now())}
onChange={(value) => {
handleCardUpdate({
...pipelineProgress,
closeDate: value.toISOString(),
});
}}
/>
</span>
</StyledBoardCardBody>
</StyledBoardCard>
</StyledBoardCardWrapper>
);
}

View File

@ -0,0 +1,50 @@
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, useSearchCompanyQuery } from '~/generated/graphql';
export function NewCompanyBoardCard() {
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'],
});
const handleEntitySelect = useCallback(async (companyId: string) => {
return;
}, []);
function handleCancel() {
return;
}
return (
<SingleEntitySelect
onEntitySelected={(value) => handleEntitySelect(value.id)}
onCancel={handleCancel}
entities={{
entitiesToSelect: companies.entitiesToSelect,
selectedEntity: companies.selectedEntities[0],
loading: companies.loading,
}}
/>
);
}

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

@ -1,59 +0,0 @@
import { BrowserRouter } from 'react-router-dom';
import styled from '@emotion/styled';
import type { Meta, StoryObj } from '@storybook/react';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { CompanyChip } from '../CompanyChip';
const meta: Meta<typeof CompanyChip> = {
title: 'Modules/Companies/CompanyChip',
component: CompanyChip,
};
export default meta;
type Story = StoryObj<typeof CompanyChip>;
const TestCellContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.primary};
display: flex;
height: fit-content;
justify-content: space-between;
max-width: 250px;
min-width: 250px;
overflow: hidden;
text-wrap: nowrap;
`;
export const SmallName: Story = {
render: getRenderWrapperForComponent(
<TestCellContainer>
<BrowserRouter>
<CompanyChip
id="airbnb"
name="Airbnb"
picture="https://api.faviconkit.com/airbnb.com/144"
/>
</BrowserRouter>
</TestCellContainer>,
),
};
export const BigName: Story = {
render: getRenderWrapperForComponent(
<TestCellContainer>
<BrowserRouter>
<CompanyChip
id="google"
name="Google with a real big name to overflow the cell"
picture="https://api.faviconkit.com/google.com/144"
/>
</BrowserRouter>
</TestCellContainer>,
),
};

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