Add ability to associate a new company to pipeline (#350)

* Add ability to associate a new company to pipeline

* Fix tests
This commit is contained in:
Charles Bochet
2023-06-21 22:31:19 -07:00
committed by GitHub
parent a65853dc2e
commit 817d6dcb05
23 changed files with 474 additions and 421 deletions

View File

@@ -1626,7 +1626,9 @@ export type DeleteCompaniesMutationVariables = Exact<{
export type DeleteCompaniesMutation = { __typename?: 'Mutation', deleteManyCompany: { __typename?: 'AffectedRows', count: number } }; export type DeleteCompaniesMutation = { __typename?: 'Mutation', deleteManyCompany: { __typename?: 'AffectedRows', count: number } };
export type GetPipelinesQueryVariables = Exact<{ [key: string]: never; }>; export type GetPipelinesQueryVariables = Exact<{
where?: InputMaybe<PipelineWhereInput>;
}>;
export type GetPipelinesQuery = { __typename?: 'Query', findManyPipeline: Array<{ __typename?: 'Pipeline', id: string, name: string, pipelineProgressableType: PipelineProgressableType, pipelineStages?: Array<{ __typename?: 'PipelineStage', id: string, name: string, color: string, pipelineProgresses?: Array<{ __typename?: 'PipelineProgress', id: string, progressableType: PipelineProgressableType, progressableId: string }> | null }> | null }> }; export type GetPipelinesQuery = { __typename?: 'Query', findManyPipeline: Array<{ __typename?: 'Pipeline', id: string, name: string, pipelineProgressableType: PipelineProgressableType, pipelineStages?: Array<{ __typename?: 'PipelineStage', id: string, name: string, color: string, pipelineProgresses?: Array<{ __typename?: 'PipelineProgress', id: string, progressableType: PipelineProgressableType, progressableId: string }> | null }> | null }> };
@@ -1733,7 +1735,7 @@ export type GetCurrentUserQuery = { __typename?: 'Query', users: Array<{ __typen
export type GetUsersQueryVariables = Exact<{ [key: string]: never; }>; export type GetUsersQueryVariables = Exact<{ [key: string]: never; }>;
export type GetUsersQuery = { __typename?: 'Query', findManyUser: Array<{ __typename?: 'User', id: string }> }; export type GetUsersQuery = { __typename?: 'Query', findManyUser: Array<{ __typename?: 'User', id: string, email: string, displayName: string }> };
export const CreateCommentDocument = gql` export const CreateCommentDocument = gql`
@@ -2220,8 +2222,8 @@ export type DeleteCompaniesMutationHookResult = ReturnType<typeof useDeleteCompa
export type DeleteCompaniesMutationResult = Apollo.MutationResult<DeleteCompaniesMutation>; export type DeleteCompaniesMutationResult = Apollo.MutationResult<DeleteCompaniesMutation>;
export type DeleteCompaniesMutationOptions = Apollo.BaseMutationOptions<DeleteCompaniesMutation, DeleteCompaniesMutationVariables>; export type DeleteCompaniesMutationOptions = Apollo.BaseMutationOptions<DeleteCompaniesMutation, DeleteCompaniesMutationVariables>;
export const GetPipelinesDocument = gql` export const GetPipelinesDocument = gql`
query GetPipelines { query GetPipelines($where: PipelineWhereInput) {
findManyPipeline { findManyPipeline(where: $where) {
id id
name name
pipelineProgressableType pipelineProgressableType
@@ -2251,6 +2253,7 @@ export const GetPipelinesDocument = gql`
* @example * @example
* const { data, loading, error } = useGetPipelinesQuery({ * const { data, loading, error } = useGetPipelinesQuery({
* variables: { * variables: {
* where: // value for 'where'
* }, * },
* }); * });
*/ */
@@ -2729,9 +2732,11 @@ export type GetCurrentUserQueryHookResult = ReturnType<typeof useGetCurrentUserQ
export type GetCurrentUserLazyQueryHookResult = ReturnType<typeof useGetCurrentUserLazyQuery>; export type GetCurrentUserLazyQueryHookResult = ReturnType<typeof useGetCurrentUserLazyQuery>;
export type GetCurrentUserQueryResult = Apollo.QueryResult<GetCurrentUserQuery, GetCurrentUserQueryVariables>; export type GetCurrentUserQueryResult = Apollo.QueryResult<GetCurrentUserQuery, GetCurrentUserQueryVariables>;
export const GetUsersDocument = gql` export const GetUsersDocument = gql`
query getUsers { query GetUsers {
findManyUser { findManyUser {
id id
email
displayName
} }
} }
`; `;

View File

@@ -1,60 +1,90 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { import {
DragDropContext, DragDropContext,
Draggable, Draggable,
Droppable, Droppable,
DroppableProvided,
OnDragEndResponder, 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 } 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 { import {
BoardItemKey,
Column, Column,
getOptimisticlyUpdatedBoard, getOptimisticlyUpdatedBoard,
Item,
Items,
StyledBoard, StyledBoard,
} from '../../ui/components/board/Board'; } from '../../ui/components/board/Board';
import { import { boardColumnsState } from '../states/boardColumnsState';
ItemsContainer, import { boardItemsState } from '../states/boardItemsState';
ScrollableColumn,
StyledColumn,
StyledColumnTitle,
} from '../../ui/components/board/BoardColumn';
import { BoardItem } from '../../ui/components/board/BoardItem';
import { NewButton } from '../../ui/components/board/BoardNewButton';
import { BoardCard } from './BoardCard'; import { CompanyBoardCard } from './CompanyBoardCard';
import { NewButton } from './NewButton';
type BoardProps = { export type CompanyProgress = Pick<
columns: Omit<Column, 'itemKeys'>[]; Company,
initialBoard: Column[]; 'id' | 'name' | 'domainName' | 'createdAt'
items: Items; >;
onUpdate?: (itemKey: BoardItemKey, columnId: Column['id']) => Promise<void>; export type CompanyProgressDict = {
onClickNew?: ( [key: string]: CompanyProgress;
columnId: Column['id'],
newItem: Partial<Item> & { id: string },
) => void;
}; };
export const Board = ({ 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, columns,
initialBoard, initialBoard,
items, initialItems,
onUpdate, onUpdate,
onClickNew, pipelineId,
}: BoardProps) => { }: BoardProps) {
const [board, setBoard] = useState<Column[]>(initialBoard); const [board, setBoard] = useRecoilState(boardColumnsState);
const [items, setItems] = useRecoilState(boardItemsState);
const [isInitialBoardLoaded, setIsInitialBoardLoaded] = useState(false);
const onClickFunctions = useMemo< useEffect(() => {
Record<Column['id'], (newItem: Partial<Item> & { id: string }) => void> if (Object.keys(initialItems).length === 0 || isInitialBoardLoaded) return;
>(() => { setBoard(initialBoard);
return board.reduce((acc, column) => { setItems(initialItems);
acc[column.id] = (newItem: Partial<Item> & { id: string }) => { setIsInitialBoardLoaded(true);
onClickNew && onClickNew(column.id, newItem); }, [
}; initialBoard,
return acc; setBoard,
}, {} as Record<Column['id'], (newItem: Partial<Item> & { id: string }) => void>); initialItems,
}, [board, onClickNew]); setItems,
setIsInitialBoardLoaded,
isInitialBoardLoaded,
]);
const onDragEnd: OnDragEndResponder = useCallback( const onDragEnd: OnDragEndResponder = useCallback(
async (result) => { async (result) => {
@@ -72,42 +102,48 @@ export const Board = ({
console.error(e); console.error(e);
} }
}, },
[board, onUpdate], [board, onUpdate, setBoard],
); );
return ( return board.length > 0 ? (
<StyledBoard> <StyledBoard>
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
{columns.map((column, columnIndex) => ( {columns.map((column, columnIndex) => (
<Droppable key={column.id} droppableId={column.id}> <Droppable key={column.id} droppableId={column.id}>
{(droppableProvided) => ( {(droppableProvided) => (
<StyledColumn> <BoardColumn title={column.title} colorCode={column.colorCode}>
<StyledColumnTitle color={column.colorCode}> <BoardColumnCardsContainer
{column.title} droppableProvided={droppableProvided}
</StyledColumnTitle> >
<ScrollableColumn> {board[columnIndex].itemKeys.map(
<ItemsContainer droppableProvided={droppableProvided}> (itemKey, index) =>
{board[columnIndex].itemKeys.map((itemKey, index) => ( items[itemKey] && (
<Draggable <Draggable
key={itemKey} key={itemKey}
draggableId={itemKey} draggableId={itemKey}
index={index} index={index}
> >
{(draggableProvided) => ( {(draggableProvided) => (
<BoardItem draggableProvided={draggableProvided}> <div
<BoardCard item={items[itemKey]} /> ref={draggableProvided?.innerRef}
</BoardItem> {...draggableProvided?.dragHandleProps}
)} {...draggableProvided?.draggableProps}
</Draggable> >
))} <CompanyBoardCard company={items[itemKey]} />
</ItemsContainer> </div>
<NewButton onClick={onClickFunctions[column.id]} /> )}
</ScrollableColumn> </Draggable>
</StyledColumn> ),
)}
</BoardColumnCardsContainer>
<NewButton pipelineId={pipelineId} columnId={column.id} />
</BoardColumn>
)} )}
</Droppable> </Droppable>
))} ))}
</DragDropContext> </DragDropContext>
</StyledBoard> </StyledBoard>
) : (
<></>
); );
}; }

View File

@@ -1,126 +0,0 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Company, Person } from '../../../generated/graphql';
import CompanyChip from '../../companies/components/CompanyChip';
import PersonPlaceholder from '../../people/components/person-placeholder.png';
import { PersonChip } from '../../people/components/PersonChip';
import {
IconBuildingSkyscraper,
IconCalendarEvent,
IconMail,
IconPhone,
IconUser,
IconUsers,
} from '../../ui/icons';
import { getLogoUrlFromDomainName, humanReadableDate } from '../../utils/utils';
const StyledBoardCard = styled.div`
color: ${(props) => props.theme.text80};
`;
const StyledBoardCardHeader = styled.div`
align-items: center;
display: flex;
flex-direction: row;
font-weight: ${(props) => props.theme.fontWeightBold};
height: 24px;
padding: ${(props) => props.theme.spacing(2)};
img {
height: ${(props) => props.theme.iconSizeMedium}px;
margin-right: ${(props) => props.theme.spacing(2)};
object-fit: cover;
width: ${(props) => props.theme.iconSizeMedium}px;
}
`;
const StyledBoardCardBody = styled.div`
display: flex;
flex-direction: column;
gap: ${(props) => props.theme.spacing(2)};
padding: ${(props) => props.theme.spacing(2)};
span {
align-items: center;
display: flex;
flex-direction: row;
svg {
color: ${(props) => props.theme.text40};
margin-right: ${(props) => props.theme.spacing(2)};
}
}
`;
export const BoardCard = ({ item }: { item: Person | Company }) => {
if (item?.__typename === 'Person') return <PersonBoardCard person={item} />;
if (item?.__typename === 'Company')
return <CompanyBoardCard company={item} />;
// @todo return card skeleton
return null;
};
const PersonBoardCard = ({ person }: { person: Person }) => {
const fullname = `${person.firstname} ${person.lastname}`;
const theme = useTheme();
return (
<StyledBoardCard>
<StyledBoardCardHeader>
<img
data-testid="person-chip-image"
src={PersonPlaceholder.toString()}
alt="person"
/>
{fullname}
</StyledBoardCardHeader>
<StyledBoardCardBody>
<span>
<IconBuildingSkyscraper size={theme.iconSizeMedium} />
<CompanyChip
name={person.company?.name || ''}
picture={getLogoUrlFromDomainName(
person.company?.domainName,
).toString()}
/>
</span>
<span>
<IconMail size={theme.iconSizeMedium} />
{person.email}
</span>
<span>
<IconPhone size={theme.iconSizeMedium} />
{person.phone}
</span>
<span>
<IconCalendarEvent size={theme.iconSizeMedium} />
{humanReadableDate(new Date(person.createdAt as string))}
</span>
</StyledBoardCardBody>
</StyledBoardCard>
);
};
const CompanyBoardCard = ({ company }: { company: Company }) => {
const theme = useTheme();
return (
<StyledBoardCard>
<StyledBoardCardHeader>
<img
src={getLogoUrlFromDomainName(company.domainName).toString()}
alt={`${company.name}-company-logo`}
/>
<span>{company.name}</span>
</StyledBoardCardHeader>
<StyledBoardCardBody>
<span>
<IconUser size={theme.iconSizeMedium} />
<PersonChip name={company.accountOwner?.displayName || ''} />
</span>
<span>
<IconUsers size={theme.iconSizeMedium} /> {company.employees}
</span>
<span>
<IconCalendarEvent size={theme.iconSizeMedium} />
{humanReadableDate(new Date(company.createdAt as string))}
</span>
</StyledBoardCardBody>
</StyledBoardCard>
);
};

View File

@@ -0,0 +1,87 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Company } from '../../../generated/graphql';
import { PersonChip } from '../../people/components/PersonChip';
import { IconCalendarEvent, IconUser, IconUsers } from '../../ui/icons';
import { getLogoUrlFromDomainName, humanReadableDate } from '../../utils/utils';
const StyledBoardCard = styled.div`
background: ${({ theme }) => theme.secondaryBackground};
border: 1px solid ${({ theme }) => theme.mediumBorder};
border-radius: 4px;
box-shadow: ${({ theme }) => theme.lightBoxShadow};
color: ${({ theme }) => theme.text80};
cursor: pointer;
`;
const StyledBoardCardWrapper = styled.div`
padding-bottom: ${(props) => props.theme.spacing(2)};
`;
const StyledBoardCardHeader = styled.div`
align-items: center;
display: flex;
flex-direction: row;
font-weight: ${(props) => props.theme.fontWeightBold};
height: 24px;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
padding-top: ${(props) => props.theme.spacing(2)};
img {
height: ${(props) => props.theme.iconSizeMedium}px;
margin-right: ${(props) => props.theme.spacing(2)};
object-fit: cover;
width: ${(props) => props.theme.iconSizeMedium}px;
}
`;
const StyledBoardCardBody = styled.div`
display: flex;
flex-direction: column;
gap: ${(props) => props.theme.spacing(2)};
padding: ${(props) => props.theme.spacing(2)};
span {
align-items: center;
display: flex;
flex-direction: row;
svg {
color: ${(props) => props.theme.text40};
margin-right: ${(props) => props.theme.spacing(2)};
}
}
`;
type CompanyProp = Pick<
Company,
'id' | 'name' | 'domainName' | 'employees' | 'createdAt' | 'accountOwner'
>;
export function CompanyBoardCard({ company }: { company: CompanyProp }) {
const theme = useTheme();
return (
<StyledBoardCardWrapper>
<StyledBoardCard>
<StyledBoardCardHeader>
<img
src={getLogoUrlFromDomainName(company.domainName).toString()}
alt={`${company.name}-company-logo`}
/>
<span>{company.name}</span>
</StyledBoardCardHeader>
<StyledBoardCardBody>
<span>
<IconUser size={theme.iconSizeMedium} />
<PersonChip name={company.accountOwner?.displayName || ''} />
</span>
<span>
<IconUsers size={theme.iconSizeMedium} /> {company.employees}
</span>
<span>
<IconCalendarEvent size={theme.iconSizeMedium} />
{humanReadableDate(new Date(company.createdAt as string))}
</span>
</StyledBoardCardBody>
</StyledBoardCard>
</StyledBoardCardWrapper>
);
}

View File

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

View File

@@ -0,0 +1,48 @@
import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState';
import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState';
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

@@ -1,6 +1,7 @@
import { StrictMode } from 'react';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { Board } from '../Board'; import { Board } from '../Board';
import { initialBoard, items } from './mock-data'; import { initialBoard, items } from './mock-data';
@@ -14,9 +15,12 @@ export default meta;
type Story = StoryObj<typeof Board>; type Story = StoryObj<typeof Board>;
export const OneColumnBoard: Story = { export const OneColumnBoard: Story = {
render: () => ( render: getRenderWrapperForComponent(
<StrictMode> <Board
<Board columns={initialBoard} initialBoard={initialBoard} items={items} /> pipelineId={'xxx-test'}
</StrictMode> columns={initialBoard}
initialBoard={initialBoard}
initialItems={items}
/>,
), ),
}; };

View File

@@ -1,36 +0,0 @@
import { StrictMode } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { Company, Person } from '../../../../generated/graphql';
import { mockedCompaniesData } from '../../../../testing/mock-data/companies';
import { mockedPeopleData } from '../../../../testing/mock-data/people';
import { BoardItem } from '../../../ui/components/board/BoardItem';
import { BoardCard } from '../BoardCard';
const meta: Meta<typeof BoardCard> = {
title: 'UI/Board/BoardCard',
component: BoardCard,
};
export default meta;
type Story = StoryObj<typeof BoardCard>;
export const CompanyBoardCard: Story = {
render: () => (
<StrictMode>
<BoardItem draggableProvided={undefined}>
<BoardCard item={mockedCompaniesData[0] as Company} />
</BoardItem>
</StrictMode>
),
};
export const PersonBoardCard: Story = {
render: () => (
<StrictMode>
<BoardItem draggableProvided={undefined}>
<BoardCard item={mockedPeopleData[0] as Person} />
</BoardItem>
</StrictMode>
),
};

View File

@@ -0,0 +1,22 @@
import { StrictMode } 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>;
export const CompanyCompanyBoardCard: Story = {
render: () => (
<StrictMode>
<CompanyBoardCard company={mockedCompaniesData[0] as Company} />
</StrictMode>
),
};

View File

@@ -1,14 +1,13 @@
import { mockedCompaniesData } from '../../../../testing/mock-data/companies'; import { Column } from '@/ui/components/board/Board';
import { mockedPeopleData } from '../../../../testing/mock-data/people'; import { mockedCompaniesData } from '~/testing/mock-data/companies';
import { Column, Items } from '../../../ui/components/board/Board';
export const items: Items = { import { CompanyProgressDict } from '../Board';
export const items: CompanyProgressDict = {
'item-1': mockedCompaniesData[0], 'item-1': mockedCompaniesData[0],
'item-2': mockedCompaniesData[1], 'item-2': mockedCompaniesData[1],
'item-3': mockedCompaniesData[2], 'item-3': mockedCompaniesData[2],
'item-4': mockedPeopleData[0], 'item-4': mockedCompaniesData[3],
'item-5': mockedPeopleData[1],
'item-6': mockedPeopleData[2],
}; };
for (let i = 7; i <= 20; i++) { for (let i = 7; i <= 20; i++) {

View File

@@ -1,86 +1,62 @@
import { import {
GetCompaniesQuery, Company,
GetPeopleQuery,
useGetCompaniesQuery, useGetCompaniesQuery,
useGetPeopleQuery,
useGetPipelinesQuery, useGetPipelinesQuery,
} from '../../../generated/graphql'; } from '../../../generated/graphql';
import { BoardItemKey, Column, Items } from '../../ui/components/board/Board'; import { Column } from '../../ui/components/board/Board';
type Entities = GetCompaniesQuery | GetPeopleQuery; type Item = Pick<Company, 'id' | 'name' | 'createdAt' | 'domainName'>;
type Items = { [key: string]: Item };
function isGetCompaniesQuery( export function useBoard(pipelineId: string) {
entities: Entities, const pipelines = useGetPipelinesQuery({
): entities is GetCompaniesQuery { variables: { where: { id: { equals: pipelineId } } },
return (entities as GetCompaniesQuery).companies !== undefined; });
} const pipelineStages = pipelines.data?.findManyPipeline[0]?.pipelineStages;
function isGetPeopleQuery(entities: Entities): entities is GetPeopleQuery {
return (entities as GetPeopleQuery).people !== undefined;
}
export const useBoard = () => {
const pipelines = useGetPipelinesQuery();
const pipelineStages = pipelines.data?.findManyPipeline[0].pipelineStages;
const initialBoard: Column[] = const initialBoard: Column[] =
pipelineStages?.map((pipelineStage) => ({ pipelineStages?.map((pipelineStage) => ({
id: pipelineStage.id, id: pipelineStage.id,
title: pipelineStage.name, title: pipelineStage.name,
colorCode: pipelineStage.color, colorCode: pipelineStage.color,
itemKeys: itemKeys:
pipelineStage.pipelineProgresses?.map( pipelineStage.pipelineProgresses?.map((item) => item.id as string) ||
(item) => item.id as BoardItemKey, [],
) || [],
})) || []; })) || [];
const pipelineEntityIds = pipelineStages?.reduce( const pipelineProgresses = pipelineStages?.reduce(
(acc, pipelineStage) => [ (acc, pipelineStage) => [
...acc, ...acc,
...(pipelineStage.pipelineProgresses?.map((item) => ({ ...(pipelineStage.pipelineProgresses?.map((item) => ({
entityId: item?.progressableId, progressableId: item?.progressableId,
pipelineProgressId: item?.id, pipelineProgressId: item?.id,
})) || []), })) || []),
], ],
[] as { entityId: string; pipelineProgressId: string }[], [] as { progressableId: string; pipelineProgressId: string }[],
); );
const pipelineProgressableIdsMapper = (pipelineProgressId: string) => { const entitiesQueryResult = useGetCompaniesQuery({
const entityId = pipelineEntityIds?.find(
(item) => item.pipelineProgressId === pipelineProgressId,
)?.entityId;
return entityId;
};
const pipelineEntityType =
pipelines.data?.findManyPipeline[0].pipelineProgressableType;
const query =
pipelineEntityType === 'Person' ? useGetPeopleQuery : useGetCompaniesQuery;
const entitiesQueryResult = query({
variables: { variables: {
where: { id: { in: pipelineEntityIds?.map((item) => item.entityId) } }, where: {
id: { in: pipelineProgresses?.map((item) => item.progressableId) },
},
}, },
}); });
const indexByIdReducer = (acc: Items, entity: { id: string }) => ({ const indexByIdReducer = (acc: Items, entity: Item) => ({
...acc, ...acc,
[entity.id]: entity, [entity.id]: entity,
}); });
const entityItems = entitiesQueryResult.data const companiesDict = entitiesQueryResult.data?.companies.reduce(
? isGetCompaniesQuery(entitiesQueryResult.data) indexByIdReducer,
? entitiesQueryResult.data.companies.reduce(indexByIdReducer, {} as Items) {} as Items,
: isGetPeopleQuery(entitiesQueryResult.data) );
? entitiesQueryResult.data.people.reduce(indexByIdReducer, {} as Items)
: undefined
: undefined;
const items = pipelineEntityIds?.reduce((acc, item) => { const items = pipelineProgresses?.reduce((acc, pipelineProgress) => {
const entityId = pipelineProgressableIdsMapper(item.pipelineProgressId); if (companiesDict?.[pipelineProgress.progressableId]) {
if (entityId) { acc[pipelineProgress.pipelineProgressId] =
acc[item.pipelineProgressId] = entityItems?.[entityId]; companiesDict[pipelineProgress.progressableId];
} }
return acc; return acc;
}, {} as Items); }, {} as Items);
@@ -90,7 +66,5 @@ export const useBoard = () => {
items, items,
loading: pipelines.loading || entitiesQueryResult.loading, loading: pipelines.loading || entitiesQueryResult.loading,
error: pipelines.error || entitiesQueryResult.error, error: pipelines.error || entitiesQueryResult.error,
pipelineId: pipelines.data?.findManyPipeline[0].id,
pipelineEntityType,
}; };
}; }

View File

@@ -1,8 +1,8 @@
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
export const GET_PIPELINES = gql` export const GET_PIPELINES = gql`
query GetPipelines { query GetPipelines($where: PipelineWhereInput) {
findManyPipeline { findManyPipeline(where: $where) {
id id
name name
pipelineProgressableType pipelineProgressableType

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

@@ -5,37 +5,33 @@ export const StyledBoard = styled.div`
border-radius: ${({ theme }) => theme.spacing(2)}; border-radius: ${({ theme }) => theme.spacing(2)};
display: flex; display: flex;
flex-direction: row; flex-direction: row;
height: 100%; height: calc(100%);
overflow-x: auto; overflow-x: auto;
width: 100%; width: 100%;
`; `;
export type BoardItemKey = string;
export type Item = any & { id: string };
export interface Items {
[key: string]: Item;
}
export interface Column { export interface Column {
id: string; id: string;
title: string; title: string;
colorCode?: string; colorCode?: string;
itemKeys: BoardItemKey[]; itemKeys: string[];
} }
export const getOptimisticlyUpdatedBoard = ( export function getOptimisticlyUpdatedBoard(
board: Column[], board: Column[],
result: DropResult, result: DropResult,
) => { ) {
const newBoard = JSON.parse(JSON.stringify(board));
const { destination, source } = result; const { destination, source } = result;
if (!destination) return; if (!destination) return;
const sourceColumnIndex = board.findIndex( const sourceColumnIndex = newBoard.findIndex(
(column) => column.id === source.droppableId, (column: Column) => column.id === source.droppableId,
); );
const sourceColumn = board[sourceColumnIndex]; const sourceColumn = newBoard[sourceColumnIndex];
const destinationColumnIndex = board.findIndex( const destinationColumnIndex = newBoard.findIndex(
(column) => column.id === destination.droppableId, (column: Column) => column.id === destination.droppableId,
); );
const destinationColumn = board[destinationColumnIndex]; const destinationColumn = newBoard[destinationColumnIndex];
if (!destinationColumn || !sourceColumn) return; if (!destinationColumn || !sourceColumn) return;
const sourceItems = sourceColumn.itemKeys; const sourceItems = sourceColumn.itemKeys;
const destinationItems = destinationColumn.itemKeys; const destinationItems = destinationColumn.itemKeys;
@@ -53,8 +49,7 @@ export const getOptimisticlyUpdatedBoard = (
itemKeys: destinationItems, itemKeys: destinationItems,
}; };
const newBoard = [...board];
newBoard.splice(sourceColumnIndex, 1, newSourceColumn); newBoard.splice(sourceColumnIndex, 1, newSourceColumn);
newBoard.splice(destinationColumnIndex, 1, newDestinationColumn); newBoard.splice(destinationColumnIndex, 1, newDestinationColumn);
return newBoard; return newBoard;
}; }

View File

@@ -1,20 +1,14 @@
import React from 'react'; import React from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { DroppableProvided } 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 StyledColumn = styled.div` export const StyledColumn = styled.div`
background-color: ${({ theme }) => theme.primaryBackground}; background-color: ${({ theme }) => theme.primaryBackground};
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 300px; min-width: 200px;
padding: ${({ theme }) => theme.spacing(2)}; padding: ${({ theme }) => theme.spacing(2)};
`; `;
export const ScrollableColumn = styled.div`
max-height: calc(100vh - 120px);
overflow-y: auto;
`;
export const StyledColumnTitle = styled.h3` export const StyledColumnTitle = styled.h3`
color: ${({ color }) => color}; color: ${({ color }) => color};
font-family: 'Inter'; font-family: 'Inter';
@@ -26,26 +20,17 @@ export const StyledColumnTitle = styled.h3`
margin-bottom: ${({ theme }) => theme.spacing(2)}; margin-bottom: ${({ theme }) => theme.spacing(2)};
`; `;
const StyledPlaceholder = styled.div` type OwnProps = {
min-height: 1px; colorCode?: string;
`; title: string;
export const StyledItemContainer = styled.div``;
export const ItemsContainer = ({
children,
droppableProvided,
}: {
children: React.ReactNode; children: React.ReactNode;
droppableProvided: DroppableProvided;
}) => {
return (
<StyledItemContainer
ref={droppableProvided?.innerRef}
{...droppableProvided?.droppableProps}
>
{children}
<StyledPlaceholder>{droppableProvided?.placeholder}</StyledPlaceholder>
</StyledItemContainer>
);
}; };
export function BoardColumn({ colorCode, title, children }: OwnProps) {
return (
<StyledColumn>
<StyledColumnTitle color={colorCode}> {title}</StyledColumnTitle>
{children}
</StyledColumn>
);
}

View File

@@ -1,28 +0,0 @@
import styled from '@emotion/styled';
import { DraggableProvided } 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
const StyledCard = styled.div`
background-color: ${({ theme }) => theme.secondaryBackground};
border: 1px solid ${({ theme }) => theme.quaternaryBackground};
border-radius: ${({ theme }) => theme.borderRadius};
box-shadow: ${({ theme }) => theme.boxShadow};
margin-bottom: ${({ theme }) => theme.spacing(2)};
max-width: 300px;
`;
type BoardCardProps = {
children: React.ReactNode;
draggableProvided?: DraggableProvided;
};
export const BoardItem = ({ children, draggableProvided }: BoardCardProps) => {
return (
<StyledCard
ref={draggableProvided?.innerRef}
{...draggableProvided?.dragHandleProps}
{...draggableProvided?.draggableProps}
>
{children}
</StyledCard>
);
};

View File

@@ -1,4 +1,3 @@
import { useCallback } from 'react';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
@@ -22,19 +21,17 @@ const StyledButton = styled.button`
} }
`; `;
export const NewButton = ({ type OwnProps = {
onClick, onClick: () => void;
}: { };
onClick?: (...args: any[]) => void;
}) => { export function NewButton({ onClick }: OwnProps) {
const theme = useTheme(); const theme = useTheme();
const onInnerClick = useCallback(() => {
onClick && onClick({ id: 'twenty-aaffcfbd-f86b-419f-b794-02319abe8637' });
}, [onClick]);
return ( return (
<StyledButton onClick={onInnerClick}> <StyledButton onClick={onClick}>
<IconPlus size={theme.iconSizeMedium} /> <IconPlus size={theme.iconSizeMedium} />
New New
</StyledButton> </StyledButton>
); );
}; }

View File

@@ -27,7 +27,7 @@ export function WithTopBarContainer({
return ( return (
<StyledContainer> <StyledContainer>
<TopBar title={title} icon={icon} onAddButtonClick={onAddButtonClick} /> <TopBar title={title} icon={icon} onAddButtonClick={onAddButtonClick} />
<ContentContainer topMargin={TOP_BAR_MIN_HEIGHT}> <ContentContainer topMargin={TOP_BAR_MIN_HEIGHT + 16 + 16}>
{children} {children}
</ContentContainer> </ContentContainer>
</StyledContainer> </StyledContainer>

View File

@@ -32,6 +32,8 @@ const lightThemeSpecific = {
blueLowTransparency: 'rgba(25, 97, 237, 0.32)', blueLowTransparency: 'rgba(25, 97, 237, 0.32)',
boxShadow: '0px 2px 4px 0px #0F0F0F0A', boxShadow: '0px 2px 4px 0px #0F0F0F0A',
modalBoxShadow: '0px 3px 12px rgba(0, 0, 0, 0.09)', modalBoxShadow: '0px 3px 12px rgba(0, 0, 0, 0.09)',
lightBoxShadow:
'0px 2px 4px 0px rgba(0, 0, 0, 0.04), 0px 0px 4px 0px rgba(0, 0, 0, 0.08)',
}; };
const darkThemeSpecific: typeof lightThemeSpecific = { const darkThemeSpecific: typeof lightThemeSpecific = {
@@ -48,6 +50,8 @@ const darkThemeSpecific: typeof lightThemeSpecific = {
blueLowTransparency: 'rgba(104, 149, 236, 0.32)', blueLowTransparency: 'rgba(104, 149, 236, 0.32)',
boxShadow: '0px 2px 4px 0px #0F0F0F0A', // TODO change color for dark theme boxShadow: '0px 2px 4px 0px #0F0F0F0A', // TODO change color for dark theme
modalBoxShadow: '0px 3px 12px rgba(0, 0, 0, 0.09)', // TODO change color for dark theme modalBoxShadow: '0px 3px 12px rgba(0, 0, 0, 0.09)', // TODO change color for dark theme
lightBoxShadow:
'0px 2px 4px 0px rgba(0, 0, 0, 0.04), 0px 0px 4px 0px rgba(0, 0, 0, 0.08)',
}; };
export const lightTheme = { ...commonTheme, ...lightThemeSpecific }; export const lightTheme = { ...commonTheme, ...lightThemeSpecific };

View File

@@ -21,6 +21,16 @@ export const GET_CURRENT_USER = gql`
} }
`; `;
export const GET_USERS = gql`
query GetUsers {
findManyUser {
id
email
displayName
}
}
`;
export function useGetCurrentUserQuery(userId: string | null) { export function useGetCurrentUserQuery(userId: string | null) {
return generatedUseGetCurrentUserQuery({ return generatedUseGetCurrentUserQuery({
variables: { variables: {

View File

@@ -1,5 +1,4 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { IconTargetArrow } from '@/ui/icons/index'; import { IconTargetArrow } from '@/ui/icons/index';
@@ -8,18 +7,19 @@ import { WithTopBarContainer } from '@/ui/layout/containers/WithTopBarContainer'
import { import {
PipelineProgress, PipelineProgress,
PipelineStage, PipelineStage,
useCreateOnePipelineProgressMutation, useGetPipelinesQuery,
useUpdateOnePipelineProgressMutation, useUpdateOnePipelineProgressMutation,
} from '../../generated/graphql'; } from '../../generated/graphql';
import { Board } from '../../modules/opportunities/components/Board'; import { Board } from '../../modules/opportunities/components/Board';
import { useBoard } from '../../modules/opportunities/hooks/useBoard'; import { useBoard } from '../../modules/opportunities/hooks/useBoard';
import { GET_PIPELINES } from '../../modules/opportunities/queries';
export function Opportunities() { export function Opportunities() {
const theme = useTheme(); const theme = useTheme();
const { initialBoard, items, error, pipelineId, pipelineEntityType } = const pipelines = useGetPipelinesQuery();
useBoard(); const pipelineId = pipelines.data?.findManyPipeline[0].id;
const { initialBoard, items } = useBoard(pipelineId || '');
const columns = useMemo( const columns = useMemo(
() => () =>
initialBoard?.map(({ id, colorCode, title }) => ({ initialBoard?.map(({ id, colorCode, title }) => ({
@@ -30,7 +30,6 @@ export function Opportunities() {
[initialBoard], [initialBoard],
); );
const [updatePipelineProgress] = useUpdateOnePipelineProgressMutation(); const [updatePipelineProgress] = useUpdateOnePipelineProgressMutation();
const [createPipelineProgress] = useCreateOnePipelineProgressMutation();
const onUpdate = useCallback( const onUpdate = useCallback(
async ( async (
@@ -44,43 +43,22 @@ export function Opportunities() {
[updatePipelineProgress], [updatePipelineProgress],
); );
const onClickNew = useCallback(
(
columnId: PipelineStage['id'],
newItem: Partial<PipelineProgress> & { id: string },
) => {
if (!pipelineId || !pipelineEntityType) return;
const variables = {
pipelineStageId: columnId,
pipelineId,
entityId: newItem.id,
entityType: pipelineEntityType,
};
createPipelineProgress({
variables,
refetchQueries: [getOperationName(GET_PIPELINES) ?? ''],
});
},
[pipelineId, pipelineEntityType, createPipelineProgress],
);
if (error) return <div>Error...</div>;
if (!initialBoard || !items) {
return <div>Initial board or items not found</div>;
}
return ( return (
<WithTopBarContainer <WithTopBarContainer
title="Opportunities" title="Opportunities"
icon={<IconTargetArrow size={theme.iconSizeMedium} />} icon={<IconTargetArrow size={theme.iconSizeMedium} />}
> >
<Board {items && pipelineId ? (
columns={columns || []} <Board
initialBoard={initialBoard} pipelineId={pipelineId}
items={items} columns={columns || []}
onUpdate={onUpdate} initialBoard={initialBoard}
onClickNew={onClickNew} initialItems={items}
/> onUpdate={onUpdate}
/>
) : (
<></>
)}
</WithTopBarContainer> </WithTopBarContainer>
); );
} }

View File

@@ -89,6 +89,7 @@ export class AbilityFactory {
// PipelineProgress // PipelineProgress
can(AbilityAction.Read, 'PipelineProgress', { workspaceId: workspace.id }); can(AbilityAction.Read, 'PipelineProgress', { workspaceId: workspace.id });
can(AbilityAction.Create, 'PipelineProgress');
can(AbilityAction.Update, 'PipelineProgress', { can(AbilityAction.Update, 'PipelineProgress', {
workspaceId: workspace.id, workspaceId: workspace.id,
}); });