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:
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -21,6 +21,7 @@ export const OneColumnBoard: Story = {
|
||||
columns={initialBoard}
|
||||
initialBoard={initialBoard}
|
||||
initialItems={items}
|
||||
onCardUpdate={async (_) => {}} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
/>,
|
||||
),
|
||||
};
|
||||
|
||||
@ -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 />),
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 } } }
|
||||
|
||||
Reference in New Issue
Block a user