diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index e2f9e02f1..2649095c0 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -3366,7 +3366,14 @@ export type GetPipelinesQueryVariables = Exact<{ }>; -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, index?: number | null, pipelineProgresses?: Array<{ __typename?: 'PipelineProgress', id: string, progressableType: PipelineProgressableType, progressableId: string, amount?: number | null, closeDate?: string | null }> | 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, index?: number | null, pipelineProgresses?: Array<{ __typename?: 'PipelineProgress', id: string }> | null }> | null }> }; + +export type GetPipelineProgressQueryVariables = Exact<{ + where?: InputMaybe; +}>; + + +export type GetPipelineProgressQuery = { __typename?: 'Query', findManyPipelineProgress: Array<{ __typename?: 'PipelineProgress', id: string, progressableType: PipelineProgressableType, progressableId: string, amount?: number | null, closeDate?: string | null }> }; export type UpdateOnePipelineProgressMutationVariables = Exact<{ id?: InputMaybe; @@ -4959,10 +4966,6 @@ export const GetPipelinesDocument = gql` index pipelineProgresses { id - progressableType - progressableId - amount - closeDate } } } @@ -4996,6 +4999,45 @@ export function useGetPipelinesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptio export type GetPipelinesQueryHookResult = ReturnType; export type GetPipelinesLazyQueryHookResult = ReturnType; export type GetPipelinesQueryResult = Apollo.QueryResult; +export const GetPipelineProgressDocument = gql` + query GetPipelineProgress($where: PipelineProgressWhereInput) { + findManyPipelineProgress(where: $where) { + id + progressableType + progressableId + amount + closeDate + } +} + `; + +/** + * __useGetPipelineProgressQuery__ + * + * To run a query within a React component, call `useGetPipelineProgressQuery` and pass it any options that fit your needs. + * When your component renders, `useGetPipelineProgressQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetPipelineProgressQuery({ + * variables: { + * where: // value for 'where' + * }, + * }); + */ +export function useGetPipelineProgressQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetPipelineProgressDocument, options); + } +export function useGetPipelineProgressLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetPipelineProgressDocument, options); + } +export type GetPipelineProgressQueryHookResult = ReturnType; +export type GetPipelineProgressLazyQueryHookResult = ReturnType; +export type GetPipelineProgressQueryResult = Apollo.QueryResult; export const UpdateOnePipelineProgressDocument = gql` mutation UpdateOnePipelineProgress($id: String, $amount: Int, $closeDate: DateTime) { updateOnePipelineProgress( diff --git a/front/src/modules/companies/__stories__/Board.stories.tsx b/front/src/modules/companies/__stories__/Board.stories.tsx new file mode 100644 index 000000000..2b1e3a5b9 --- /dev/null +++ b/front/src/modules/companies/__stories__/Board.stories.tsx @@ -0,0 +1,25 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { companyBoardOptions } from '@/companies/components/companyBoardOptions'; +import { EntityBoard } from '@/pipeline-progress/components/EntityBoard'; +import { BoardDecorator } from '~/testing/decorators'; +import { graphqlMocks } from '~/testing/graphqlMocks'; +import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; + +const meta: Meta = { + title: 'UI/Board/Board', + component: EntityBoard, + decorators: [BoardDecorator], +}; + +export default meta; +type Story = StoryObj; + +export const OneColumnBoard: Story = { + render: getRenderWrapperForComponent( + , + ), + parameters: { + msw: graphqlMocks, + }, +}; diff --git a/front/src/modules/companies/__stories__/CompanyBoardCard.stories.tsx b/front/src/modules/companies/__stories__/CompanyBoardCard.stories.tsx new file mode 100644 index 000000000..86345e3fc --- /dev/null +++ b/front/src/modules/companies/__stories__/CompanyBoardCard.stories.tsx @@ -0,0 +1,26 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { CompanyBoardCard } from '@/companies/components/CompanyBoardCard'; +import { BoardCardDecorator } from '~/testing/decorators'; +import { graphqlMocks } from '~/testing/graphqlMocks'; +import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; + +const meta: Meta = { + title: 'UI/Board/CompanyBoardCard', + component: CompanyBoardCard, + decorators: [BoardCardDecorator], +}; + +export default meta; +type Story = StoryObj; + +const FakeSelectableCompanyBoardCard = () => { + return ; +}; + +export const CompanyCompanyBoardCard: Story = { + render: getRenderWrapperForComponent(), + parameters: { + msw: graphqlMocks, + }, +}; diff --git a/front/src/modules/companies/components/__stories__/CompanyChip.stories.tsx b/front/src/modules/companies/__stories__/CompanyChip.stories.tsx similarity index 95% rename from front/src/modules/companies/components/__stories__/CompanyChip.stories.tsx rename to front/src/modules/companies/__stories__/CompanyChip.stories.tsx index 119682e99..f97a26fd7 100644 --- a/front/src/modules/companies/components/__stories__/CompanyChip.stories.tsx +++ b/front/src/modules/companies/__stories__/CompanyChip.stories.tsx @@ -4,7 +4,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; -import { CompanyChip } from '../CompanyChip'; +import { CompanyChip } from '../components/CompanyChip'; const meta: Meta = { title: 'Modules/Companies/CompanyChip', diff --git a/front/src/modules/companies/__stories__/mock-data.ts b/front/src/modules/companies/__stories__/mock-data.ts new file mode 100644 index 000000000..748e9e58a --- /dev/null +++ b/front/src/modules/companies/__stories__/mock-data.ts @@ -0,0 +1,42 @@ +import { Pipeline } from '~/generated/graphql'; + +export const pipeline = { + id: 'pipeline-1', + name: 'pipeline-1', + pipelineStages: [ + { + id: 'pipeline-stage-1', + name: 'New', + index: 0, + color: '#B76796', + pipelineProgresses: [ + { id: 'fe256b39-3ec3-4fe7-8998-b76aa0bfb600' }, + { id: '4a886c90-f4f2-4984-8222-882ebbb905d6' }, + ], + }, + { + id: 'pipeline-stage-2', + name: 'Screening', + index: 1, + color: '#CB912F', + }, + { + id: 'pipeline-stage-3', + name: 'Meeting', + index: 2, + color: '#9065B0', + }, + { + id: 'pipeline-stage-4', + name: 'Proposal', + index: 3, + color: '#337EA9', + }, + { + id: 'pipeline-stage-5', + name: 'Customer', + index: 4, + color: '#079039', + }, + ], +} as Pipeline; diff --git a/front/src/modules/pipeline-progress/components/CompanyBoardCard.tsx b/front/src/modules/companies/components/CompanyBoardCard.tsx similarity index 56% rename from front/src/modules/pipeline-progress/components/CompanyBoardCard.tsx rename to front/src/modules/companies/components/CompanyBoardCard.tsx index e1c614b5e..5c9c0371b 100644 --- a/front/src/modules/pipeline-progress/components/CompanyBoardCard.tsx +++ b/front/src/modules/companies/components/CompanyBoardCard.tsx @@ -1,14 +1,25 @@ +import { useCallback } from 'react'; +import { getOperationName } from '@apollo/client/utilities'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { IconCurrencyDollar } from '@tabler/icons-react'; +import { useRecoilState } from 'recoil'; +import { GET_PIPELINES } from '@/pipeline-progress/queries'; +import { BoardCardContext } from '@/pipeline-progress/states/BoardCardContext'; +import { pipelineProgressIdScopedState } from '@/pipeline-progress/states/pipelineProgressIdScopedState'; +import { selectedBoardCardsState } from '@/pipeline-progress/states/selectedBoardCardsState'; +import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; import { BoardCardEditableFieldDate } from '@/ui/board-card-field-inputs/components/BoardCardEditableFieldDate'; import { BoardCardEditableFieldText } from '@/ui/board-card-field-inputs/components/BoardCardEditableFieldText'; - -import { Company, PipelineProgress } from '../../../generated/graphql'; -import { Checkbox } from '../../ui/components/form/Checkbox'; -import { IconCalendarEvent } from '../../ui/icons'; -import { getLogoUrlFromDomainName } from '../../utils/utils'; +import { Checkbox } from '@/ui/components/form/Checkbox'; +import { IconCalendarEvent } from '@/ui/icons'; +import { getLogoUrlFromDomainName } from '@/utils/utils'; +import { + PipelineProgress, + useUpdateOnePipelineProgressMutation, +} from '~/generated/graphql'; +import { companyProgressesFamilyState } from '~/pages/opportunities/companyProgressesFamilyState'; const StyledBoardCard = styled.div<{ selected: boolean }>` background-color: ${({ theme, selected }) => @@ -59,30 +70,58 @@ const StyledBoardCardBody = styled.div` } `; -type CompanyProp = Pick< - Company, - 'id' | 'name' | 'domainName' | 'employees' | 'accountOwner' ->; - -type PipelineProgressProp = Pick< - PipelineProgress, - 'id' | 'amount' | 'closeDate' ->; - -export function CompanyBoardCard({ - company, - pipelineProgress, - selected, - onSelect, - onCardUpdate, -}: { - company: CompanyProp; - pipelineProgress: PipelineProgressProp; - selected: boolean; - onSelect: (company: CompanyProp) => void; - onCardUpdate: (pipelineProgress: PipelineProgressProp) => Promise; -}) { +export function CompanyBoardCard() { const theme = useTheme(); + + const [updatePipelineProgress] = useUpdateOnePipelineProgressMutation(); + + const [pipelineProgressId] = useRecoilScopedState( + pipelineProgressIdScopedState, + BoardCardContext, + ); + const [companyProgress] = useRecoilState( + companyProgressesFamilyState(pipelineProgressId || ''), + ); + const { pipelineProgress, company } = companyProgress || {}; + const [selectedBoardCards, setSelectedBoardCards] = useRecoilState( + selectedBoardCardsState, + ); + + const selected = selectedBoardCards.includes(pipelineProgressId || ''); + function setSelected(isSelected: boolean) { + if (isSelected) { + setSelectedBoardCards([...selectedBoardCards, pipelineProgressId || '']); + } else { + setSelectedBoardCards( + selectedBoardCards.filter((id) => id !== pipelineProgressId), + ); + } + } + + const handleCardUpdate = useCallback( + async ( + pipelineProgress: Pick, + ) => { + await updatePipelineProgress({ + variables: { + id: pipelineProgress.id, + amount: pipelineProgress.amount, + closeDate: pipelineProgress.closeDate || null, + }, + refetchQueries: [getOperationName(GET_PIPELINES) ?? ''], + }); + }, + [updatePipelineProgress], + ); + + const handleCheckboxChange = () => { + setSelected(!selected); + }; + + if (!company || !pipelineProgress) { + return null; + } + return ( @@ -93,7 +132,9 @@ export function CompanyBoardCard({ /> {company.name}
- onSelect(company)} /> +
+ +
@@ -102,7 +143,7 @@ export function CompanyBoardCard({ value={pipelineProgress.amount?.toString() || ''} placeholder="Opportunity amount" onChange={(value) => - onCardUpdate({ + handleCardUpdate({ ...pipelineProgress, amount: parseInt(value), }) @@ -114,7 +155,7 @@ export function CompanyBoardCard({ { - onCardUpdate({ + handleCardUpdate({ ...pipelineProgress, closeDate: value.toISOString(), }); diff --git a/front/src/modules/pipeline-progress/components/NewCompanyBoardCard.tsx b/front/src/modules/companies/components/NewCompanyBoardCard.tsx similarity index 75% rename from front/src/modules/pipeline-progress/components/NewCompanyBoardCard.tsx rename to front/src/modules/companies/components/NewCompanyBoardCard.tsx index 804fbbf7b..b048ef908 100644 --- a/front/src/modules/pipeline-progress/components/NewCompanyBoardCard.tsx +++ b/front/src/modules/companies/components/NewCompanyBoardCard.tsx @@ -1,22 +1,13 @@ +import { useCallback } from 'react'; + import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect'; import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery'; import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState'; import { getLogoUrlFromDomainName } from '@/utils/utils'; -import { - CommentableType, - Company, - useSearchCompanyQuery, -} from '~/generated/graphql'; +import { CommentableType, useSearchCompanyQuery } from '~/generated/graphql'; -type OwnProps = { - onEntitySelect: ( - company: Pick, - ) => void; - onCancel: () => void; -}; - -export function NewCompanyBoardCard({ onEntitySelect, onCancel }: OwnProps) { +export function NewCompanyBoardCard() { const [searchFilter] = useRecoilScopedState( relationPickerSearchFilterScopedState, ); @@ -37,10 +28,18 @@ export function NewCompanyBoardCard({ onEntitySelect, onCancel }: OwnProps) { searchOnFields: ['name'], }); + const handleEntitySelect = useCallback(async (companyId: string) => { + return; + }, []); + + function handleCancel() { + return; + } + return ( onEntitySelect(value)} - onCancel={onCancel} + onEntitySelected={(value) => handleEntitySelect(value.id)} + onCancel={handleCancel} entities={{ entitiesToSelect: companies.entitiesToSelect, selectedEntity: companies.selectedEntities[0], diff --git a/front/src/modules/companies/components/NewCompanyProgressButton.tsx b/front/src/modules/companies/components/NewCompanyProgressButton.tsx new file mode 100644 index 000000000..2d3481f6f --- /dev/null +++ b/front/src/modules/companies/components/NewCompanyProgressButton.tsx @@ -0,0 +1,135 @@ +import { useCallback, useRef, useState } from 'react'; +import { getOperationName } from '@apollo/client/utilities'; +import { useRecoilState } from 'recoil'; +import { v4 as uuidv4 } from 'uuid'; + +import { usePreviousHotkeyScope } from '@/lib/hotkeys/hooks/usePreviousHotkeyScope'; +import { GET_PIPELINES } from '@/pipeline-progress/queries'; +import { BoardColumnContext } from '@/pipeline-progress/states/BoardColumnContext'; +import { boardState } from '@/pipeline-progress/states/boardState'; +import { pipelineStageIdScopedState } from '@/pipeline-progress/states/pipelineStageIdScopedState'; +import { RecoilScope } from '@/recoil-scope/components/RecoilScope'; +import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; +import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect'; +import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery'; +import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState'; +import { RelationPickerHotkeyScope } from '@/relation-picker/types/RelationPickerHotkeyScope'; +import { BoardPipelineStageColumn } from '@/ui/board/components/Board'; +import { NewButton } from '@/ui/board/components/NewButton'; +import { getLogoUrlFromDomainName } from '@/utils/utils'; +import { + CommentableType, + PipelineProgressableType, + useCreateOnePipelineProgressMutation, + useSearchCompanyQuery, +} from '~/generated/graphql'; +import { currentPipelineState } from '~/pages/opportunities/currentPipelineState'; + +export function NewCompanyProgressButton() { + const containerRef = useRef(null); + const [isCreatingCard, setIsCreatingCard] = useState(false); + const [board, setBoard] = useRecoilState(boardState); + const [pipeline] = useRecoilState(currentPipelineState); + const [pipelineStageId] = useRecoilScopedState( + pipelineStageIdScopedState, + BoardColumnContext, + ); + + const { + goBackToPreviousHotkeyScope, + setHotkeyScopeAndMemorizePreviousScope, + } = usePreviousHotkeyScope(); + + const [createOnePipelineProgress] = useCreateOnePipelineProgressMutation({ + refetchQueries: [getOperationName(GET_PIPELINES) ?? ''], + }); + + const handleEntitySelect = useCallback( + async (company: any) => { + if (!company) return; + + setIsCreatingCard(false); + goBackToPreviousHotkeyScope(); + + const newUuid = uuidv4(); + const newBoard = JSON.parse(JSON.stringify(board)); + const destinationColumnIndex = newBoard.findIndex( + (column: BoardPipelineStageColumn) => + column.pipelineStageId === pipelineStageId, + ); + newBoard[destinationColumnIndex].pipelineProgressIds.push(newUuid); + setBoard(newBoard); + await createOnePipelineProgress({ + variables: { + uuid: newUuid, + pipelineStageId: pipelineStageId || '', + pipelineId: pipeline?.id || '', + entityId: company.id || '', + entityType: PipelineProgressableType.Company, + }, + }); + }, + [ + goBackToPreviousHotkeyScope, + board, + setBoard, + createOnePipelineProgress, + pipelineStageId, + pipeline?.id, + ], + ); + + const handleNewClick = useCallback(() => { + setIsCreatingCard(true); + setHotkeyScopeAndMemorizePreviousScope( + RelationPickerHotkeyScope.RelationPicker, + ); + }, [setIsCreatingCard, setHotkeyScopeAndMemorizePreviousScope]); + + function handleCancel() { + goBackToPreviousHotkeyScope(); + setIsCreatingCard(false); + } + + const [searchFilter] = useRecoilScopedState( + relationPickerSearchFilterScopedState, + ); + const companies = useFilteredSearchEntityQuery({ + queryHook: useSearchCompanyQuery, + selectedIds: [], + searchFilter: searchFilter, + mappingFunction: (company) => ({ + entityType: CommentableType.Company, + id: company.id, + name: company.name, + domainName: company.domainName, + avatarType: 'squared', + avatarUrl: getLogoUrlFromDomainName(company.domainName), + }), + orderByField: 'name', + searchOnFields: ['name'], + }); + + return ( + <> + {isCreatingCard && ( + +
+
+ handleEntitySelect(value)} + onCancel={handleCancel} + entities={{ + entitiesToSelect: companies.entitiesToSelect, + selectedEntity: companies.selectedEntities[0], + loading: companies.loading, + }} + /> +
+
+
+ )} + + + ); +} diff --git a/front/src/modules/companies/components/companyBoardOptions.tsx b/front/src/modules/companies/components/companyBoardOptions.tsx new file mode 100644 index 000000000..8bccfdc04 --- /dev/null +++ b/front/src/modules/companies/components/companyBoardOptions.tsx @@ -0,0 +1,9 @@ +import { CompanyBoardCard } from '@/companies/components/CompanyBoardCard'; +import { NewCompanyProgressButton } from '@/companies/components/NewCompanyProgressButton'; + +import { BoardOptions } from '../../pipeline-progress/types/BoardOptions'; + +export const companyBoardOptions: BoardOptions = { + newCardComponent: , + cardComponent: , +}; diff --git a/front/src/modules/companies/states/CompanyBoardContext.ts b/front/src/modules/companies/states/CompanyBoardContext.ts new file mode 100644 index 000000000..4ab4abd9e --- /dev/null +++ b/front/src/modules/companies/states/CompanyBoardContext.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const CompanyBoardContext = createContext(null); diff --git a/front/src/modules/companies/states/companyBoardIndexState.ts b/front/src/modules/companies/states/companyBoardIndexState.ts new file mode 100644 index 000000000..0c838433a --- /dev/null +++ b/front/src/modules/companies/states/companyBoardIndexState.ts @@ -0,0 +1,11 @@ +import { atomFamily } from 'recoil'; + +import { CompanyProgress } from '@/companies/types/CompanyProgress'; + +export const companyBoardIndexState = atomFamily< + CompanyProgress | undefined, + string +>({ + key: 'currentPipelineState', + default: undefined, +}); diff --git a/front/src/modules/companies/types/CompanyProgress.ts b/front/src/modules/companies/types/CompanyProgress.ts new file mode 100644 index 000000000..42b113ace --- /dev/null +++ b/front/src/modules/companies/types/CompanyProgress.ts @@ -0,0 +1,16 @@ +import { Company, PipelineProgress } from '~/generated/graphql'; + +export type CompanyForBoard = Pick; +export type PipelineProgressForBoard = Pick< + PipelineProgress, + 'id' | 'amount' | 'closeDate' | 'progressableId' +>; + +export type CompanyProgress = { + company: CompanyForBoard; + pipelineProgress: PipelineProgressForBoard; +}; + +export type CompanyProgressDict = { + [key: string]: CompanyProgress; +}; diff --git a/front/src/modules/pipeline-progress/components/Board.tsx b/front/src/modules/pipeline-progress/components/Board.tsx deleted file mode 100644 index 8cd7c575d..000000000 --- a/front/src/modules/pipeline-progress/components/Board.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { useCallback, useEffect, useMemo, 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/board/components/BoardColumn'; -import { Company, PipelineProgress } from '~/generated/graphql'; - -import { - Column, - getOptimisticlyUpdatedBoard, - StyledBoard, -} from '../../ui/board/components/Board'; -import { boardColumnsState } from '../states/boardColumnsState'; -import { boardItemsState } from '../states/boardItemsState'; -import { selectedBoardItemsState } from '../states/selectedBoardItemsState'; - -import { CompanyBoardCard } from './CompanyBoardCard'; -import { NewButton } from './NewButton'; - -export type CompanyProgress = { - company: Pick; - pipelineProgress: Pick; -}; - -export type CompanyProgressDict = { - [key: string]: CompanyProgress; -}; - -type BoardProps = { - pipelineId: string; - columns: Omit[]; - initialBoard: Column[]; - initialItems: CompanyProgressDict; - onCardMove?: (itemKey: string, columnId: Column['id']) => Promise; - onCardUpdate: ( - pipelineProgress: Pick, - ) => 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, - initialItems, - onCardMove, - onCardUpdate, - pipelineId, -}: BoardProps) { - const [board, setBoard] = useRecoilState(boardColumnsState); - const [boardItems, setBoardItems] = useRecoilState(boardItemsState); - const [selectedBoardItems, setSelectedBoardItems] = useRecoilState( - selectedBoardItemsState, - ); - const [isInitialBoardLoaded, setIsInitialBoardLoaded] = useState(false); - - useEffect(() => { - if (!isInitialBoardLoaded) { - setBoard(initialBoard); - } - if (Object.keys(initialItems).length > 0) { - setBoardItems(initialItems); - setIsInitialBoardLoaded(true); - } - }, [ - initialBoard, - setBoard, - initialItems, - setBoardItems, - setIsInitialBoardLoaded, - isInitialBoardLoaded, - ]); - - const calculateColumnTotals = ( - columns: Column[], - items: { - [key: string]: CompanyProgress; - }, - ): { [key: string]: number } => { - return columns.reduce<{ [key: string]: number }>((acc, column) => { - acc[column.id] = column.itemKeys.reduce( - (total: number, itemKey: string) => { - return total + (items[itemKey]?.pipelineProgress?.amount || 0); - }, - 0, - ); - return acc; - }, {}); - }; - - const columnTotals = useMemo( - () => calculateColumnTotals(board, boardItems), - [board, boardItems], - ); - - const onDragEnd: OnDragEndResponder = useCallback( - async (result) => { - const newBoard = getOptimisticlyUpdatedBoard(board, result); - if (!newBoard) return; - setBoard(newBoard); - try { - const draggedEntityId = result.draggableId; - const destinationColumnId = result.destination?.droppableId; - draggedEntityId && - destinationColumnId && - onCardMove && - (await onCardMove(draggedEntityId, destinationColumnId)); - } catch (e) { - console.error(e); - } - }, - [board, onCardMove, setBoard], - ); - - function handleSelect(itemKey: string) { - if (selectedBoardItems.includes(itemKey)) { - setSelectedBoardItems( - selectedBoardItems.filter((key) => key !== itemKey), - ); - } else { - setSelectedBoardItems([...selectedBoardItems, itemKey]); - } - } - - return board.length > 0 ? ( - - - {columns.map((column, columnIndex) => ( - - {(droppableProvided) => ( - - - {board[columnIndex].itemKeys.map( - (itemKey, index) => - boardItems[itemKey] && ( - - {(draggableProvided) => ( -
- handleSelect(itemKey)} - /> -
- )} -
- ), - )} -
- -
- )} -
- ))} -
-
- ) : ( - <> - ); -} diff --git a/front/src/modules/pipeline-progress/components/BoardActionBarButtonDeletePipelineProgress.tsx b/front/src/modules/pipeline-progress/components/BoardActionBarButtonDeletePipelineProgress.tsx index 86aabd26f..80e0c663b 100644 --- a/front/src/modules/pipeline-progress/components/BoardActionBarButtonDeletePipelineProgress.tsx +++ b/front/src/modules/pipeline-progress/components/BoardActionBarButtonDeletePipelineProgress.tsx @@ -1,39 +1,41 @@ import { getOperationName } from '@apollo/client/utilities'; import { useRecoilState } from 'recoil'; +import { boardState } from '@/pipeline-progress/states/boardState'; import { EntityTableActionBarButton } from '@/ui/components/table/action-bar/EntityTableActionBarButton'; import { IconTrash } from '@/ui/icons/index'; import { useDeleteManyPipelineProgressMutation } from '~/generated/graphql'; import { GET_PIPELINES } from '../queries'; -import { boardItemsState } from '../states/boardItemsState'; -import { selectedBoardItemsState } from '../states/selectedBoardItemsState'; +import { selectedBoardCardsState } from '../states/selectedBoardCardsState'; export function BoardActionBarButtonDeletePipelineProgress() { const [selectedBoardItems, setSelectedBoardItems] = useRecoilState( - selectedBoardItemsState, + selectedBoardCardsState, ); - const [boardItems, setBoardItems] = useRecoilState(boardItemsState); + const [board, setBoard] = useRecoilState(boardState); const [deletePipelineProgress] = useDeleteManyPipelineProgressMutation({ refetchQueries: [getOperationName(GET_PIPELINES) ?? ''], }); async function handleDeleteClick() { + setBoard( + board?.map((pipelineStage) => ({ + ...pipelineStage, + pipelineProgressIds: pipelineStage.pipelineProgressIds.filter( + (pipelineProgressId) => + !selectedBoardItems.includes(pipelineProgressId), + ), + })), + ); + + setSelectedBoardItems([]); await deletePipelineProgress({ variables: { ids: selectedBoardItems, }, }); - - setBoardItems( - Object.fromEntries( - Object.entries(boardItems).filter( - ([key]) => !selectedBoardItems.includes(key), - ), - ), - ); - setSelectedBoardItems([]); } return ( diff --git a/front/src/modules/pipeline-progress/components/EntityBoard.tsx b/front/src/modules/pipeline-progress/components/EntityBoard.tsx new file mode 100644 index 000000000..e0a4b3754 --- /dev/null +++ b/front/src/modules/pipeline-progress/components/EntityBoard.tsx @@ -0,0 +1,81 @@ +import { useCallback } from 'react'; +import { DragDropContext, 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 { boardState } from '@/pipeline-progress/states/boardState'; +import { RecoilScope } from '@/recoil-scope/components/RecoilScope'; +import { + PipelineProgress, + PipelineStage, + useUpdateOnePipelineProgressStageMutation, +} from '~/generated/graphql'; + +import { + getOptimisticlyUpdatedBoard, + StyledBoard, +} from '../../ui/board/components/Board'; +import { BoardColumnContext } from '../states/BoardColumnContext'; +import { BoardOptions } from '../types/BoardOptions'; + +import { EntityBoardColumn } from './EntityBoardColumn'; + +export function EntityBoard({ boardOptions }: { boardOptions: BoardOptions }) { + const [board, setBoard] = useRecoilState(boardState); + const [updatePipelineProgressStage] = + useUpdateOnePipelineProgressStageMutation(); + + const updatePipelineProgressStageInDB = useCallback( + async ( + pipelineProgressId: NonNullable, + pipelineStageId: NonNullable, + ) => { + updatePipelineProgressStage({ + variables: { + id: pipelineProgressId, + pipelineStageId, + }, + }); + }, + [updatePipelineProgressStage], + ); + + const onDragEnd: OnDragEndResponder = useCallback( + async (result) => { + if (!board) return; + const newBoard = getOptimisticlyUpdatedBoard(board, result); + if (!newBoard) return; + setBoard(newBoard); + try { + const draggedEntityId = result.draggableId; + const destinationColumnId = result.destination?.droppableId; + draggedEntityId && + destinationColumnId && + updatePipelineProgressStageInDB && + (await updatePipelineProgressStageInDB( + draggedEntityId, + destinationColumnId, + )); + } catch (e) { + console.error(e); + } + }, + [board, updatePipelineProgressStageInDB, setBoard], + ); + + return (board?.length ?? 0) > 0 ? ( + + + {board?.map((column) => ( + + + + ))} + + + ) : ( + <> + ); +} diff --git a/front/src/modules/pipeline-progress/components/EntityBoardActionBar.tsx b/front/src/modules/pipeline-progress/components/EntityBoardActionBar.tsx index 83f078d33..da3103e3b 100644 --- a/front/src/modules/pipeline-progress/components/EntityBoardActionBar.tsx +++ b/front/src/modules/pipeline-progress/components/EntityBoardActionBar.tsx @@ -3,13 +3,13 @@ import { useRecoilValue } from 'recoil'; import { ActionBar } from '@/ui/components/action-bar/ActionBar'; -import { selectedBoardItemsState } from '../states/selectedBoardItemsState'; +import { selectedBoardCardsState } from '../states/selectedBoardCardsState'; type OwnProps = { children: React.ReactNode | React.ReactNode[]; }; export function EntityBoardActionBar({ children }: OwnProps) { - const selectedBoardItems = useRecoilValue(selectedBoardItemsState); - return {children}; + const selectedBoardCards = useRecoilValue(selectedBoardCardsState); + return {children}; } diff --git a/front/src/modules/pipeline-progress/components/EntityBoardCard.tsx b/front/src/modules/pipeline-progress/components/EntityBoardCard.tsx new file mode 100644 index 000000000..d479bde91 --- /dev/null +++ b/front/src/modules/pipeline-progress/components/EntityBoardCard.tsx @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; +import { Draggable } from '@hello-pangea/dnd'; + +import { BoardCardContext } from '@/pipeline-progress/states/BoardCardContext'; +import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; + +import { pipelineProgressIdScopedState } from '../states/pipelineProgressIdScopedState'; +import { BoardOptions } from '../types/BoardOptions'; + +export function EntityBoardCard({ + boardOptions, + pipelineProgressId, + index, +}: { + boardOptions: BoardOptions; + pipelineProgressId: string; + index: number; +}) { + const [pipelineProgressIdFromRecoil, setPipelineProgressId] = + useRecoilScopedState(pipelineProgressIdScopedState, BoardCardContext); + + useEffect(() => { + if (pipelineProgressIdFromRecoil !== pipelineProgressId) { + setPipelineProgressId(pipelineProgressId); + } + }, [pipelineProgressId, setPipelineProgressId, pipelineProgressIdFromRecoil]); + + return ( + + {(draggableProvided) => ( +
+ {boardOptions.cardComponent} +
+ )} +
+ ); +} diff --git a/front/src/modules/pipeline-progress/components/EntityBoardColumn.tsx b/front/src/modules/pipeline-progress/components/EntityBoardColumn.tsx new file mode 100644 index 000000000..1f61f3d43 --- /dev/null +++ b/front/src/modules/pipeline-progress/components/EntityBoardColumn.tsx @@ -0,0 +1,80 @@ +import { useEffect } from 'react'; +import styled from '@emotion/styled'; +import { Droppable, DroppableProvided } from '@hello-pangea/dnd'; + +import { BoardCardContext } from '@/pipeline-progress/states/BoardCardContext'; +import { RecoilScope } from '@/recoil-scope/components/RecoilScope'; +import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; +import { BoardPipelineStageColumn } from '@/ui/board/components/Board'; +import { BoardColumn } from '@/ui/board/components/BoardColumn'; + +import { BoardColumnContext } from '../states/BoardColumnContext'; +import { pipelineStageIdScopedState } from '../states/pipelineStageIdScopedState'; +import { BoardOptions } from '../types/BoardOptions'; + +import { EntityBoardCard } from './EntityBoardCard'; + +const StyledPlaceholder = styled.div` + min-height: 1px; +`; + +const BoardColumnCardsContainer = ({ + children, + droppableProvided, +}: { + children: React.ReactNode; + droppableProvided: DroppableProvided; +}) => { + return ( +
+ {children} + {droppableProvided?.placeholder} +
+ ); +}; + +export function EntityBoardColumn({ + column, + boardOptions, +}: { + column: BoardPipelineStageColumn; + boardOptions: BoardOptions; +}) { + const [pipelineStageId, setPipelineStageId] = useRecoilScopedState( + pipelineStageIdScopedState, + BoardColumnContext, + ); + + useEffect(() => { + if (pipelineStageId !== column.pipelineStageId) { + setPipelineStageId(column.pipelineStageId); + } + }, [column, setPipelineStageId, pipelineStageId]); + + return ( + + {(droppableProvided) => ( + + + {column.pipelineProgressIds.map((pipelineProgressId, index) => ( + + + + ))} + + {boardOptions.newCardComponent} + + )} + + ); +} diff --git a/front/src/modules/pipeline-progress/components/NewButton.tsx b/front/src/modules/pipeline-progress/components/NewButton.tsx deleted file mode 100644 index 5015d3eb6..000000000 --- a/front/src/modules/pipeline-progress/components/NewButton.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useCallback, useRef, useState } from 'react'; -import { getOperationName } from '@apollo/client/utilities'; -import { useRecoilState } from 'recoil'; -import { v4 as uuidv4 } from 'uuid'; - -import { usePreviousHotkeyScope } from '@/lib/hotkeys/hooks/usePreviousHotkeyScope'; -import { RecoilScope } from '@/recoil-scope/components/RecoilScope'; -import { RelationPickerHotkeyScope } from '@/relation-picker/types/RelationPickerHotkeyScope'; -import { Column } from '@/ui/board/components/Board'; -import { NewButton as UINewButton } from '@/ui/board/components/NewButton'; -import { - Company, - PipelineProgressableType, - useCreateOnePipelineProgressMutation, -} from '~/generated/graphql'; - -import { GET_PIPELINES } from '../queries'; -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 containerRef = useRef(null); - const [isCreatingCard, setIsCreatingCard] = useState(false); - const [board, setBoard] = useRecoilState(boardColumnsState); - const [boardItems, setBoardItems] = useRecoilState(boardItemsState); - - const { - goBackToPreviousHotkeyScope, - setHotkeyScopeAndMemorizePreviousScope, - } = usePreviousHotkeyScope(); - - const [createOnePipelineProgress] = useCreateOnePipelineProgressMutation({ - refetchQueries: [getOperationName(GET_PIPELINES) ?? ''], - }); - - const handleEntitySelect = useCallback( - async (company: Pick) => { - if (!company) return; - - setIsCreatingCard(false); - goBackToPreviousHotkeyScope(); - - const newUuid = uuidv4(); - const newBoard = JSON.parse(JSON.stringify(board)); - const destinationColumnIndex = newBoard.findIndex( - (column: Column) => column.id === columnId, - ); - newBoard[destinationColumnIndex].itemKeys.push(newUuid); - setBoardItems({ - ...boardItems, - [newUuid]: { - company, - pipelineProgress: { - id: newUuid, - amount: 0, - }, - }, - }); - setBoard(newBoard); - await createOnePipelineProgress({ - variables: { - uuid: newUuid, - pipelineStageId: columnId, - pipelineId, - entityId: company.id, - entityType: PipelineProgressableType.Company, - }, - }); - }, - [ - createOnePipelineProgress, - columnId, - pipelineId, - board, - setBoard, - boardItems, - setBoardItems, - goBackToPreviousHotkeyScope, - ], - ); - - const handleNewClick = useCallback(() => { - setIsCreatingCard(true); - setHotkeyScopeAndMemorizePreviousScope( - RelationPickerHotkeyScope.RelationPicker, - ); - }, [setIsCreatingCard, setHotkeyScopeAndMemorizePreviousScope]); - - function handleCancel() { - goBackToPreviousHotkeyScope(); - setIsCreatingCard(false); - } - - return ( - <> - {isCreatingCard && ( - -
- -
-
- )} - - - ); -} diff --git a/front/src/modules/pipeline-progress/components/__stories__/Board.stories.tsx b/front/src/modules/pipeline-progress/components/__stories__/Board.stories.tsx deleted file mode 100644 index 7595111e2..000000000 --- a/front/src/modules/pipeline-progress/components/__stories__/Board.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; - -import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; - -import { Board } from '../Board'; - -import { initialBoard, items } from './mock-data'; - -const meta: Meta = { - title: 'UI/Board/Board', - component: Board, -}; - -export default meta; -type Story = StoryObj; - -export const OneColumnBoard: Story = { - render: getRenderWrapperForComponent( - {}} // eslint-disable-line @typescript-eslint/no-empty-function - />, - ), -}; diff --git a/front/src/modules/pipeline-progress/components/__stories__/CompanyBoardCard.stories.tsx b/front/src/modules/pipeline-progress/components/__stories__/CompanyBoardCard.stories.tsx deleted file mode 100644 index c3632c493..000000000 --- a/front/src/modules/pipeline-progress/components/__stories__/CompanyBoardCard.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -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 = { - title: 'UI/Board/CompanyBoardCard', - component: CompanyBoardCard, -}; - -export default meta; -type Story = StoryObj; - -const FakeSelectableCompanyBoardCard = () => { - const [selected, setSelected] = useState(false); - - return ( - setSelected(!selected)} - onCardUpdate={async (_) => {}} // eslint-disable-line @typescript-eslint/no-empty-function - /> - ); -}; - -export const CompanyCompanyBoardCard: Story = { - render: getRenderWrapperForComponent(), -}; diff --git a/front/src/modules/pipeline-progress/components/__stories__/mock-data.ts b/front/src/modules/pipeline-progress/components/__stories__/mock-data.ts deleted file mode 100644 index f237c0e44..000000000 --- a/front/src/modules/pipeline-progress/components/__stories__/mock-data.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Column } from '@/ui/board/components/Board'; -import { mockedCompaniesData } from '~/testing/mock-data/companies'; - -import { CompanyProgressDict } from '../Board'; - -export const items: CompanyProgressDict = { - '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 }, - }, -}; - -export const initialBoard = [ - { - id: 'column-1', - title: 'New', - colorCode: '#B76796', - itemKeys: [ - 'item-1', - 'item-2', - 'item-3', - 'item-4', - 'item-7', - 'item-8', - 'item-9', - ], - }, - { - id: 'column-2', - title: 'Screening', - colorCode: '#CB912F', - itemKeys: ['item-5', 'item-6'], - }, - { - id: 'column-3', - colorCode: '#9065B0', - title: 'Meeting', - itemKeys: [], - }, - { - id: 'column-4', - title: 'Proposal', - colorCode: '#337EA9', - itemKeys: [], - }, - { - id: 'column-5', - colorCode: '#079039', - title: 'Customer', - itemKeys: [], - }, -] satisfies Column[]; diff --git a/front/src/modules/pipeline-progress/hooks/useBoard.ts b/front/src/modules/pipeline-progress/hooks/useBoard.ts deleted file mode 100644 index 4ed764bcc..000000000 --- a/front/src/modules/pipeline-progress/hooks/useBoard.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - Company, - PipelineProgress, - useGetCompaniesQuery, - useGetPipelinesQuery, -} from '../../../generated/graphql'; -import { Column } from '../../ui/board/components/Board'; - -type ItemCompany = Pick; -type ItemPipelineProgress = Pick< - PipelineProgress, - 'id' | 'amount' | 'progressableId' ->; - -type Item = { - company: ItemCompany; - pipelineProgress: ItemPipelineProgress; -}; -type Items = { [key: string]: Item }; - -export function useBoard(pipelineId: string) { - const pipelines = useGetPipelinesQuery({ - variables: { where: { id: { equals: pipelineId } } }, - skip: pipelineId === '', - }); - const pipelineStages = pipelines.data?.findManyPipeline[0]?.pipelineStages; - const orderedPipelineStages = pipelineStages - ? [...pipelineStages].sort((a, b) => { - if (!a.index || !b.index) return 0; - return a.index - b.index; - }) - : []; - - const initialBoard: Column[] = - orderedPipelineStages?.map((pipelineStage) => ({ - id: pipelineStage.id, - title: pipelineStage.name, - colorCode: pipelineStage.color, - itemKeys: - pipelineStage.pipelineProgresses?.map((item) => item.id as string) || - [], - })) || []; - - const pipelineProgresses = orderedPipelineStages?.reduce( - (acc, pipelineStage) => [ - ...acc, - ...(pipelineStage.pipelineProgresses || []), - ], - [] as ItemPipelineProgress[], - ); - - const entitiesQueryResult = useGetCompaniesQuery({ - variables: { - where: { - id: { in: pipelineProgresses?.map((item) => item.progressableId) }, - }, - }, - }); - - const indexCompanyByIdReducer = ( - acc: { [key: string]: ItemCompany }, - entity: ItemCompany, - ) => ({ - ...acc, - [entity.id]: entity, - }); - - const companiesDict = entitiesQueryResult.data?.companies.reduce( - indexCompanyByIdReducer, - {} as { [key: string]: ItemCompany }, - ); - - const items = pipelineProgresses?.reduce((acc, pipelineProgress) => { - if (companiesDict?.[pipelineProgress.progressableId]) { - acc[pipelineProgress.id] = { - pipelineProgress, - company: companiesDict[pipelineProgress.progressableId], - }; - } - return acc; - }, {} as Items); - - return { - initialBoard, - items, - loading: pipelines.loading || entitiesQueryResult.loading, - error: pipelines.error || entitiesQueryResult.error, - }; -} diff --git a/front/src/modules/pipeline-progress/queries/index.ts b/front/src/modules/pipeline-progress/queries/index.ts index 90adcc299..bc9a98009 100644 --- a/front/src/modules/pipeline-progress/queries/index.ts +++ b/front/src/modules/pipeline-progress/queries/index.ts @@ -13,16 +13,24 @@ export const GET_PIPELINES = gql` index pipelineProgresses { id - progressableType - progressableId - amount - closeDate } } } } `; +export const GET_PIPELINE_PROGRESS = gql` + query GetPipelineProgress($where: PipelineProgressWhereInput) { + findManyPipelineProgress(where: $where) { + id + progressableType + progressableId + amount + closeDate + } + } +`; + export const UPDATE_PIPELINE_PROGRESS = gql` mutation UpdateOnePipelineProgress( $id: String diff --git a/front/src/modules/pipeline-progress/states/BoardCardContext.ts b/front/src/modules/pipeline-progress/states/BoardCardContext.ts new file mode 100644 index 000000000..4d518faf7 --- /dev/null +++ b/front/src/modules/pipeline-progress/states/BoardCardContext.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const BoardCardContext = createContext(null); diff --git a/front/src/modules/pipeline-progress/states/BoardColumnContext.ts b/front/src/modules/pipeline-progress/states/BoardColumnContext.ts new file mode 100644 index 000000000..beaa790c6 --- /dev/null +++ b/front/src/modules/pipeline-progress/states/BoardColumnContext.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const BoardColumnContext = createContext(null); diff --git a/front/src/modules/pipeline-progress/states/boardColumnsState.ts b/front/src/modules/pipeline-progress/states/boardColumnsState.ts index 90f823417..c70e1ce54 100644 --- a/front/src/modules/pipeline-progress/states/boardColumnsState.ts +++ b/front/src/modules/pipeline-progress/states/boardColumnsState.ts @@ -1,8 +1,8 @@ import { atom } from 'recoil'; -import { Column } from '@/ui/board/components/Board'; +import { BoardPipelineStageColumn } from '@/ui/board/components/Board'; -export const boardColumnsState = atom({ +export const boardColumnsState = atom({ key: 'boardColumnsState', default: [], }); diff --git a/front/src/modules/pipeline-progress/states/boardItemsState.ts b/front/src/modules/pipeline-progress/states/boardItemsState.ts deleted file mode 100644 index 344d8a109..000000000 --- a/front/src/modules/pipeline-progress/states/boardItemsState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { atom } from 'recoil'; - -import { CompanyProgressDict } from '../components/Board'; - -export const boardItemsState = atom({ - key: 'boardItemsState', - default: {}, -}); diff --git a/front/src/modules/pipeline-progress/states/boardState.ts b/front/src/modules/pipeline-progress/states/boardState.ts new file mode 100644 index 000000000..22498827a --- /dev/null +++ b/front/src/modules/pipeline-progress/states/boardState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +import { BoardPipelineStageColumn } from '@/ui/board/components/Board'; + +export const boardState = atom({ + key: 'boardState', + default: undefined, +}); diff --git a/front/src/modules/pipeline-progress/states/currentPipelineState.ts b/front/src/modules/pipeline-progress/states/currentPipelineState.ts new file mode 100644 index 000000000..c80fb0782 --- /dev/null +++ b/front/src/modules/pipeline-progress/states/currentPipelineState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +import { Pipeline } from '~/generated/graphql'; + +export const currentPipelineState = atom({ + key: 'currentPipelineState', + default: undefined, +}); diff --git a/front/src/modules/pipeline-progress/states/isBoardLoadedState.ts b/front/src/modules/pipeline-progress/states/isBoardLoadedState.ts new file mode 100644 index 000000000..541c5e90e --- /dev/null +++ b/front/src/modules/pipeline-progress/states/isBoardLoadedState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const isBoardLoadedState = atom({ + key: 'isBoardLoadedState', + default: false, +}); diff --git a/front/src/modules/pipeline-progress/states/pipelineProgressIdScopedState.ts b/front/src/modules/pipeline-progress/states/pipelineProgressIdScopedState.ts new file mode 100644 index 000000000..0b0cef3d4 --- /dev/null +++ b/front/src/modules/pipeline-progress/states/pipelineProgressIdScopedState.ts @@ -0,0 +1,6 @@ +import { atomFamily } from 'recoil'; + +export const pipelineProgressIdScopedState = atomFamily({ + key: 'pipelineProgressIdScopedState', + default: null, +}); diff --git a/front/src/modules/pipeline-progress/states/pipelineStageIdScopedState.ts b/front/src/modules/pipeline-progress/states/pipelineStageIdScopedState.ts new file mode 100644 index 000000000..72c7c472b --- /dev/null +++ b/front/src/modules/pipeline-progress/states/pipelineStageIdScopedState.ts @@ -0,0 +1,6 @@ +import { atomFamily } from 'recoil'; + +export const pipelineStageIdScopedState = atomFamily({ + key: 'pipelineStageIdScopedState', + default: null, +}); diff --git a/front/src/modules/pipeline-progress/states/selectedBoardCardsState.ts b/front/src/modules/pipeline-progress/states/selectedBoardCardsState.ts new file mode 100644 index 000000000..a658bcb03 --- /dev/null +++ b/front/src/modules/pipeline-progress/states/selectedBoardCardsState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const selectedBoardCardsState = atom({ + key: 'isBoardCardSelectedFamilyState', + default: [], +}); diff --git a/front/src/modules/pipeline-progress/states/selectedBoardItemsState.ts b/front/src/modules/pipeline-progress/states/selectedBoardItemsState.ts deleted file mode 100644 index b8871d569..000000000 --- a/front/src/modules/pipeline-progress/states/selectedBoardItemsState.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { atom } from 'recoil'; - -export const selectedBoardItemsState = atom({ - key: 'selectedBoardItemsState', - default: [], -}); diff --git a/front/src/modules/pipeline-progress/types/BoardOptions.ts b/front/src/modules/pipeline-progress/types/BoardOptions.ts new file mode 100644 index 000000000..8f0a97874 --- /dev/null +++ b/front/src/modules/pipeline-progress/types/BoardOptions.ts @@ -0,0 +1,4 @@ +export type BoardOptions = { + newCardComponent: React.ReactNode; + cardComponent: React.ReactNode; +}; diff --git a/front/src/modules/ui/board/components/Board.tsx b/front/src/modules/ui/board/components/Board.tsx index fed05f785..063d783da 100644 --- a/front/src/modules/ui/board/components/Board.tsx +++ b/front/src/modules/ui/board/components/Board.tsx @@ -10,43 +10,46 @@ export const StyledBoard = styled.div` width: 100%; `; -export type Column = { - id: string; +export type BoardPipelineStageColumn = { + pipelineStageId: string; title: string; colorCode?: string; - itemKeys: string[]; + pipelineProgressIds: string[]; }; export function getOptimisticlyUpdatedBoard( - board: Column[], + board: BoardPipelineStageColumn[], result: DropResult, ) { + // TODO: review any types const newBoard = JSON.parse(JSON.stringify(board)); const { destination, source } = result; if (!destination) return; const sourceColumnIndex = newBoard.findIndex( - (column: Column) => column.id === source.droppableId, + (column: BoardPipelineStageColumn) => + column.pipelineStageId === source.droppableId, ); const sourceColumn = newBoard[sourceColumnIndex]; const destinationColumnIndex = newBoard.findIndex( - (column: Column) => column.id === destination.droppableId, + (column: BoardPipelineStageColumn) => + column.pipelineStageId === destination.droppableId, ); const destinationColumn = newBoard[destinationColumnIndex]; if (!destinationColumn || !sourceColumn) return; - const sourceItems = sourceColumn.itemKeys; - const destinationItems = destinationColumn.itemKeys; + const sourceItems = sourceColumn.pipelineProgressIds; + const destinationItems = destinationColumn.pipelineProgressIds; const [removed] = sourceItems.splice(source.index, 1); destinationItems.splice(destination.index, 0, removed); - const newSourceColumn = { + const newSourceColumn: BoardPipelineStageColumn = { ...sourceColumn, - itemKeys: sourceItems, + pipelineProgressIds: sourceItems, }; const newDestinationColumn = { ...destinationColumn, - itemKeys: destinationItems, + pipelineProgressIds: destinationItems, }; newBoard.splice(sourceColumnIndex, 1, newSourceColumn); diff --git a/front/src/modules/ui/board/components/BoardColumn.tsx b/front/src/modules/ui/board/components/BoardColumn.tsx index 5fb24faa0..a28ac304c 100644 --- a/front/src/modules/ui/board/components/BoardColumn.tsx +++ b/front/src/modules/ui/board/components/BoardColumn.tsx @@ -33,16 +33,14 @@ export const StyledAmount = styled.div` type OwnProps = { colorCode?: string; title: string; - amount: number; children: React.ReactNode; }; -export function BoardColumn({ colorCode, title, amount, children }: OwnProps) { +export function BoardColumn({ colorCode, title, children }: OwnProps) { return ( • {title} - {!!amount && ${amount}} {children} diff --git a/front/src/modules/ui/board/components/__tests__/Board.test.ts b/front/src/modules/ui/board/components/__tests__/Board.test.ts index da712fcda..ce6d97a56 100644 --- a/front/src/modules/ui/board/components/__tests__/Board.test.ts +++ b/front/src/modules/ui/board/components/__tests__/Board.test.ts @@ -25,12 +25,14 @@ describe('getOptimisticlyUpdatedBoard', () => { { id: 'column-1', title: 'My Column', - itemKeys: initialColumn1, + pipelineStageId: 'column-1', + pipelineProgressIds: initialColumn1, }, { id: 'column-2', title: 'My Column', - itemKeys: initialColumn2, + pipelineStageId: 'column-2', + pipelineProgressIds: initialColumn2, }, ]; @@ -40,12 +42,14 @@ describe('getOptimisticlyUpdatedBoard', () => { { id: 'column-1', title: 'My Column', - itemKeys: finalColumn1, + pipelineStageId: 'column-1', + pipelineProgressIds: finalColumn1, }, { id: 'column-2', title: 'My Column', - itemKeys: finalColumn2, + pipelineStageId: 'column-2', + pipelineProgressIds: finalColumn2, }, ]; diff --git a/front/src/pages/opportunities/HookCompanyBoard.tsx b/front/src/pages/opportunities/HookCompanyBoard.tsx new file mode 100644 index 000000000..8dbd20ac4 --- /dev/null +++ b/front/src/pages/opportunities/HookCompanyBoard.tsx @@ -0,0 +1,138 @@ +import { useEffect } from 'react'; +import { useRecoilCallback, useRecoilState } from 'recoil'; + +import { + CompanyForBoard, + CompanyProgress, + PipelineProgressForBoard, +} from '@/companies/types/CompanyProgress'; +import { BoardPipelineStageColumn } from '@/ui/board/components/Board'; +import { + Pipeline, + PipelineStage, + useGetCompaniesQuery, + useGetPipelineProgressQuery, + useGetPipelinesQuery, +} from '~/generated/graphql'; + +import { boardState } from '../../modules/pipeline-progress/states/boardState'; + +import { companyProgressesFamilyState } from './companyProgressesFamilyState'; +import { currentPipelineState } from './currentPipelineState'; +import { isBoardLoadedState } from './isBoardLoadedState'; +export function HookCompanyBoard() { + const [currentPipeline, setCurrentPipeline] = + useRecoilState(currentPipelineState); + + const [, setBoard] = useRecoilState(boardState); + + const [, setIsBoardLoaded] = useRecoilState(isBoardLoadedState); + + useGetPipelinesQuery({ + onCompleted: async (data) => { + const pipeline = data?.findManyPipeline[0] as Pipeline; + setCurrentPipeline(pipeline); + const pipelineStages = pipeline?.pipelineStages; + const orderedPipelineStages = pipelineStages + ? [...pipelineStages].sort((a, b) => { + if (!a.index || !b.index) return 0; + return a.index - b.index; + }) + : []; + const initialBoard: BoardPipelineStageColumn[] = + orderedPipelineStages?.map((pipelineStage) => ({ + pipelineStageId: pipelineStage.id, + title: pipelineStage.name, + colorCode: pipelineStage.color, + pipelineProgressIds: + pipelineStage.pipelineProgresses?.map( + (item) => item.id as string, + ) || [], + })) || []; + setBoard(initialBoard); + setIsBoardLoaded(true); + }, + }); + + const pipelineProgressIds = currentPipeline?.pipelineStages + ?.map((pipelineStage: PipelineStage) => + ( + pipelineStage.pipelineProgresses?.map((item) => item.id as string) || [] + ).flat(), + ) + .flat(); + + const pipelineProgressesQuery = useGetPipelineProgressQuery({ + variables: { + where: { + id: { in: pipelineProgressIds }, + }, + }, + }); + + const pipelineProgresses = + pipelineProgressesQuery.data?.findManyPipelineProgress || []; + + const entitiesQueryResult = useGetCompaniesQuery({ + variables: { + where: { + id: { + in: pipelineProgresses.map((item) => item.progressableId), + }, + }, + }, + }); + + const indexCompanyByIdReducer = ( + acc: { [key: string]: CompanyForBoard }, + company: CompanyForBoard, + ) => ({ + ...acc, + [company.id]: company, + }); + + const companiesDict = + entitiesQueryResult.data?.companies.reduce( + indexCompanyByIdReducer, + {} as { [key: string]: CompanyForBoard }, + ) || {}; + + const indexPipelineProgressByIdReducer = ( + acc: { + [key: string]: CompanyProgress; + }, + pipelineProgress: PipelineProgressForBoard, + ) => { + const company = companiesDict[pipelineProgress.progressableId]; + return { + ...acc, + [pipelineProgress.id]: { + pipelineProgress, + company, + }, + }; + }; + const companyBoardIndex = pipelineProgresses.reduce( + indexPipelineProgressByIdReducer, + {} as { [key: string]: CompanyProgress }, + ); + + const synchronizeCompanyProgresses = useRecoilCallback( + ({ set }) => + (companyBoardIndex: { [key: string]: CompanyProgress }) => { + Object.entries(companyBoardIndex).forEach(([id, companyProgress]) => { + set(companyProgressesFamilyState(id), companyProgress); + }); + }, + [], + ); + + const loading = + entitiesQueryResult.loading || pipelineProgressesQuery.loading; + + useEffect(() => { + !loading && synchronizeCompanyProgresses(companyBoardIndex); + }, [loading, companyBoardIndex, synchronizeCompanyProgresses]); + + return <>; +} diff --git a/front/src/pages/opportunities/Opportunities.tsx b/front/src/pages/opportunities/Opportunities.tsx index 93f60d90e..8a6a8bf35 100644 --- a/front/src/pages/opportunities/Opportunities.tsx +++ b/front/src/pages/opportunities/Opportunities.tsx @@ -1,97 +1,31 @@ -import { useCallback, useMemo } from 'react'; -import { getOperationName } from '@apollo/client/utilities'; import { useTheme } from '@emotion/react'; +import { companyBoardOptions } from '@/companies/components/companyBoardOptions'; +import { CompanyBoardContext } from '@/companies/states/CompanyBoardContext'; import { BoardActionBarButtonDeletePipelineProgress } from '@/pipeline-progress/components/BoardActionBarButtonDeletePipelineProgress'; +import { EntityBoard } from '@/pipeline-progress/components/EntityBoard'; import { EntityBoardActionBar } from '@/pipeline-progress/components/EntityBoardActionBar'; -import { GET_PIPELINES } from '@/pipeline-progress/queries'; +import { RecoilScope } from '@/recoil-scope/components/RecoilScope'; import { IconTargetArrow } from '@/ui/icons/index'; import { WithTopBarContainer } from '@/ui/layout/containers/WithTopBarContainer'; -import { - PipelineProgress, - PipelineStage, - useGetPipelinesQuery, - useUpdateOnePipelineProgressMutation, - useUpdateOnePipelineProgressStageMutation, -} from '../../generated/graphql'; -import { Board } from '../../modules/pipeline-progress/components/Board'; -import { useBoard } from '../../modules/pipeline-progress/hooks/useBoard'; +import { HookCompanyBoard } from './HookCompanyBoard'; export function Opportunities() { const theme = useTheme(); - const pipelines = useGetPipelinesQuery(); - const pipelineId = pipelines.data?.findManyPipeline[0]?.id; - - const { initialBoard, items } = useBoard(pipelineId || ''); - - const columns = useMemo( - () => - initialBoard?.map(({ id, colorCode, title }) => ({ - id, - colorCode, - title, - })), - [initialBoard], - ); - const [updatePipelineProgress] = useUpdateOnePipelineProgressMutation(); - const [updatePipelineProgressStage] = - useUpdateOnePipelineProgressStageMutation(); - - const handleCardUpdate = useCallback( - async ( - pipelineProgress: Pick, - ) => { - await updatePipelineProgress({ - variables: { - id: pipelineProgress.id, - amount: pipelineProgress.amount, - closeDate: pipelineProgress.closeDate || null, - }, - refetchQueries: [getOperationName(GET_PIPELINES) ?? ''], - }); - }, - [updatePipelineProgress], - ); - - const handleCardMove = useCallback( - async ( - pipelineProgressId: NonNullable, - pipelineStageId: NonNullable, - ) => { - updatePipelineProgressStage({ - variables: { - id: pipelineProgressId, - pipelineStageId, - }, - }); - }, - [updatePipelineProgressStage], - ); - return ( } > - {items && pipelineId ? ( - <> - - - - - - ) : ( - <> - )} + + + + + + + ); } diff --git a/front/src/pages/opportunities/companyProgressesFamilyState.ts b/front/src/pages/opportunities/companyProgressesFamilyState.ts new file mode 100644 index 000000000..5b920ef30 --- /dev/null +++ b/front/src/pages/opportunities/companyProgressesFamilyState.ts @@ -0,0 +1,11 @@ +import { atomFamily } from 'recoil'; + +import { CompanyProgress } from '@/companies/types/CompanyProgress'; + +export const companyProgressesFamilyState = atomFamily< + CompanyProgress | undefined, + string +>({ + key: 'companyProgressesFamilyState', + default: undefined, +}); diff --git a/front/src/pages/opportunities/currentPipelineState.ts b/front/src/pages/opportunities/currentPipelineState.ts new file mode 100644 index 000000000..c80fb0782 --- /dev/null +++ b/front/src/pages/opportunities/currentPipelineState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +import { Pipeline } from '~/generated/graphql'; + +export const currentPipelineState = atom({ + key: 'currentPipelineState', + default: undefined, +}); diff --git a/front/src/pages/opportunities/isBoardLoadedState.ts b/front/src/pages/opportunities/isBoardLoadedState.ts new file mode 100644 index 000000000..541c5e90e --- /dev/null +++ b/front/src/pages/opportunities/isBoardLoadedState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const isBoardLoadedState = atom({ + key: 'isBoardLoadedState', + default: false, +}); diff --git a/front/src/testing/decorators.tsx b/front/src/testing/decorators.tsx index d8788f2c6..f3086e12e 100644 --- a/front/src/testing/decorators.tsx +++ b/front/src/testing/decorators.tsx @@ -1,7 +1,16 @@ +import { useEffect } from 'react'; import { ApolloProvider } from '@apollo/client'; import { Decorator } from '@storybook/react'; import { RecoilRoot } from 'recoil'; +import { pipeline } from '@/companies/__stories__/mock-data'; +import { CompanyBoardContext } from '@/companies/states/CompanyBoardContext'; +import { BoardCardContext } from '@/pipeline-progress/states/BoardCardContext'; +import { BoardColumnContext } from '@/pipeline-progress/states/BoardColumnContext'; +import { pipelineProgressIdScopedState } from '@/pipeline-progress/states/pipelineProgressIdScopedState'; +import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; +import { HookCompanyBoard } from '~/pages/opportunities/HookCompanyBoard'; + import { RecoilScope } from '../modules/recoil-scope/components/RecoilScope'; import { CellContext } from '../modules/ui/tables/states/CellContext'; import { RowContext } from '../modules/ui/tables/states/RowContext'; @@ -30,3 +39,41 @@ export const CellPositionDecorator: Decorator = (Story) => ( ); + +export const BoardDecorator: Decorator = (Story) => ( + <> + + + + + +); + +function HookLoadFakeBoardContextState() { + const [, setPipelineProgressId] = useRecoilScopedState( + pipelineProgressIdScopedState, + BoardCardContext, + ); + const pipelineProgress = + pipeline?.pipelineStages?.[0]?.pipelineProgresses?.[0]; + useEffect(() => { + setPipelineProgressId(pipelineProgress?.id || ''); + }, [pipelineProgress?.id, setPipelineProgressId]); + return <>; +} + +export const BoardCardDecorator: Decorator = (Story) => { + return ( + <> + + + + + + + + + + + ); +}; diff --git a/front/src/testing/graphqlMocks.ts b/front/src/testing/graphqlMocks.ts index c6506317c..bcedb2ab8 100644 --- a/front/src/testing/graphqlMocks.ts +++ b/front/src/testing/graphqlMocks.ts @@ -5,7 +5,10 @@ import { CREATE_EVENT } from '@/analytics/services'; import { GET_CLIENT_CONFIG } from '@/client-config/queries'; import { GET_COMPANIES } from '@/companies/services'; import { GET_PEOPLE, UPDATE_PERSON } from '@/people/services'; -import { GET_PIPELINES } from '@/pipeline-progress/queries'; +import { + GET_PIPELINE_PROGRESS, + GET_PIPELINES, +} from '@/pipeline-progress/queries'; import { SEARCH_COMPANY_QUERY, SEARCH_USER_QUERY, @@ -20,6 +23,7 @@ import { import { mockedCompaniesData } from './mock-data/companies'; import { mockedPeopleData } from './mock-data/people'; +import { mockedPipelineProgressData } from './mock-data/pipeline-progress'; import { mockedPipelinesData } from './mock-data/pipelines'; import { mockedUsersData } from './mock-data/users'; import { filterAndSortData, updateOneFromData } from './mock-data'; @@ -113,6 +117,16 @@ export const graphqlMocks = [ }), ); }), + graphql.query( + getOperationName(GET_PIPELINE_PROGRESS) ?? '', + (req, res, ctx) => { + return res( + ctx.data({ + findManyPipelineProgress: mockedPipelineProgressData, + }), + ); + }, + ), graphql.mutation(getOperationName(CREATE_EVENT) ?? '', (req, res, ctx) => { return res( ctx.data({ diff --git a/front/src/testing/mock-data/pipeline-progress.ts b/front/src/testing/mock-data/pipeline-progress.ts index baa9e9381..5ea09e9c0 100644 --- a/front/src/testing/mock-data/pipeline-progress.ts +++ b/front/src/testing/mock-data/pipeline-progress.ts @@ -2,7 +2,7 @@ import { PipelineProgress, User } from '../../generated/graphql'; type MockedPipelineProgress = Pick< PipelineProgress, - 'id' | 'amount' | 'closeDate' + 'id' | 'amount' | 'closeDate' | 'progressableId' > & { accountOwner: Pick< User, @@ -10,17 +10,34 @@ type MockedPipelineProgress = Pick< > | null; }; +const accountOwner = { + email: 'charles@test.com', + displayName: 'Charles Test', + firstName: 'Charles', + lastName: 'Test', + id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', +}; + export const mockedPipelineProgressData: Array = [ { id: '0ac8761c-1ad6-11ee-be56-0242ac120002', amount: 78, - accountOwner: { - email: 'charles@test.com', - displayName: 'Charles Test', - firstName: 'Charles', - lastName: 'Test', - id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', - __typename: 'User', - }, + closeDate: '2021-10-01T00:00:00.000Z', + progressableId: '0', + accountOwner: accountOwner, + }, + { + id: 'fe256b39-3ec3-4fe7-8998-b76aa0bfb600', + progressableId: '89bb825c-171e-4bcc-9cf7-43448d6fb278', + amount: 7, + closeDate: '2021-10-01T00:00:00.000Z', + accountOwner, + }, + { + id: '4a886c90-f4f2-4984-8222-882ebbb905d6', + progressableId: 'b396e6b9-dc5c-4643-bcff-61b6cf7523ae', + amount: 100, + closeDate: '2021-10-01T00:00:00.000Z', + accountOwner, }, ];