Update company card (#512)

* Add card rows

* WIP - add amount

* Refactor board state to separate pipeline progress data and company data

* Add migration and generated code

* Pass pipeline progress properties to the comapny card

* WIP-editable

* Enable amount edition

* Nits

* Remove useless import

* Fix empty board bug

* Use cell for editable values on company card

* Add fields

* Enable edition for closeDate

* Add dummy edits for recurring and probability

* Nits

* remove useless fields

* Nits

* Fix user provider

* Add generated code

* Fix nits, reorder migrations, fix login

* Fix tests

* Fix lint
This commit is contained in:
Emilien Chauvet
2023-07-06 18:41:44 -07:00
committed by GitHub
parent 1144bd13ed
commit 7d6adbaa73
68 changed files with 721 additions and 108 deletions

View File

@ -10,7 +10,7 @@ import {
import { useRecoilState } from 'recoil';
import { BoardColumn } from '@/ui/components/board/BoardColumn';
import { Company } from '~/generated/graphql';
import { Company, PipelineProgress } from '~/generated/graphql';
import {
Column,
@ -24,10 +24,11 @@ import { selectedBoardItemsState } from '../states/selectedBoardItemsState';
import { CompanyBoardCard } from './CompanyBoardCard';
import { NewButton } from './NewButton';
export type CompanyProgress = Pick<
Company,
'id' | 'name' | 'domainName' | 'createdAt'
>;
export type CompanyProgress = {
company: Pick<Company, 'id' | 'name' | 'domainName'>;
pipelineProgress: Pick<PipelineProgress, 'id' | 'amount' | 'closeDate'>;
};
export type CompanyProgressDict = {
[key: string]: CompanyProgress;
};
@ -37,7 +38,10 @@ type BoardProps = {
columns: Omit<Column, 'itemKeys'>[];
initialBoard: Column[];
initialItems: CompanyProgressDict;
onUpdate?: (itemKey: string, columnId: Column['id']) => Promise<void>;
onCardMove?: (itemKey: string, columnId: Column['id']) => Promise<void>;
onCardUpdate: (
pipelineProgress: Pick<PipelineProgress, 'id' | 'amount' | 'closeDate'>,
) => Promise<void>;
};
const StyledPlaceholder = styled.div`
@ -66,7 +70,8 @@ export function Board({
columns,
initialBoard,
initialItems,
onUpdate,
onCardMove,
onCardUpdate,
pipelineId,
}: BoardProps) {
const [board, setBoard] = useRecoilState(boardColumnsState);
@ -79,6 +84,7 @@ export function Board({
useEffect(() => {
if (isInitialBoardLoaded) return;
setBoard(initialBoard);
if (Object.keys(initialItems).length === 0) return;
setBoardItems(initialItems);
setIsInitialBoardLoaded(true);
}, [
@ -100,13 +106,13 @@ export function Board({
const destinationColumnId = result.destination?.droppableId;
draggedEntityId &&
destinationColumnId &&
onUpdate &&
(await onUpdate(draggedEntityId, destinationColumnId));
onCardMove &&
(await onCardMove(draggedEntityId, destinationColumnId));
} catch (e) {
console.error(e);
}
},
[board, onUpdate, setBoard],
[board, onCardMove, setBoard],
);
function handleSelect(itemKey: string) {
@ -144,8 +150,12 @@ export function Board({
{...draggableProvided?.draggableProps}
>
<CompanyBoardCard
company={boardItems[itemKey]}
company={boardItems[itemKey].company}
pipelineProgress={
boardItems[itemKey].pipelineProgress
}
selected={selectedBoardItems.includes(itemKey)}
onCardUpdate={onCardUpdate}
onSelect={() => handleSelect(itemKey)}
/>
</div>

View File

@ -1,11 +1,17 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCurrencyDollar } from '@tabler/icons-react';
import { Company } from '../../../generated/graphql';
import { PersonChip } from '../../people/components/PersonChip';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { EditableDate } from '@/ui/components/editable-cell/types/EditableDate';
import { EditableText } from '@/ui/components/editable-cell/types/EditableText';
import { CellContext } from '@/ui/tables/states/CellContext';
import { RowContext } from '@/ui/tables/states/RowContext';
import { Company, PipelineProgress } from '../../../generated/graphql';
import { Checkbox } from '../../ui/components/form/Checkbox';
import { IconCalendarEvent, IconUser, IconUsers } from '../../ui/icons';
import { getLogoUrlFromDomainName, humanReadableDate } from '../../utils/utils';
import { IconCalendarEvent } from '../../ui/icons';
import { getLogoUrlFromDomainName } from '../../utils/utils';
const StyledBoardCard = styled.div<{ selected: boolean }>`
background-color: ${({ theme, selected }) =>
@ -44,7 +50,6 @@ const StyledBoardCardHeader = styled.div`
const StyledBoardCardBody = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
span {
align-items: center;
@ -59,17 +64,37 @@ const StyledBoardCardBody = styled.div`
type CompanyProp = Pick<
Company,
'id' | 'name' | 'domainName' | 'employees' | 'createdAt' | 'accountOwner'
'id' | 'name' | 'domainName' | 'employees' | 'accountOwner'
>;
type PipelineProgressProp = Pick<
PipelineProgress,
'id' | 'amount' | 'closeDate'
>;
// TODO: Remove when refactoring EditableCell into EditableField
function HackScope({ children }: { children: React.ReactNode }) {
return (
<RecoilScope>
<RecoilScope SpecificContext={RowContext}>
<RecoilScope SpecificContext={CellContext}>{children}</RecoilScope>
</RecoilScope>
</RecoilScope>
);
}
export function CompanyBoardCard({
company,
pipelineProgress,
selected,
onSelect,
onCardUpdate,
}: {
company: CompanyProp;
pipelineProgress: PipelineProgressProp;
selected: boolean;
onSelect: (company: CompanyProp) => void;
onCardUpdate: (pipelineProgress: PipelineProgressProp) => Promise<void>;
}) {
const theme = useTheme();
return (
@ -86,15 +111,33 @@ export function CompanyBoardCard({
</StyledBoardCardHeader>
<StyledBoardCardBody>
<span>
<IconUser size={theme.icon.size.md} />
<PersonChip name={company.accountOwner?.displayName || ''} />
</span>
<span>
<IconUsers size={theme.icon.size.md} /> {company.employees}
<IconCurrencyDollar size={theme.icon.size.md} />
<HackScope>
<EditableText
content={pipelineProgress.amount?.toString() || ''}
placeholder="Opportunity amount"
changeHandler={(value) =>
onCardUpdate({
...pipelineProgress,
amount: parseInt(value),
})
}
/>
</HackScope>
</span>
<span>
<IconCalendarEvent size={theme.icon.size.md} />
{humanReadableDate(new Date(company.createdAt as string))}
<HackScope>
<EditableDate
value={new Date(pipelineProgress.closeDate || Date.now())}
changeHandler={(value) => {
onCardUpdate({
...pipelineProgress,
closeDate: value.toISOString(),
});
}}
/>
</HackScope>
</span>
</StyledBoardCardBody>
</StyledBoardCard>

View File

@ -39,10 +39,11 @@ export function NewButton({ pipelineId, columnId }: OwnProps) {
setBoardItems({
...boardItems,
[newUuid]: {
id: company.id,
name: company.name,
domainName: company.domainName,
createdAt: new Date().toISOString(),
company,
pipelineProgress: {
id: newUuid,
amount: 0,
},
},
});
setBoard(newBoard);

View File

@ -21,6 +21,7 @@ export const OneColumnBoard: Story = {
columns={initialBoard}
initialBoard={initialBoard}
initialItems={items}
onCardUpdate={async (_) => {}} // eslint-disable-line @typescript-eslint/no-empty-function
/>,
),
};

View File

@ -1,8 +1,11 @@
import { StrictMode, useState } from 'react';
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> = {
@ -19,16 +22,14 @@ const FakeSelectableCompanyBoardCard = () => {
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: () => (
<StrictMode>
<FakeSelectableCompanyBoardCard />
</StrictMode>
),
render: getRenderWrapperForComponent(<FakeSelectableCompanyBoardCard />),
};

View File

@ -4,20 +4,24 @@ 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],
'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 },
},
};
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',

View File

@ -1,11 +1,21 @@
import {
Company,
PipelineProgress,
useGetCompaniesQuery,
useGetPipelinesQuery,
} from '../../../generated/graphql';
import { Column } from '../../ui/components/board/Board';
type Item = Pick<Company, 'id' | 'name' | 'createdAt' | 'domainName'>;
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) {
@ -27,12 +37,9 @@ export function useBoard(pipelineId: string) {
const pipelineProgresses = pipelineStages?.reduce(
(acc, pipelineStage) => [
...acc,
...(pipelineStage.pipelineProgresses?.map((item) => ({
progressableId: item?.progressableId,
pipelineProgressId: item?.id,
})) || []),
...(pipelineStage.pipelineProgresses || []),
],
[] as { progressableId: string; pipelineProgressId: string }[],
[] as ItemPipelineProgress[],
);
const entitiesQueryResult = useGetCompaniesQuery({
@ -43,20 +50,25 @@ export function useBoard(pipelineId: string) {
},
});
const indexByIdReducer = (acc: Items, entity: Item) => ({
const indexCompanyByIdReducer = (
acc: { [key: string]: ItemCompany },
entity: ItemCompany,
) => ({
...acc,
[entity.id]: entity,
});
const companiesDict = entitiesQueryResult.data?.companies.reduce(
indexByIdReducer,
{} as Items,
indexCompanyByIdReducer,
{} as { [key: string]: ItemCompany },
);
const items = pipelineProgresses?.reduce((acc, pipelineProgress) => {
if (companiesDict?.[pipelineProgress.progressableId]) {
acc[pipelineProgress.pipelineProgressId] =
companiesDict[pipelineProgress.progressableId];
acc[pipelineProgress.id] = {
pipelineProgress,
company: companiesDict[pipelineProgress.progressableId],
};
}
return acc;
}, {} as Items);

View File

@ -14,14 +14,36 @@ export const GET_PIPELINES = gql`
id
progressableType
progressableId
amount
closeDate
}
}
}
}
`;
export const UPDATE_PIPELINE_STAGE = gql`
mutation UpdateOnePipelineProgress($id: String, $pipelineStageId: String) {
export const UPDATE_PIPELINE_PROGRESS = gql`
mutation UpdateOnePipelineProgress(
$id: String
$amount: Int
$closeDate: DateTime
) {
updateOnePipelineProgress(
where: { id: $id }
data: { amount: { set: $amount }, closeDate: { set: $closeDate } }
) {
id
amount
closeDate
}
}
`;
export const UPDATE_PIPELINE_PROGRESS_STAGE = gql`
mutation UpdateOnePipelineProgressStage(
$id: String
$pipelineStageId: String
) {
updateOnePipelineProgress(
where: { id: $id }
data: { pipelineStage: { connect: { id: $pipelineStageId } } }