From 817d6dcb053153ece5af6467d9afb35b43959b8c Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 21 Jun 2023 22:31:19 -0700 Subject: [PATCH] Add ability to associate a new company to pipeline (#350) * Add ability to associate a new company to pipeline * Fix tests --- front/src/generated/graphql.tsx | 15 +- .../opportunities/components/Board.tsx | 162 +++++++++++------- .../opportunities/components/BoardCard.tsx | 126 -------------- .../components/CompanyBoardCard.tsx | 87 ++++++++++ .../opportunities/components/NewButton.tsx | 82 +++++++++ .../components/NewCompanyBoardCard.tsx | 48 ++++++ .../components/__stories__/Board.stories.tsx | 14 +- .../__stories__/BoardCard.stories.tsx | 36 ---- .../__stories__/CompanyBoardCard.stories.tsx | 22 +++ .../components/__stories__/mock-data.ts | 13 +- .../modules/opportunities/hooks/useBoard.ts | 82 +++------ .../modules/opportunities/queries/index.ts | 4 +- .../opportunities/states/boardColumnsState.ts | 8 + .../opportunities/states/boardItemsState.ts | 8 + .../src/modules/ui/components/board/Board.tsx | 29 ++-- .../ui/components/board/BoardColumn.tsx | 41 ++--- .../modules/ui/components/board/BoardItem.tsx | 28 --- .../{BoardNewButton.tsx => NewButton.tsx} | 19 +- .../layout/containers/WithTopBarContainer.tsx | 2 +- front/src/modules/ui/layout/styles/themes.ts | 4 + front/src/modules/users/services/index.ts | 10 ++ .../src/pages/opportunities/Opportunities.tsx | 54 ++---- server/src/ability/ability.factory.ts | 1 + 23 files changed, 474 insertions(+), 421 deletions(-) delete mode 100644 front/src/modules/opportunities/components/BoardCard.tsx create mode 100644 front/src/modules/opportunities/components/CompanyBoardCard.tsx create mode 100644 front/src/modules/opportunities/components/NewButton.tsx create mode 100644 front/src/modules/opportunities/components/NewCompanyBoardCard.tsx delete mode 100644 front/src/modules/opportunities/components/__stories__/BoardCard.stories.tsx create mode 100644 front/src/modules/opportunities/components/__stories__/CompanyBoardCard.stories.tsx create mode 100644 front/src/modules/opportunities/states/boardColumnsState.ts create mode 100644 front/src/modules/opportunities/states/boardItemsState.ts delete mode 100644 front/src/modules/ui/components/board/BoardItem.tsx rename front/src/modules/ui/components/board/{BoardNewButton.tsx => NewButton.tsx} (70%) diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 4295f50cca..d1ad746930 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -1626,7 +1626,9 @@ export type DeleteCompaniesMutationVariables = Exact<{ export type DeleteCompaniesMutation = { __typename?: 'Mutation', deleteManyCompany: { __typename?: 'AffectedRows', count: number } }; -export type GetPipelinesQueryVariables = Exact<{ [key: string]: never; }>; +export type GetPipelinesQueryVariables = Exact<{ + where?: InputMaybe; +}>; 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 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` @@ -2220,8 +2222,8 @@ export type DeleteCompaniesMutationHookResult = ReturnType; export type DeleteCompaniesMutationOptions = Apollo.BaseMutationOptions; export const GetPipelinesDocument = gql` - query GetPipelines { - findManyPipeline { + query GetPipelines($where: PipelineWhereInput) { + findManyPipeline(where: $where) { id name pipelineProgressableType @@ -2251,6 +2253,7 @@ export const GetPipelinesDocument = gql` * @example * const { data, loading, error } = useGetPipelinesQuery({ * variables: { + * where: // value for 'where' * }, * }); */ @@ -2729,9 +2732,11 @@ export type GetCurrentUserQueryHookResult = ReturnType; export type GetCurrentUserQueryResult = Apollo.QueryResult; export const GetUsersDocument = gql` - query getUsers { + query GetUsers { findManyUser { id + email + displayName } } `; diff --git a/front/src/modules/opportunities/components/Board.tsx b/front/src/modules/opportunities/components/Board.tsx index d4f54755d5..5c51656c8f 100644 --- a/front/src/modules/opportunities/components/Board.tsx +++ b/front/src/modules/opportunities/components/Board.tsx @@ -1,60 +1,90 @@ -import { useCallback, useMemo, useState } from 'react'; +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 { - BoardItemKey, Column, getOptimisticlyUpdatedBoard, - Item, - Items, StyledBoard, } from '../../ui/components/board/Board'; -import { - ItemsContainer, - ScrollableColumn, - StyledColumn, - StyledColumnTitle, -} from '../../ui/components/board/BoardColumn'; -import { BoardItem } from '../../ui/components/board/BoardItem'; -import { NewButton } from '../../ui/components/board/BoardNewButton'; +import { boardColumnsState } from '../states/boardColumnsState'; +import { boardItemsState } from '../states/boardItemsState'; -import { BoardCard } from './BoardCard'; +import { CompanyBoardCard } from './CompanyBoardCard'; +import { NewButton } from './NewButton'; -type BoardProps = { - columns: Omit[]; - initialBoard: Column[]; - items: Items; - onUpdate?: (itemKey: BoardItemKey, columnId: Column['id']) => Promise; - onClickNew?: ( - columnId: Column['id'], - newItem: Partial & { id: string }, - ) => void; +export type CompanyProgress = Pick< + Company, + 'id' | 'name' | 'domainName' | 'createdAt' +>; +export type CompanyProgressDict = { + [key: string]: CompanyProgress; }; -export const Board = ({ +type BoardProps = { + pipelineId: string; + columns: Omit[]; + initialBoard: Column[]; + initialItems: CompanyProgressDict; + onUpdate?: (itemKey: string, columnId: Column['id']) => Promise; +}; + +const StyledPlaceholder = styled.div` + min-height: 1px; +`; + +const BoardColumnCardsContainer = ({ + children, + droppableProvided, +}: { + children: React.ReactNode; + droppableProvided: DroppableProvided; +}) => { + return ( +
+ {children} + {droppableProvided?.placeholder} +
+ ); +}; + +export function Board({ columns, initialBoard, - items, + initialItems, onUpdate, - onClickNew, -}: BoardProps) => { - const [board, setBoard] = useState(initialBoard); + pipelineId, +}: BoardProps) { + const [board, setBoard] = useRecoilState(boardColumnsState); + const [items, setItems] = useRecoilState(boardItemsState); + const [isInitialBoardLoaded, setIsInitialBoardLoaded] = useState(false); - const onClickFunctions = useMemo< - Record & { id: string }) => void> - >(() => { - return board.reduce((acc, column) => { - acc[column.id] = (newItem: Partial & { id: string }) => { - onClickNew && onClickNew(column.id, newItem); - }; - return acc; - }, {} as Record & { id: string }) => void>); - }, [board, onClickNew]); + useEffect(() => { + if (Object.keys(initialItems).length === 0 || isInitialBoardLoaded) return; + setBoard(initialBoard); + setItems(initialItems); + setIsInitialBoardLoaded(true); + }, [ + initialBoard, + setBoard, + initialItems, + setItems, + setIsInitialBoardLoaded, + isInitialBoardLoaded, + ]); const onDragEnd: OnDragEndResponder = useCallback( async (result) => { @@ -72,42 +102,48 @@ export const Board = ({ console.error(e); } }, - [board, onUpdate], + [board, onUpdate, setBoard], ); - return ( + return board.length > 0 ? ( {columns.map((column, columnIndex) => ( {(droppableProvided) => ( - - - • {column.title} - - - - {board[columnIndex].itemKeys.map((itemKey, index) => ( - - {(draggableProvided) => ( - - - - )} - - ))} - - - - + + + {board[columnIndex].itemKeys.map( + (itemKey, index) => + items[itemKey] && ( + + {(draggableProvided) => ( +
+ +
+ )} +
+ ), + )} +
+ +
)}
))}
+ ) : ( + <> ); -}; +} diff --git a/front/src/modules/opportunities/components/BoardCard.tsx b/front/src/modules/opportunities/components/BoardCard.tsx deleted file mode 100644 index 6e7f10b7da..0000000000 --- a/front/src/modules/opportunities/components/BoardCard.tsx +++ /dev/null @@ -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 ; - if (item?.__typename === 'Company') - return ; - // @todo return card skeleton - return null; -}; - -const PersonBoardCard = ({ person }: { person: Person }) => { - const fullname = `${person.firstname} ${person.lastname}`; - const theme = useTheme(); - return ( - - - person - {fullname} - - - - - - - - - {person.email} - - - - {person.phone} - - - - {humanReadableDate(new Date(person.createdAt as string))} - - - - ); -}; - -const CompanyBoardCard = ({ company }: { company: Company }) => { - const theme = useTheme(); - return ( - - - {`${company.name}-company-logo`} - {company.name} - - - - - - - - {company.employees} - - - - {humanReadableDate(new Date(company.createdAt as string))} - - - - ); -}; diff --git a/front/src/modules/opportunities/components/CompanyBoardCard.tsx b/front/src/modules/opportunities/components/CompanyBoardCard.tsx new file mode 100644 index 0000000000..0ce27c1621 --- /dev/null +++ b/front/src/modules/opportunities/components/CompanyBoardCard.tsx @@ -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 ( + + + + {`${company.name}-company-logo`} + {company.name} + + + + + + + + {company.employees} + + + + {humanReadableDate(new Date(company.createdAt as string))} + + + + + ); +} diff --git a/front/src/modules/opportunities/components/NewButton.tsx b/front/src/modules/opportunities/components/NewButton.tsx new file mode 100644 index 0000000000..8f6da0acc4 --- /dev/null +++ b/front/src/modules/opportunities/components/NewButton.tsx @@ -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) => { + 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 && ( + + + + )} + + + ); +} diff --git a/front/src/modules/opportunities/components/NewCompanyBoardCard.tsx b/front/src/modules/opportunities/components/NewCompanyBoardCard.tsx new file mode 100644 index 0000000000..bf8cef2fc6 --- /dev/null +++ b/front/src/modules/opportunities/components/NewCompanyBoardCard.tsx @@ -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, + ) => 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 ( + onEntitySelect(value)} + entities={{ + entitiesToSelect: companies.entitiesToSelect, + selectedEntity: companies.selectedEntities[0], + }} + /> + ); +} diff --git a/front/src/modules/opportunities/components/__stories__/Board.stories.tsx b/front/src/modules/opportunities/components/__stories__/Board.stories.tsx index 89d3d19570..86b7e6500d 100644 --- a/front/src/modules/opportunities/components/__stories__/Board.stories.tsx +++ b/front/src/modules/opportunities/components/__stories__/Board.stories.tsx @@ -1,6 +1,7 @@ -import { StrictMode } from 'react'; import { Meta, StoryObj } from '@storybook/react'; +import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; + import { Board } from '../Board'; import { initialBoard, items } from './mock-data'; @@ -14,9 +15,12 @@ export default meta; type Story = StoryObj; export const OneColumnBoard: Story = { - render: () => ( - - - + render: getRenderWrapperForComponent( + , ), }; diff --git a/front/src/modules/opportunities/components/__stories__/BoardCard.stories.tsx b/front/src/modules/opportunities/components/__stories__/BoardCard.stories.tsx deleted file mode 100644 index cdf05e4037..0000000000 --- a/front/src/modules/opportunities/components/__stories__/BoardCard.stories.tsx +++ /dev/null @@ -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 = { - title: 'UI/Board/BoardCard', - component: BoardCard, -}; - -export default meta; -type Story = StoryObj; - -export const CompanyBoardCard: Story = { - render: () => ( - - - - - - ), -}; - -export const PersonBoardCard: Story = { - render: () => ( - - - - - - ), -}; diff --git a/front/src/modules/opportunities/components/__stories__/CompanyBoardCard.stories.tsx b/front/src/modules/opportunities/components/__stories__/CompanyBoardCard.stories.tsx new file mode 100644 index 0000000000..6d95fdc789 --- /dev/null +++ b/front/src/modules/opportunities/components/__stories__/CompanyBoardCard.stories.tsx @@ -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 = { + title: 'UI/Board/CompanyBoardCard', + component: CompanyBoardCard, +}; + +export default meta; +type Story = StoryObj; + +export const CompanyCompanyBoardCard: Story = { + render: () => ( + + + + ), +}; diff --git a/front/src/modules/opportunities/components/__stories__/mock-data.ts b/front/src/modules/opportunities/components/__stories__/mock-data.ts index 618115aa00..6b8deaae70 100644 --- a/front/src/modules/opportunities/components/__stories__/mock-data.ts +++ b/front/src/modules/opportunities/components/__stories__/mock-data.ts @@ -1,14 +1,13 @@ -import { mockedCompaniesData } from '../../../../testing/mock-data/companies'; -import { mockedPeopleData } from '../../../../testing/mock-data/people'; -import { Column, Items } from '../../../ui/components/board/Board'; +import { Column } from '@/ui/components/board/Board'; +import { mockedCompaniesData } from '~/testing/mock-data/companies'; -export const items: Items = { +import { CompanyProgressDict } from '../Board'; + +export const items: CompanyProgressDict = { 'item-1': mockedCompaniesData[0], 'item-2': mockedCompaniesData[1], 'item-3': mockedCompaniesData[2], - 'item-4': mockedPeopleData[0], - 'item-5': mockedPeopleData[1], - 'item-6': mockedPeopleData[2], + 'item-4': mockedCompaniesData[3], }; for (let i = 7; i <= 20; i++) { diff --git a/front/src/modules/opportunities/hooks/useBoard.ts b/front/src/modules/opportunities/hooks/useBoard.ts index c002b8bf71..91e92aa266 100644 --- a/front/src/modules/opportunities/hooks/useBoard.ts +++ b/front/src/modules/opportunities/hooks/useBoard.ts @@ -1,86 +1,62 @@ import { - GetCompaniesQuery, - GetPeopleQuery, + Company, useGetCompaniesQuery, - useGetPeopleQuery, useGetPipelinesQuery, } 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; +type Items = { [key: string]: Item }; -function isGetCompaniesQuery( - entities: Entities, -): entities is GetCompaniesQuery { - return (entities as GetCompaniesQuery).companies !== undefined; -} +export function useBoard(pipelineId: string) { + const pipelines = useGetPipelinesQuery({ + variables: { where: { id: { equals: pipelineId } } }, + }); + 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[] = pipelineStages?.map((pipelineStage) => ({ id: pipelineStage.id, title: pipelineStage.name, colorCode: pipelineStage.color, itemKeys: - pipelineStage.pipelineProgresses?.map( - (item) => item.id as BoardItemKey, - ) || [], + pipelineStage.pipelineProgresses?.map((item) => item.id as string) || + [], })) || []; - const pipelineEntityIds = pipelineStages?.reduce( + const pipelineProgresses = pipelineStages?.reduce( (acc, pipelineStage) => [ ...acc, ...(pipelineStage.pipelineProgresses?.map((item) => ({ - entityId: item?.progressableId, + progressableId: item?.progressableId, pipelineProgressId: item?.id, })) || []), ], - [] as { entityId: string; pipelineProgressId: string }[], + [] as { progressableId: string; pipelineProgressId: string }[], ); - const pipelineProgressableIdsMapper = (pipelineProgressId: string) => { - 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({ + const entitiesQueryResult = useGetCompaniesQuery({ 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, [entity.id]: entity, }); - const entityItems = entitiesQueryResult.data - ? isGetCompaniesQuery(entitiesQueryResult.data) - ? entitiesQueryResult.data.companies.reduce(indexByIdReducer, {} as Items) - : isGetPeopleQuery(entitiesQueryResult.data) - ? entitiesQueryResult.data.people.reduce(indexByIdReducer, {} as Items) - : undefined - : undefined; + const companiesDict = entitiesQueryResult.data?.companies.reduce( + indexByIdReducer, + {} as Items, + ); - const items = pipelineEntityIds?.reduce((acc, item) => { - const entityId = pipelineProgressableIdsMapper(item.pipelineProgressId); - if (entityId) { - acc[item.pipelineProgressId] = entityItems?.[entityId]; + const items = pipelineProgresses?.reduce((acc, pipelineProgress) => { + if (companiesDict?.[pipelineProgress.progressableId]) { + acc[pipelineProgress.pipelineProgressId] = + companiesDict[pipelineProgress.progressableId]; } return acc; }, {} as Items); @@ -90,7 +66,5 @@ export const useBoard = () => { items, loading: pipelines.loading || entitiesQueryResult.loading, error: pipelines.error || entitiesQueryResult.error, - pipelineId: pipelines.data?.findManyPipeline[0].id, - pipelineEntityType, }; -}; +} diff --git a/front/src/modules/opportunities/queries/index.ts b/front/src/modules/opportunities/queries/index.ts index 140c42c907..5753c37e29 100644 --- a/front/src/modules/opportunities/queries/index.ts +++ b/front/src/modules/opportunities/queries/index.ts @@ -1,8 +1,8 @@ import { gql } from '@apollo/client'; export const GET_PIPELINES = gql` - query GetPipelines { - findManyPipeline { + query GetPipelines($where: PipelineWhereInput) { + findManyPipeline(where: $where) { id name pipelineProgressableType diff --git a/front/src/modules/opportunities/states/boardColumnsState.ts b/front/src/modules/opportunities/states/boardColumnsState.ts new file mode 100644 index 0000000000..eb48df9ca9 --- /dev/null +++ b/front/src/modules/opportunities/states/boardColumnsState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +import { Column } from '@/ui/components/board/Board'; + +export const boardColumnsState = atom({ + key: 'boardColumnsState', + default: [], +}); diff --git a/front/src/modules/opportunities/states/boardItemsState.ts b/front/src/modules/opportunities/states/boardItemsState.ts new file mode 100644 index 0000000000..344d8a1096 --- /dev/null +++ b/front/src/modules/opportunities/states/boardItemsState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +import { CompanyProgressDict } from '../components/Board'; + +export const boardItemsState = atom({ + key: 'boardItemsState', + default: {}, +}); diff --git a/front/src/modules/ui/components/board/Board.tsx b/front/src/modules/ui/components/board/Board.tsx index 2664a40f8a..b221672e0a 100644 --- a/front/src/modules/ui/components/board/Board.tsx +++ b/front/src/modules/ui/components/board/Board.tsx @@ -5,37 +5,33 @@ export const StyledBoard = styled.div` border-radius: ${({ theme }) => theme.spacing(2)}; display: flex; flex-direction: row; - height: 100%; + height: calc(100%); overflow-x: auto; width: 100%; `; -export type BoardItemKey = string; -export type Item = any & { id: string }; -export interface Items { - [key: string]: Item; -} export interface Column { id: string; title: string; colorCode?: string; - itemKeys: BoardItemKey[]; + itemKeys: string[]; } -export const getOptimisticlyUpdatedBoard = ( +export function getOptimisticlyUpdatedBoard( board: Column[], result: DropResult, -) => { +) { + const newBoard = JSON.parse(JSON.stringify(board)); const { destination, source } = result; if (!destination) return; - const sourceColumnIndex = board.findIndex( - (column) => column.id === source.droppableId, + const sourceColumnIndex = newBoard.findIndex( + (column: Column) => column.id === source.droppableId, ); - const sourceColumn = board[sourceColumnIndex]; - const destinationColumnIndex = board.findIndex( - (column) => column.id === destination.droppableId, + const sourceColumn = newBoard[sourceColumnIndex]; + const destinationColumnIndex = newBoard.findIndex( + (column: Column) => column.id === destination.droppableId, ); - const destinationColumn = board[destinationColumnIndex]; + const destinationColumn = newBoard[destinationColumnIndex]; if (!destinationColumn || !sourceColumn) return; const sourceItems = sourceColumn.itemKeys; const destinationItems = destinationColumn.itemKeys; @@ -53,8 +49,7 @@ export const getOptimisticlyUpdatedBoard = ( itemKeys: destinationItems, }; - const newBoard = [...board]; newBoard.splice(sourceColumnIndex, 1, newSourceColumn); newBoard.splice(destinationColumnIndex, 1, newDestinationColumn); return newBoard; -}; +} diff --git a/front/src/modules/ui/components/board/BoardColumn.tsx b/front/src/modules/ui/components/board/BoardColumn.tsx index 43d0b15798..37f81f3459 100644 --- a/front/src/modules/ui/components/board/BoardColumn.tsx +++ b/front/src/modules/ui/components/board/BoardColumn.tsx @@ -1,20 +1,14 @@ import React from 'react'; 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` background-color: ${({ theme }) => theme.primaryBackground}; display: flex; flex-direction: column; - min-width: 300px; + min-width: 200px; padding: ${({ theme }) => theme.spacing(2)}; `; -export const ScrollableColumn = styled.div` - max-height: calc(100vh - 120px); - overflow-y: auto; -`; - export const StyledColumnTitle = styled.h3` color: ${({ color }) => color}; font-family: 'Inter'; @@ -26,26 +20,17 @@ export const StyledColumnTitle = styled.h3` margin-bottom: ${({ theme }) => theme.spacing(2)}; `; -const StyledPlaceholder = styled.div` - min-height: 1px; -`; - -export const StyledItemContainer = styled.div``; - -export const ItemsContainer = ({ - children, - droppableProvided, -}: { +type OwnProps = { + colorCode?: string; + title: string; children: React.ReactNode; - droppableProvided: DroppableProvided; -}) => { - return ( - - {children} - {droppableProvided?.placeholder} - - ); }; + +export function BoardColumn({ colorCode, title, children }: OwnProps) { + return ( + + • {title} + {children} + + ); +} diff --git a/front/src/modules/ui/components/board/BoardItem.tsx b/front/src/modules/ui/components/board/BoardItem.tsx deleted file mode 100644 index a03249bf00..0000000000 --- a/front/src/modules/ui/components/board/BoardItem.tsx +++ /dev/null @@ -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 ( - - {children} - - ); -}; diff --git a/front/src/modules/ui/components/board/BoardNewButton.tsx b/front/src/modules/ui/components/board/NewButton.tsx similarity index 70% rename from front/src/modules/ui/components/board/BoardNewButton.tsx rename to front/src/modules/ui/components/board/NewButton.tsx index 7e9e9bca56..a7d0a79b28 100644 --- a/front/src/modules/ui/components/board/BoardNewButton.tsx +++ b/front/src/modules/ui/components/board/NewButton.tsx @@ -1,4 +1,3 @@ -import { useCallback } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; @@ -22,19 +21,17 @@ const StyledButton = styled.button` } `; -export const NewButton = ({ - onClick, -}: { - onClick?: (...args: any[]) => void; -}) => { +type OwnProps = { + onClick: () => void; +}; + +export function NewButton({ onClick }: OwnProps) { const theme = useTheme(); - const onInnerClick = useCallback(() => { - onClick && onClick({ id: 'twenty-aaffcfbd-f86b-419f-b794-02319abe8637' }); - }, [onClick]); + return ( - + New ); -}; +} diff --git a/front/src/modules/ui/layout/containers/WithTopBarContainer.tsx b/front/src/modules/ui/layout/containers/WithTopBarContainer.tsx index 798d8da2f6..02f106d72b 100644 --- a/front/src/modules/ui/layout/containers/WithTopBarContainer.tsx +++ b/front/src/modules/ui/layout/containers/WithTopBarContainer.tsx @@ -27,7 +27,7 @@ export function WithTopBarContainer({ return ( - + {children} diff --git a/front/src/modules/ui/layout/styles/themes.ts b/front/src/modules/ui/layout/styles/themes.ts index 8b52c7ece7..71c6d99486 100644 --- a/front/src/modules/ui/layout/styles/themes.ts +++ b/front/src/modules/ui/layout/styles/themes.ts @@ -32,6 +32,8 @@ const lightThemeSpecific = { blueLowTransparency: 'rgba(25, 97, 237, 0.32)', boxShadow: '0px 2px 4px 0px #0F0F0F0A', 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 = { @@ -48,6 +50,8 @@ const darkThemeSpecific: typeof lightThemeSpecific = { blueLowTransparency: 'rgba(104, 149, 236, 0.32)', 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 + 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 }; diff --git a/front/src/modules/users/services/index.ts b/front/src/modules/users/services/index.ts index 3b4cf45bf7..61eab390c7 100644 --- a/front/src/modules/users/services/index.ts +++ b/front/src/modules/users/services/index.ts @@ -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) { return generatedUseGetCurrentUserQuery({ variables: { diff --git a/front/src/pages/opportunities/Opportunities.tsx b/front/src/pages/opportunities/Opportunities.tsx index f30fce70f7..ddf1181d80 100644 --- a/front/src/pages/opportunities/Opportunities.tsx +++ b/front/src/pages/opportunities/Opportunities.tsx @@ -1,5 +1,4 @@ import { useCallback, useMemo } from 'react'; -import { getOperationName } from '@apollo/client/utilities'; import { useTheme } from '@emotion/react'; import { IconTargetArrow } from '@/ui/icons/index'; @@ -8,18 +7,19 @@ import { WithTopBarContainer } from '@/ui/layout/containers/WithTopBarContainer' import { PipelineProgress, PipelineStage, - useCreateOnePipelineProgressMutation, + useGetPipelinesQuery, useUpdateOnePipelineProgressMutation, } from '../../generated/graphql'; import { Board } from '../../modules/opportunities/components/Board'; import { useBoard } from '../../modules/opportunities/hooks/useBoard'; -import { GET_PIPELINES } from '../../modules/opportunities/queries'; export function Opportunities() { const theme = useTheme(); - const { initialBoard, items, error, pipelineId, pipelineEntityType } = - useBoard(); + const pipelines = useGetPipelinesQuery(); + const pipelineId = pipelines.data?.findManyPipeline[0].id; + + const { initialBoard, items } = useBoard(pipelineId || ''); const columns = useMemo( () => initialBoard?.map(({ id, colorCode, title }) => ({ @@ -30,7 +30,6 @@ export function Opportunities() { [initialBoard], ); const [updatePipelineProgress] = useUpdateOnePipelineProgressMutation(); - const [createPipelineProgress] = useCreateOnePipelineProgressMutation(); const onUpdate = useCallback( async ( @@ -44,43 +43,22 @@ export function Opportunities() { [updatePipelineProgress], ); - const onClickNew = useCallback( - ( - columnId: PipelineStage['id'], - newItem: Partial & { 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
Error...
; - if (!initialBoard || !items) { - return
Initial board or items not found
; - } - return ( } > - + {items && pipelineId ? ( + + ) : ( + <> + )} ); } diff --git a/server/src/ability/ability.factory.ts b/server/src/ability/ability.factory.ts index aaac870d15..8533330cb0 100644 --- a/server/src/ability/ability.factory.ts +++ b/server/src/ability/ability.factory.ts @@ -89,6 +89,7 @@ export class AbilityFactory { // PipelineProgress can(AbilityAction.Read, 'PipelineProgress', { workspaceId: workspace.id }); + can(AbilityAction.Create, 'PipelineProgress'); can(AbilityAction.Update, 'PipelineProgress', { workspaceId: workspace.id, });