mirror of
				https://github.com/lingble/twenty.git
				synced 2025-11-04 06:37:56 +00:00 
			
		
		
		
	Add ability to associate a new company to pipeline (#350)
* Add ability to associate a new company to pipeline * Fix tests
This commit is contained in:
		@@ -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<PipelineWhereInput>;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export type GetPipelinesQuery = { __typename?: 'Query', findManyPipeline: Array<{ __typename?: 'Pipeline', id: string, name: string, pipelineProgressableType: PipelineProgressableType, pipelineStages?: Array<{ __typename?: 'PipelineStage', id: string, name: string, color: string, pipelineProgresses?: Array<{ __typename?: 'PipelineProgress', id: string, progressableType: PipelineProgressableType, progressableId: string }> | null }> | null }> };
 | 
			
		||||
@@ -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<typeof useDeleteCompa
 | 
			
		||||
export type DeleteCompaniesMutationResult = Apollo.MutationResult<DeleteCompaniesMutation>;
 | 
			
		||||
export type DeleteCompaniesMutationOptions = Apollo.BaseMutationOptions<DeleteCompaniesMutation, DeleteCompaniesMutationVariables>;
 | 
			
		||||
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<typeof useGetCurrentUserQ
 | 
			
		||||
export type GetCurrentUserLazyQueryHookResult = ReturnType<typeof useGetCurrentUserLazyQuery>;
 | 
			
		||||
export type GetCurrentUserQueryResult = Apollo.QueryResult<GetCurrentUserQuery, GetCurrentUserQueryVariables>;
 | 
			
		||||
export const GetUsersDocument = gql`
 | 
			
		||||
    query getUsers {
 | 
			
		||||
    query GetUsers {
 | 
			
		||||
  findManyUser {
 | 
			
		||||
    id
 | 
			
		||||
    email
 | 
			
		||||
    displayName
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
    `;
 | 
			
		||||
 
 | 
			
		||||
@@ -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<Column, 'itemKeys'>[];
 | 
			
		||||
  initialBoard: Column[];
 | 
			
		||||
  items: Items;
 | 
			
		||||
  onUpdate?: (itemKey: BoardItemKey, columnId: Column['id']) => Promise<void>;
 | 
			
		||||
  onClickNew?: (
 | 
			
		||||
    columnId: Column['id'],
 | 
			
		||||
    newItem: Partial<Item> & { 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<Column, 'itemKeys'>[];
 | 
			
		||||
  initialBoard: Column[];
 | 
			
		||||
  initialItems: CompanyProgressDict;
 | 
			
		||||
  onUpdate?: (itemKey: string, columnId: Column['id']) => Promise<void>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const StyledPlaceholder = styled.div`
 | 
			
		||||
  min-height: 1px;
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
const BoardColumnCardsContainer = ({
 | 
			
		||||
  children,
 | 
			
		||||
  droppableProvided,
 | 
			
		||||
}: {
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
  droppableProvided: DroppableProvided;
 | 
			
		||||
}) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      ref={droppableProvided?.innerRef}
 | 
			
		||||
      {...droppableProvided?.droppableProps}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
      <StyledPlaceholder>{droppableProvided?.placeholder}</StyledPlaceholder>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function Board({
 | 
			
		||||
  columns,
 | 
			
		||||
  initialBoard,
 | 
			
		||||
  items,
 | 
			
		||||
  initialItems,
 | 
			
		||||
  onUpdate,
 | 
			
		||||
  onClickNew,
 | 
			
		||||
}: BoardProps) => {
 | 
			
		||||
  const [board, setBoard] = useState<Column[]>(initialBoard);
 | 
			
		||||
  pipelineId,
 | 
			
		||||
}: BoardProps) {
 | 
			
		||||
  const [board, setBoard] = useRecoilState(boardColumnsState);
 | 
			
		||||
  const [items, setItems] = useRecoilState(boardItemsState);
 | 
			
		||||
  const [isInitialBoardLoaded, setIsInitialBoardLoaded] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const onClickFunctions = useMemo<
 | 
			
		||||
    Record<Column['id'], (newItem: Partial<Item> & { id: string }) => void>
 | 
			
		||||
  >(() => {
 | 
			
		||||
    return board.reduce((acc, column) => {
 | 
			
		||||
      acc[column.id] = (newItem: Partial<Item> & { id: string }) => {
 | 
			
		||||
        onClickNew && onClickNew(column.id, newItem);
 | 
			
		||||
      };
 | 
			
		||||
      return acc;
 | 
			
		||||
    }, {} as Record<Column['id'], (newItem: Partial<Item> & { 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 ? (
 | 
			
		||||
    <StyledBoard>
 | 
			
		||||
      <DragDropContext onDragEnd={onDragEnd}>
 | 
			
		||||
        {columns.map((column, columnIndex) => (
 | 
			
		||||
          <Droppable key={column.id} droppableId={column.id}>
 | 
			
		||||
            {(droppableProvided) => (
 | 
			
		||||
              <StyledColumn>
 | 
			
		||||
                <StyledColumnTitle color={column.colorCode}>
 | 
			
		||||
                  • {column.title}
 | 
			
		||||
                </StyledColumnTitle>
 | 
			
		||||
                <ScrollableColumn>
 | 
			
		||||
                  <ItemsContainer droppableProvided={droppableProvided}>
 | 
			
		||||
                    {board[columnIndex].itemKeys.map((itemKey, index) => (
 | 
			
		||||
                      <Draggable
 | 
			
		||||
                        key={itemKey}
 | 
			
		||||
                        draggableId={itemKey}
 | 
			
		||||
                        index={index}
 | 
			
		||||
                      >
 | 
			
		||||
                        {(draggableProvided) => (
 | 
			
		||||
                          <BoardItem draggableProvided={draggableProvided}>
 | 
			
		||||
                            <BoardCard item={items[itemKey]} />
 | 
			
		||||
                          </BoardItem>
 | 
			
		||||
                        )}
 | 
			
		||||
                      </Draggable>
 | 
			
		||||
                    ))}
 | 
			
		||||
                  </ItemsContainer>
 | 
			
		||||
                  <NewButton onClick={onClickFunctions[column.id]} />
 | 
			
		||||
                </ScrollableColumn>
 | 
			
		||||
              </StyledColumn>
 | 
			
		||||
              <BoardColumn title={column.title} colorCode={column.colorCode}>
 | 
			
		||||
                <BoardColumnCardsContainer
 | 
			
		||||
                  droppableProvided={droppableProvided}
 | 
			
		||||
                >
 | 
			
		||||
                  {board[columnIndex].itemKeys.map(
 | 
			
		||||
                    (itemKey, index) =>
 | 
			
		||||
                      items[itemKey] && (
 | 
			
		||||
                        <Draggable
 | 
			
		||||
                          key={itemKey}
 | 
			
		||||
                          draggableId={itemKey}
 | 
			
		||||
                          index={index}
 | 
			
		||||
                        >
 | 
			
		||||
                          {(draggableProvided) => (
 | 
			
		||||
                            <div
 | 
			
		||||
                              ref={draggableProvided?.innerRef}
 | 
			
		||||
                              {...draggableProvided?.dragHandleProps}
 | 
			
		||||
                              {...draggableProvided?.draggableProps}
 | 
			
		||||
                            >
 | 
			
		||||
                              <CompanyBoardCard company={items[itemKey]} />
 | 
			
		||||
                            </div>
 | 
			
		||||
                          )}
 | 
			
		||||
                        </Draggable>
 | 
			
		||||
                      ),
 | 
			
		||||
                  )}
 | 
			
		||||
                </BoardColumnCardsContainer>
 | 
			
		||||
                <NewButton pipelineId={pipelineId} columnId={column.id} />
 | 
			
		||||
              </BoardColumn>
 | 
			
		||||
            )}
 | 
			
		||||
          </Droppable>
 | 
			
		||||
        ))}
 | 
			
		||||
      </DragDropContext>
 | 
			
		||||
    </StyledBoard>
 | 
			
		||||
  ) : (
 | 
			
		||||
    <></>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,126 +0,0 @@
 | 
			
		||||
import { useTheme } from '@emotion/react';
 | 
			
		||||
import styled from '@emotion/styled';
 | 
			
		||||
 | 
			
		||||
import { Company, Person } from '../../../generated/graphql';
 | 
			
		||||
import CompanyChip from '../../companies/components/CompanyChip';
 | 
			
		||||
import PersonPlaceholder from '../../people/components/person-placeholder.png';
 | 
			
		||||
import { PersonChip } from '../../people/components/PersonChip';
 | 
			
		||||
import {
 | 
			
		||||
  IconBuildingSkyscraper,
 | 
			
		||||
  IconCalendarEvent,
 | 
			
		||||
  IconMail,
 | 
			
		||||
  IconPhone,
 | 
			
		||||
  IconUser,
 | 
			
		||||
  IconUsers,
 | 
			
		||||
} from '../../ui/icons';
 | 
			
		||||
import { getLogoUrlFromDomainName, humanReadableDate } from '../../utils/utils';
 | 
			
		||||
 | 
			
		||||
const StyledBoardCard = styled.div`
 | 
			
		||||
  color: ${(props) => props.theme.text80};
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
const StyledBoardCardHeader = styled.div`
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  font-weight: ${(props) => props.theme.fontWeightBold};
 | 
			
		||||
  height: 24px;
 | 
			
		||||
  padding: ${(props) => props.theme.spacing(2)};
 | 
			
		||||
  img {
 | 
			
		||||
    height: ${(props) => props.theme.iconSizeMedium}px;
 | 
			
		||||
    margin-right: ${(props) => props.theme.spacing(2)};
 | 
			
		||||
    object-fit: cover;
 | 
			
		||||
    width: ${(props) => props.theme.iconSizeMedium}px;
 | 
			
		||||
  }
 | 
			
		||||
`;
 | 
			
		||||
const StyledBoardCardBody = styled.div`
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  gap: ${(props) => props.theme.spacing(2)};
 | 
			
		||||
  padding: ${(props) => props.theme.spacing(2)};
 | 
			
		||||
  span {
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
    svg {
 | 
			
		||||
      color: ${(props) => props.theme.text40};
 | 
			
		||||
      margin-right: ${(props) => props.theme.spacing(2)};
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
export const BoardCard = ({ item }: { item: Person | Company }) => {
 | 
			
		||||
  if (item?.__typename === 'Person') return <PersonBoardCard person={item} />;
 | 
			
		||||
  if (item?.__typename === 'Company')
 | 
			
		||||
    return <CompanyBoardCard company={item} />;
 | 
			
		||||
  // @todo return card skeleton
 | 
			
		||||
  return null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const PersonBoardCard = ({ person }: { person: Person }) => {
 | 
			
		||||
  const fullname = `${person.firstname} ${person.lastname}`;
 | 
			
		||||
  const theme = useTheme();
 | 
			
		||||
  return (
 | 
			
		||||
    <StyledBoardCard>
 | 
			
		||||
      <StyledBoardCardHeader>
 | 
			
		||||
        <img
 | 
			
		||||
          data-testid="person-chip-image"
 | 
			
		||||
          src={PersonPlaceholder.toString()}
 | 
			
		||||
          alt="person"
 | 
			
		||||
        />
 | 
			
		||||
        {fullname}
 | 
			
		||||
      </StyledBoardCardHeader>
 | 
			
		||||
      <StyledBoardCardBody>
 | 
			
		||||
        <span>
 | 
			
		||||
          <IconBuildingSkyscraper size={theme.iconSizeMedium} />
 | 
			
		||||
          <CompanyChip
 | 
			
		||||
            name={person.company?.name || ''}
 | 
			
		||||
            picture={getLogoUrlFromDomainName(
 | 
			
		||||
              person.company?.domainName,
 | 
			
		||||
            ).toString()}
 | 
			
		||||
          />
 | 
			
		||||
        </span>
 | 
			
		||||
        <span>
 | 
			
		||||
          <IconMail size={theme.iconSizeMedium} />
 | 
			
		||||
          {person.email}
 | 
			
		||||
        </span>
 | 
			
		||||
        <span>
 | 
			
		||||
          <IconPhone size={theme.iconSizeMedium} />
 | 
			
		||||
          {person.phone}
 | 
			
		||||
        </span>
 | 
			
		||||
        <span>
 | 
			
		||||
          <IconCalendarEvent size={theme.iconSizeMedium} />
 | 
			
		||||
          {humanReadableDate(new Date(person.createdAt as string))}
 | 
			
		||||
        </span>
 | 
			
		||||
      </StyledBoardCardBody>
 | 
			
		||||
    </StyledBoardCard>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const CompanyBoardCard = ({ company }: { company: Company }) => {
 | 
			
		||||
  const theme = useTheme();
 | 
			
		||||
  return (
 | 
			
		||||
    <StyledBoardCard>
 | 
			
		||||
      <StyledBoardCardHeader>
 | 
			
		||||
        <img
 | 
			
		||||
          src={getLogoUrlFromDomainName(company.domainName).toString()}
 | 
			
		||||
          alt={`${company.name}-company-logo`}
 | 
			
		||||
        />
 | 
			
		||||
        <span>{company.name}</span>
 | 
			
		||||
      </StyledBoardCardHeader>
 | 
			
		||||
      <StyledBoardCardBody>
 | 
			
		||||
        <span>
 | 
			
		||||
          <IconUser size={theme.iconSizeMedium} />
 | 
			
		||||
          <PersonChip name={company.accountOwner?.displayName || ''} />
 | 
			
		||||
        </span>
 | 
			
		||||
        <span>
 | 
			
		||||
          <IconUsers size={theme.iconSizeMedium} /> {company.employees}
 | 
			
		||||
        </span>
 | 
			
		||||
        <span>
 | 
			
		||||
          <IconCalendarEvent size={theme.iconSizeMedium} />
 | 
			
		||||
          {humanReadableDate(new Date(company.createdAt as string))}
 | 
			
		||||
        </span>
 | 
			
		||||
      </StyledBoardCardBody>
 | 
			
		||||
    </StyledBoardCard>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -0,0 +1,87 @@
 | 
			
		||||
import { useTheme } from '@emotion/react';
 | 
			
		||||
import styled from '@emotion/styled';
 | 
			
		||||
 | 
			
		||||
import { Company } from '../../../generated/graphql';
 | 
			
		||||
import { PersonChip } from '../../people/components/PersonChip';
 | 
			
		||||
import { IconCalendarEvent, IconUser, IconUsers } from '../../ui/icons';
 | 
			
		||||
import { getLogoUrlFromDomainName, humanReadableDate } from '../../utils/utils';
 | 
			
		||||
 | 
			
		||||
const StyledBoardCard = styled.div`
 | 
			
		||||
  background: ${({ theme }) => theme.secondaryBackground};
 | 
			
		||||
  border: 1px solid ${({ theme }) => theme.mediumBorder};
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  box-shadow: ${({ theme }) => theme.lightBoxShadow};
 | 
			
		||||
  color: ${({ theme }) => theme.text80};
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
const StyledBoardCardWrapper = styled.div`
 | 
			
		||||
  padding-bottom: ${(props) => props.theme.spacing(2)};
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
const StyledBoardCardHeader = styled.div`
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  font-weight: ${(props) => props.theme.fontWeightBold};
 | 
			
		||||
  height: 24px;
 | 
			
		||||
  padding-left: ${(props) => props.theme.spacing(2)};
 | 
			
		||||
  padding-right: ${(props) => props.theme.spacing(2)};
 | 
			
		||||
  padding-top: ${(props) => props.theme.spacing(2)};
 | 
			
		||||
  img {
 | 
			
		||||
    height: ${(props) => props.theme.iconSizeMedium}px;
 | 
			
		||||
    margin-right: ${(props) => props.theme.spacing(2)};
 | 
			
		||||
    object-fit: cover;
 | 
			
		||||
    width: ${(props) => props.theme.iconSizeMedium}px;
 | 
			
		||||
  }
 | 
			
		||||
`;
 | 
			
		||||
const StyledBoardCardBody = styled.div`
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  gap: ${(props) => props.theme.spacing(2)};
 | 
			
		||||
  padding: ${(props) => props.theme.spacing(2)};
 | 
			
		||||
  span {
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
    svg {
 | 
			
		||||
      color: ${(props) => props.theme.text40};
 | 
			
		||||
      margin-right: ${(props) => props.theme.spacing(2)};
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
type CompanyProp = Pick<
 | 
			
		||||
  Company,
 | 
			
		||||
  'id' | 'name' | 'domainName' | 'employees' | 'createdAt' | 'accountOwner'
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
export function CompanyBoardCard({ company }: { company: CompanyProp }) {
 | 
			
		||||
  const theme = useTheme();
 | 
			
		||||
  return (
 | 
			
		||||
    <StyledBoardCardWrapper>
 | 
			
		||||
      <StyledBoardCard>
 | 
			
		||||
        <StyledBoardCardHeader>
 | 
			
		||||
          <img
 | 
			
		||||
            src={getLogoUrlFromDomainName(company.domainName).toString()}
 | 
			
		||||
            alt={`${company.name}-company-logo`}
 | 
			
		||||
          />
 | 
			
		||||
          <span>{company.name}</span>
 | 
			
		||||
        </StyledBoardCardHeader>
 | 
			
		||||
        <StyledBoardCardBody>
 | 
			
		||||
          <span>
 | 
			
		||||
            <IconUser size={theme.iconSizeMedium} />
 | 
			
		||||
            <PersonChip name={company.accountOwner?.displayName || ''} />
 | 
			
		||||
          </span>
 | 
			
		||||
          <span>
 | 
			
		||||
            <IconUsers size={theme.iconSizeMedium} /> {company.employees}
 | 
			
		||||
          </span>
 | 
			
		||||
          <span>
 | 
			
		||||
            <IconCalendarEvent size={theme.iconSizeMedium} />
 | 
			
		||||
            {humanReadableDate(new Date(company.createdAt as string))}
 | 
			
		||||
          </span>
 | 
			
		||||
        </StyledBoardCardBody>
 | 
			
		||||
      </StyledBoardCard>
 | 
			
		||||
    </StyledBoardCardWrapper>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										82
									
								
								front/src/modules/opportunities/components/NewButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								front/src/modules/opportunities/components/NewButton.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,82 @@
 | 
			
		||||
import { useCallback, useState } from 'react';
 | 
			
		||||
import { useRecoilState } from 'recoil';
 | 
			
		||||
import { v4 as uuidv4 } from 'uuid';
 | 
			
		||||
 | 
			
		||||
import { Column } from '@/ui/components/board/Board';
 | 
			
		||||
import { NewButton as UINewButton } from '@/ui/components/board/NewButton';
 | 
			
		||||
import { RecoilScope } from '@/ui/hooks/RecoilScope';
 | 
			
		||||
import {
 | 
			
		||||
  Company,
 | 
			
		||||
  PipelineProgressableType,
 | 
			
		||||
  useCreateOnePipelineProgressMutation,
 | 
			
		||||
} from '~/generated/graphql';
 | 
			
		||||
 | 
			
		||||
import { boardColumnsState } from '../states/boardColumnsState';
 | 
			
		||||
import { boardItemsState } from '../states/boardItemsState';
 | 
			
		||||
 | 
			
		||||
import { NewCompanyBoardCard } from './NewCompanyBoardCard';
 | 
			
		||||
 | 
			
		||||
type OwnProps = {
 | 
			
		||||
  pipelineId: string;
 | 
			
		||||
  columnId: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function NewButton({ pipelineId, columnId }: OwnProps) {
 | 
			
		||||
  const [isCreatingCard, setIsCreatingCard] = useState(false);
 | 
			
		||||
  const [board, setBoard] = useRecoilState(boardColumnsState);
 | 
			
		||||
  const [items, setItems] = useRecoilState(boardItemsState);
 | 
			
		||||
 | 
			
		||||
  const [createOnePipelineProgress] = useCreateOnePipelineProgressMutation();
 | 
			
		||||
  const onEntitySelect = useCallback(
 | 
			
		||||
    async (company: Pick<Company, 'id' | 'name' | 'domainName'>) => {
 | 
			
		||||
      setIsCreatingCard(false);
 | 
			
		||||
      const newUuid = uuidv4();
 | 
			
		||||
      const newBoard = JSON.parse(JSON.stringify(board));
 | 
			
		||||
      const destinationColumnIndex = newBoard.findIndex(
 | 
			
		||||
        (column: Column) => column.id === columnId,
 | 
			
		||||
      );
 | 
			
		||||
      newBoard[destinationColumnIndex].itemKeys.push(newUuid);
 | 
			
		||||
      setItems({
 | 
			
		||||
        ...items,
 | 
			
		||||
        [newUuid]: {
 | 
			
		||||
          id: company.id,
 | 
			
		||||
          name: company.name,
 | 
			
		||||
          domainName: company.domainName,
 | 
			
		||||
          createdAt: new Date().toISOString(),
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
      setBoard(newBoard);
 | 
			
		||||
      await createOnePipelineProgress({
 | 
			
		||||
        variables: {
 | 
			
		||||
          pipelineStageId: columnId,
 | 
			
		||||
          pipelineId,
 | 
			
		||||
          entityId: company.id,
 | 
			
		||||
          entityType: PipelineProgressableType.Company,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    [
 | 
			
		||||
      createOnePipelineProgress,
 | 
			
		||||
      columnId,
 | 
			
		||||
      pipelineId,
 | 
			
		||||
      board,
 | 
			
		||||
      setBoard,
 | 
			
		||||
      items,
 | 
			
		||||
      setItems,
 | 
			
		||||
    ],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const onNewClick = useCallback(() => {
 | 
			
		||||
    setIsCreatingCard(true);
 | 
			
		||||
  }, [setIsCreatingCard]);
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {isCreatingCard && (
 | 
			
		||||
        <RecoilScope>
 | 
			
		||||
          <NewCompanyBoardCard onEntitySelect={onEntitySelect} />
 | 
			
		||||
        </RecoilScope>
 | 
			
		||||
      )}
 | 
			
		||||
      <UINewButton onClick={onNewClick} />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,48 @@
 | 
			
		||||
import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect';
 | 
			
		||||
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
 | 
			
		||||
import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState';
 | 
			
		||||
import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState';
 | 
			
		||||
import { getLogoUrlFromDomainName } from '@/utils/utils';
 | 
			
		||||
import {
 | 
			
		||||
  CommentableType,
 | 
			
		||||
  Company,
 | 
			
		||||
  useSearchCompanyQuery,
 | 
			
		||||
} from '~/generated/graphql';
 | 
			
		||||
 | 
			
		||||
type OwnProps = {
 | 
			
		||||
  onEntitySelect: (
 | 
			
		||||
    company: Pick<Company, 'id' | 'name' | 'domainName'>,
 | 
			
		||||
  ) => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function NewCompanyBoardCard({ onEntitySelect }: OwnProps) {
 | 
			
		||||
  const [searchFilter] = useRecoilScopedState(
 | 
			
		||||
    relationPickerSearchFilterScopedState,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const companies = useFilteredSearchEntityQuery({
 | 
			
		||||
    queryHook: useSearchCompanyQuery,
 | 
			
		||||
    selectedIds: [],
 | 
			
		||||
    searchFilter: searchFilter,
 | 
			
		||||
    mappingFunction: (company) => ({
 | 
			
		||||
      entityType: CommentableType.Company,
 | 
			
		||||
      id: company.id,
 | 
			
		||||
      name: company.name,
 | 
			
		||||
      domainName: company.domainName,
 | 
			
		||||
      avatarType: 'squared',
 | 
			
		||||
      avatarUrl: getLogoUrlFromDomainName(company.domainName),
 | 
			
		||||
    }),
 | 
			
		||||
    orderByField: 'name',
 | 
			
		||||
    searchOnFields: ['name'],
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SingleEntitySelect
 | 
			
		||||
      onEntitySelected={(value) => onEntitySelect(value)}
 | 
			
		||||
      entities={{
 | 
			
		||||
        entitiesToSelect: companies.entitiesToSelect,
 | 
			
		||||
        selectedEntity: companies.selectedEntities[0],
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -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<typeof Board>;
 | 
			
		||||
 | 
			
		||||
export const OneColumnBoard: Story = {
 | 
			
		||||
  render: () => (
 | 
			
		||||
    <StrictMode>
 | 
			
		||||
      <Board columns={initialBoard} initialBoard={initialBoard} items={items} />
 | 
			
		||||
    </StrictMode>
 | 
			
		||||
  render: getRenderWrapperForComponent(
 | 
			
		||||
    <Board
 | 
			
		||||
      pipelineId={'xxx-test'}
 | 
			
		||||
      columns={initialBoard}
 | 
			
		||||
      initialBoard={initialBoard}
 | 
			
		||||
      initialItems={items}
 | 
			
		||||
    />,
 | 
			
		||||
  ),
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,36 +0,0 @@
 | 
			
		||||
import { StrictMode } from 'react';
 | 
			
		||||
import { Meta, StoryObj } from '@storybook/react';
 | 
			
		||||
 | 
			
		||||
import { Company, Person } from '../../../../generated/graphql';
 | 
			
		||||
import { mockedCompaniesData } from '../../../../testing/mock-data/companies';
 | 
			
		||||
import { mockedPeopleData } from '../../../../testing/mock-data/people';
 | 
			
		||||
import { BoardItem } from '../../../ui/components/board/BoardItem';
 | 
			
		||||
import { BoardCard } from '../BoardCard';
 | 
			
		||||
 | 
			
		||||
const meta: Meta<typeof BoardCard> = {
 | 
			
		||||
  title: 'UI/Board/BoardCard',
 | 
			
		||||
  component: BoardCard,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default meta;
 | 
			
		||||
type Story = StoryObj<typeof BoardCard>;
 | 
			
		||||
 | 
			
		||||
export const CompanyBoardCard: Story = {
 | 
			
		||||
  render: () => (
 | 
			
		||||
    <StrictMode>
 | 
			
		||||
      <BoardItem draggableProvided={undefined}>
 | 
			
		||||
        <BoardCard item={mockedCompaniesData[0] as Company} />
 | 
			
		||||
      </BoardItem>
 | 
			
		||||
    </StrictMode>
 | 
			
		||||
  ),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const PersonBoardCard: Story = {
 | 
			
		||||
  render: () => (
 | 
			
		||||
    <StrictMode>
 | 
			
		||||
      <BoardItem draggableProvided={undefined}>
 | 
			
		||||
        <BoardCard item={mockedPeopleData[0] as Person} />
 | 
			
		||||
      </BoardItem>
 | 
			
		||||
    </StrictMode>
 | 
			
		||||
  ),
 | 
			
		||||
};
 | 
			
		||||
@@ -0,0 +1,22 @@
 | 
			
		||||
import { StrictMode } from 'react';
 | 
			
		||||
import { Meta, StoryObj } from '@storybook/react';
 | 
			
		||||
 | 
			
		||||
import { Company } from '../../../../generated/graphql';
 | 
			
		||||
import { mockedCompaniesData } from '../../../../testing/mock-data/companies';
 | 
			
		||||
import { CompanyBoardCard } from '../CompanyBoardCard';
 | 
			
		||||
 | 
			
		||||
const meta: Meta<typeof CompanyBoardCard> = {
 | 
			
		||||
  title: 'UI/Board/CompanyBoardCard',
 | 
			
		||||
  component: CompanyBoardCard,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default meta;
 | 
			
		||||
type Story = StoryObj<typeof CompanyBoardCard>;
 | 
			
		||||
 | 
			
		||||
export const CompanyCompanyBoardCard: Story = {
 | 
			
		||||
  render: () => (
 | 
			
		||||
    <StrictMode>
 | 
			
		||||
      <CompanyBoardCard company={mockedCompaniesData[0] as Company} />
 | 
			
		||||
    </StrictMode>
 | 
			
		||||
  ),
 | 
			
		||||
};
 | 
			
		||||
@@ -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++) {
 | 
			
		||||
 
 | 
			
		||||
@@ -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<Company, 'id' | 'name' | 'createdAt' | 'domainName'>;
 | 
			
		||||
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,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,8 @@
 | 
			
		||||
import { atom } from 'recoil';
 | 
			
		||||
 | 
			
		||||
import { Column } from '@/ui/components/board/Board';
 | 
			
		||||
 | 
			
		||||
export const boardColumnsState = atom<Column[]>({
 | 
			
		||||
  key: 'boardColumnsState',
 | 
			
		||||
  default: [],
 | 
			
		||||
});
 | 
			
		||||
@@ -0,0 +1,8 @@
 | 
			
		||||
import { atom } from 'recoil';
 | 
			
		||||
 | 
			
		||||
import { CompanyProgressDict } from '../components/Board';
 | 
			
		||||
 | 
			
		||||
export const boardItemsState = atom<CompanyProgressDict>({
 | 
			
		||||
  key: 'boardItemsState',
 | 
			
		||||
  default: {},
 | 
			
		||||
});
 | 
			
		||||
@@ -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;
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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 (
 | 
			
		||||
    <StyledItemContainer
 | 
			
		||||
      ref={droppableProvided?.innerRef}
 | 
			
		||||
      {...droppableProvided?.droppableProps}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
      <StyledPlaceholder>{droppableProvided?.placeholder}</StyledPlaceholder>
 | 
			
		||||
    </StyledItemContainer>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function BoardColumn({ colorCode, title, children }: OwnProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <StyledColumn>
 | 
			
		||||
      <StyledColumnTitle color={colorCode}>• {title}</StyledColumnTitle>
 | 
			
		||||
      {children}
 | 
			
		||||
    </StyledColumn>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,28 +0,0 @@
 | 
			
		||||
import styled from '@emotion/styled';
 | 
			
		||||
import { DraggableProvided } from '@hello-pangea/dnd'; // Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350
 | 
			
		||||
 | 
			
		||||
const StyledCard = styled.div`
 | 
			
		||||
  background-color: ${({ theme }) => theme.secondaryBackground};
 | 
			
		||||
  border: 1px solid ${({ theme }) => theme.quaternaryBackground};
 | 
			
		||||
  border-radius: ${({ theme }) => theme.borderRadius};
 | 
			
		||||
  box-shadow: ${({ theme }) => theme.boxShadow};
 | 
			
		||||
  margin-bottom: ${({ theme }) => theme.spacing(2)};
 | 
			
		||||
  max-width: 300px;
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
type BoardCardProps = {
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
  draggableProvided?: DraggableProvided;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const BoardItem = ({ children, draggableProvided }: BoardCardProps) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <StyledCard
 | 
			
		||||
      ref={draggableProvided?.innerRef}
 | 
			
		||||
      {...draggableProvided?.dragHandleProps}
 | 
			
		||||
      {...draggableProvided?.draggableProps}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </StyledCard>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -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 (
 | 
			
		||||
    <StyledButton onClick={onInnerClick}>
 | 
			
		||||
    <StyledButton onClick={onClick}>
 | 
			
		||||
      <IconPlus size={theme.iconSizeMedium} />
 | 
			
		||||
      New
 | 
			
		||||
    </StyledButton>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
@@ -27,7 +27,7 @@ export function WithTopBarContainer({
 | 
			
		||||
  return (
 | 
			
		||||
    <StyledContainer>
 | 
			
		||||
      <TopBar title={title} icon={icon} onAddButtonClick={onAddButtonClick} />
 | 
			
		||||
      <ContentContainer topMargin={TOP_BAR_MIN_HEIGHT}>
 | 
			
		||||
      <ContentContainer topMargin={TOP_BAR_MIN_HEIGHT + 16 + 16}>
 | 
			
		||||
        {children}
 | 
			
		||||
      </ContentContainer>
 | 
			
		||||
    </StyledContainer>
 | 
			
		||||
 
 | 
			
		||||
@@ -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 };
 | 
			
		||||
 
 | 
			
		||||
@@ -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: {
 | 
			
		||||
 
 | 
			
		||||
@@ -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<PipelineProgress> & { id: string },
 | 
			
		||||
    ) => {
 | 
			
		||||
      if (!pipelineId || !pipelineEntityType) return;
 | 
			
		||||
      const variables = {
 | 
			
		||||
        pipelineStageId: columnId,
 | 
			
		||||
        pipelineId,
 | 
			
		||||
        entityId: newItem.id,
 | 
			
		||||
        entityType: pipelineEntityType,
 | 
			
		||||
      };
 | 
			
		||||
      createPipelineProgress({
 | 
			
		||||
        variables,
 | 
			
		||||
        refetchQueries: [getOperationName(GET_PIPELINES) ?? ''],
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    [pipelineId, pipelineEntityType, createPipelineProgress],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (error) return <div>Error...</div>;
 | 
			
		||||
  if (!initialBoard || !items) {
 | 
			
		||||
    return <div>Initial board or items not found</div>;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <WithTopBarContainer
 | 
			
		||||
      title="Opportunities"
 | 
			
		||||
      icon={<IconTargetArrow size={theme.iconSizeMedium} />}
 | 
			
		||||
    >
 | 
			
		||||
      <Board
 | 
			
		||||
        columns={columns || []}
 | 
			
		||||
        initialBoard={initialBoard}
 | 
			
		||||
        items={items}
 | 
			
		||||
        onUpdate={onUpdate}
 | 
			
		||||
        onClickNew={onClickNew}
 | 
			
		||||
      />
 | 
			
		||||
      {items && pipelineId ? (
 | 
			
		||||
        <Board
 | 
			
		||||
          pipelineId={pipelineId}
 | 
			
		||||
          columns={columns || []}
 | 
			
		||||
          initialBoard={initialBoard}
 | 
			
		||||
          initialItems={items}
 | 
			
		||||
          onUpdate={onUpdate}
 | 
			
		||||
        />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <></>
 | 
			
		||||
      )}
 | 
			
		||||
    </WithTopBarContainer>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
    });
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user