Enable opportunity card deletion (#490)

* Add checkbox

* Add state management for selected opportunities

* Use recoil for selected items state, show action bar

* Deduplicate code

* Add delete action

* Enable delete

* Add color for selected cards

* update board state on delete

* Add stories

* Enable empty board

* Fix story

* Handle dark mdoe

* Nits

* Rename module

* Better naming

* Fix naming confusion process<>progress
This commit is contained in:
Emilien Chauvet
2023-07-03 14:11:39 -07:00
committed by GitHub
parent c871d1cc10
commit db5dfb3bdf
22 changed files with 275 additions and 81 deletions

View File

@ -1704,6 +1704,13 @@ export type CreateOnePipelineProgressMutationVariables = Exact<{
export type CreateOnePipelineProgressMutation = { __typename?: 'Mutation', createOnePipelineProgress: { __typename?: 'PipelineProgress', id: string } };
export type DeleteManyPipelineProgressMutationVariables = Exact<{
ids?: InputMaybe<Array<Scalars['String']> | Scalars['String']>;
}>;
export type DeleteManyPipelineProgressMutation = { __typename?: 'Mutation', deleteManyPipelineProgress: { __typename?: 'AffectedRows', count: number } };
export type GetPeopleQueryVariables = Exact<{
orderBy?: InputMaybe<Array<PersonOrderByWithRelationInput> | PersonOrderByWithRelationInput>;
where?: InputMaybe<PersonWhereInput>;
@ -2600,6 +2607,39 @@ export function useCreateOnePipelineProgressMutation(baseOptions?: Apollo.Mutati
export type CreateOnePipelineProgressMutationHookResult = ReturnType<typeof useCreateOnePipelineProgressMutation>;
export type CreateOnePipelineProgressMutationResult = Apollo.MutationResult<CreateOnePipelineProgressMutation>;
export type CreateOnePipelineProgressMutationOptions = Apollo.BaseMutationOptions<CreateOnePipelineProgressMutation, CreateOnePipelineProgressMutationVariables>;
export const DeleteManyPipelineProgressDocument = gql`
mutation DeleteManyPipelineProgress($ids: [String!]) {
deleteManyPipelineProgress(where: {id: {in: $ids}}) {
count
}
}
`;
export type DeleteManyPipelineProgressMutationFn = Apollo.MutationFunction<DeleteManyPipelineProgressMutation, DeleteManyPipelineProgressMutationVariables>;
/**
* __useDeleteManyPipelineProgressMutation__
*
* To run a mutation, you first call `useDeleteManyPipelineProgressMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteManyPipelineProgressMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deleteManyPipelineProgressMutation, { data, loading, error }] = useDeleteManyPipelineProgressMutation({
* variables: {
* ids: // value for 'ids'
* },
* });
*/
export function useDeleteManyPipelineProgressMutation(baseOptions?: Apollo.MutationHookOptions<DeleteManyPipelineProgressMutation, DeleteManyPipelineProgressMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteManyPipelineProgressMutation, DeleteManyPipelineProgressMutationVariables>(DeleteManyPipelineProgressDocument, options);
}
export type DeleteManyPipelineProgressMutationHookResult = ReturnType<typeof useDeleteManyPipelineProgressMutation>;
export type DeleteManyPipelineProgressMutationResult = Apollo.MutationResult<DeleteManyPipelineProgressMutation>;
export type DeleteManyPipelineProgressMutationOptions = Apollo.BaseMutationOptions<DeleteManyPipelineProgressMutation, DeleteManyPipelineProgressMutationVariables>;
export const GetPeopleDocument = gql`
query GetPeople($orderBy: [PersonOrderByWithRelationInput!], $where: PersonWhereInput, $limit: Int) {
people: findManyPerson(orderBy: $orderBy, where: $where, take: $limit) {

View File

@ -19,6 +19,7 @@ import {
} from '../../ui/components/board/Board';
import { boardColumnsState } from '../states/boardColumnsState';
import { boardItemsState } from '../states/boardItemsState';
import { selectedBoardItemsState } from '../states/selectedBoardItemsState';
import { CompanyBoardCard } from './CompanyBoardCard';
import { NewButton } from './NewButton';
@ -69,19 +70,22 @@ export function Board({
pipelineId,
}: BoardProps) {
const [board, setBoard] = useRecoilState(boardColumnsState);
const [items, setItems] = useRecoilState(boardItemsState);
const [boardItems, setBoardItems] = useRecoilState(boardItemsState);
const [selectedBoardItems, setSelectedBoardItems] = useRecoilState(
selectedBoardItemsState,
);
const [isInitialBoardLoaded, setIsInitialBoardLoaded] = useState(false);
useEffect(() => {
if (Object.keys(initialItems).length === 0 || isInitialBoardLoaded) return;
setBoard(initialBoard);
setItems(initialItems);
if (Object.keys(initialItems).length === 0 || isInitialBoardLoaded) return;
setBoardItems(initialItems);
setIsInitialBoardLoaded(true);
}, [
initialBoard,
setBoard,
initialItems,
setItems,
setBoardItems,
setIsInitialBoardLoaded,
isInitialBoardLoaded,
]);
@ -105,6 +109,16 @@ export function Board({
[board, onUpdate, setBoard],
);
function handleSelect(itemKey: string) {
if (selectedBoardItems.includes(itemKey)) {
setSelectedBoardItems(
selectedBoardItems.filter((key) => key !== itemKey),
);
} else {
setSelectedBoardItems([...selectedBoardItems, itemKey]);
}
}
return board.length > 0 ? (
<StyledBoard>
<DragDropContext onDragEnd={onDragEnd}>
@ -117,7 +131,7 @@ export function Board({
>
{board[columnIndex].itemKeys.map(
(itemKey, index) =>
items[itemKey] && (
boardItems[itemKey] && (
<Draggable
key={itemKey}
draggableId={itemKey}
@ -129,7 +143,11 @@ export function Board({
{...draggableProvided?.dragHandleProps}
{...draggableProvided?.draggableProps}
>
<CompanyBoardCard company={items[itemKey]} />
<CompanyBoardCard
company={boardItems[itemKey]}
selected={selectedBoardItems.includes(itemKey)}
onSelect={() => handleSelect(itemKey)}
/>
</div>
)}
</Draggable>

View File

@ -0,0 +1,49 @@
import { getOperationName } from '@apollo/client/utilities';
import { useRecoilState } from 'recoil';
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';
export function BoardActionBarButtonDeletePipelineProgress() {
const [selectedBoardItems, setSelectedBoardItems] = useRecoilState(
selectedBoardItemsState,
);
const [boardItems, setBoardItems] = useRecoilState(boardItemsState);
const [deletePipelineProgress] = useDeleteManyPipelineProgressMutation({
refetchQueries: [getOperationName(GET_PIPELINES) ?? ''],
});
async function handleDeleteClick() {
await deletePipelineProgress({
variables: {
ids: selectedBoardItems,
},
});
console.log('boardItems', boardItems);
setBoardItems(
Object.fromEntries(
Object.entries(boardItems).filter(
([key]) => !selectedBoardItems.includes(key),
),
),
);
setSelectedBoardItems([]);
}
return (
<EntityTableActionBarButton
label="Delete"
icon={<IconTrash size={16} />}
type="warning"
onClick={handleDeleteClick}
/>
);
}

View File

@ -3,15 +3,21 @@ import styled from '@emotion/styled';
import { Company } from '../../../generated/graphql';
import { PersonChip } from '../../people/components/PersonChip';
import { Checkbox } from '../../ui/components/form/Checkbox';
import { IconCalendarEvent, IconUser, IconUsers } from '../../ui/icons';
import { getLogoUrlFromDomainName, humanReadableDate } from '../../utils/utils';
const StyledBoardCard = styled.div`
background: ${({ theme }) => theme.background.secondary};
const StyledBoardCard = styled.div<{ selected: boolean }>`
background-color: ${({ theme, selected }) =>
selected ? theme.selectedCard : theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: 4px;
box-shadow: ${({ theme }) => theme.boxShadow.light};
color: ${({ theme }) => theme.font.color.primary};
&:hover {
background-color: ${({ theme, selected }) =>
selected ? theme.selectedCardHover : theme.background.tertiary};
}
cursor: pointer;
`;
@ -56,17 +62,27 @@ type CompanyProp = Pick<
'id' | 'name' | 'domainName' | 'employees' | 'createdAt' | 'accountOwner'
>;
export function CompanyBoardCard({ company }: { company: CompanyProp }) {
export function CompanyBoardCard({
company,
selected,
onSelect,
}: {
company: CompanyProp;
selected: boolean;
onSelect: (company: CompanyProp) => void;
}) {
const theme = useTheme();
return (
<StyledBoardCardWrapper>
<StyledBoardCard>
<StyledBoardCard selected={selected}>
<StyledBoardCardHeader>
<img
src={getLogoUrlFromDomainName(company.domainName).toString()}
alt={`${company.name}-company-logo`}
/>
<span>{company.name}</span>
<div style={{ display: 'flex', flex: 1 }} />
<Checkbox checked={selected} onChange={() => onSelect(company)} />
</StyledBoardCardHeader>
<StyledBoardCardBody>
<span>

View File

@ -0,0 +1,15 @@
import React from 'react';
import { useRecoilValue } from 'recoil';
import { ActionBar } from '@/ui/components/action-bar/ActionBar';
import { selectedBoardItemsState } from '../states/selectedBoardItemsState';
type OwnProps = {
children: React.ReactNode | React.ReactNode[];
};
export function EntityBoardActionBar({ children }: OwnProps) {
const selectedBoardItems = useRecoilValue(selectedBoardItemsState);
return <ActionBar selectedIds={selectedBoardItems}>{children}</ActionBar>;
}

View File

@ -24,7 +24,7 @@ type OwnProps = {
export function NewButton({ pipelineId, columnId }: OwnProps) {
const [isCreatingCard, setIsCreatingCard] = useState(false);
const [board, setBoard] = useRecoilState(boardColumnsState);
const [items, setItems] = useRecoilState(boardItemsState);
const [boardItems, setBoardItems] = useRecoilState(boardItemsState);
const [createOnePipelineProgress] = useCreateOnePipelineProgressMutation();
const onEntitySelect = useCallback(
@ -36,8 +36,8 @@ export function NewButton({ pipelineId, columnId }: OwnProps) {
(column: Column) => column.id === columnId,
);
newBoard[destinationColumnIndex].itemKeys.push(newUuid);
setItems({
...items,
setBoardItems({
...boardItems,
[newUuid]: {
id: company.id,
name: company.name,
@ -62,8 +62,8 @@ export function NewButton({ pipelineId, columnId }: OwnProps) {
pipelineId,
board,
setBoard,
items,
setItems,
boardItems,
setBoardItems,
],
);

View File

@ -1,4 +1,4 @@
import { StrictMode } from 'react';
import { StrictMode, useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { Company } from '../../../../generated/graphql';
@ -13,10 +13,22 @@ const meta: Meta<typeof CompanyBoardCard> = {
export default meta;
type Story = StoryObj<typeof CompanyBoardCard>;
const FakeSelectableCompanyBoardCard = () => {
const [selected, setSelected] = useState<boolean>(false);
return (
<CompanyBoardCard
company={mockedCompaniesData[0] as Company}
selected={selected}
onSelect={() => setSelected(!selected)}
/>
);
};
export const CompanyCompanyBoardCard: Story = {
render: () => (
<StrictMode>
<CompanyBoardCard company={mockedCompaniesData[0] as Company} />
<FakeSelectableCompanyBoardCard />
</StrictMode>
),
};

View File

@ -0,0 +1 @@
export * from './update';

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const DELETE_PIPELINE_PROGRESS = gql`
mutation DeleteManyPipelineProgress($ids: [String!]) {
deleteManyPipelineProgress(where: { id: { in: $ids } }) {
count
}
}
`;

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const selectedBoardItemsState = atom<string[]>({
key: 'selectedBoardItemsState',
default: [],
});

View File

@ -0,0 +1,65 @@
import React, { useEffect } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { contextMenuPositionState } from '@/ui/tables/states/contextMenuPositionState';
import { PositionType } from '@/ui/types/PositionType';
type OwnProps = {
children: React.ReactNode | React.ReactNode[];
selectedIds: string[];
};
type StyledContainerProps = {
position: PositionType;
};
const StyledContainer = styled.div<StyledContainerProps>`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: 8px;
bottom: ${(props) => (props.position.x ? 'auto' : '38px')};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
height: 48px;
left: ${(props) => (props.position.x ? `${props.position.x}px` : '50%')};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
position: ${(props) => (props.position.x ? 'fixed' : 'absolute')};
top: ${(props) => (props.position.y ? `${props.position.y}px` : 'auto')};
transform: translateX(-50%);
z-index: 1;
`;
export function ActionBar({ children, selectedIds }: OwnProps) {
const position = useRecoilValue(contextMenuPositionState);
const setContextMenuPosition = useSetRecoilState(contextMenuPositionState);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (!(event.target as HTMLElement).closest('.action-bar')) {
setContextMenuPosition({ x: null, y: null });
}
}
document.addEventListener('mousedown', handleClickOutside);
// Cleanup the event listener when the component unmounts
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [setContextMenuPosition]);
if (selectedIds.length === 0) {
return null;
}
return (
<StyledContainer className="action-bar" position={position}>
{children}
</StyledContainer>
);
}

View File

@ -1,66 +1,15 @@
import React, { useEffect } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import React from 'react';
import { useRecoilValue } from 'recoil';
import { contextMenuPositionState } from '@/ui/tables/states/contextMenuPositionState';
import { ActionBar } from '@/ui/components/action-bar/ActionBar';
import { selectedRowIdsState } from '@/ui/tables/states/selectedRowIdsState';
import { PositionType } from '@/ui/types/PositionType';
type OwnProps = {
children: React.ReactNode | React.ReactNode[];
};
type StyledContainerProps = {
position: PositionType;
};
const StyledContainer = styled.div<StyledContainerProps>`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: 8px;
bottom: ${(props) => (props.position.x ? 'auto' : '38px')};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
height: 48px;
left: ${(props) => (props.position.x ? `${props.position.x}px` : '50%')};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
position: ${(props) => (props.position.x ? 'fixed' : 'absolute')};
top: ${(props) => (props.position.y ? `${props.position.y}px` : 'auto')};
transform: translateX(-50%);
z-index: 1;
`;
export function EntityTableActionBar({ children }: OwnProps) {
const selectedRowIds = useRecoilValue(selectedRowIdsState);
const position = useRecoilValue(contextMenuPositionState);
const setContextMenuPosition = useSetRecoilState(contextMenuPositionState);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (!(event.target as HTMLElement).closest('.action-bar')) {
setContextMenuPosition({ x: null, y: null });
}
}
document.addEventListener('mousedown', handleClickOutside);
// Cleanup the event listener when the component unmounts
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [setContextMenuPosition]);
if (selectedRowIds.length === 0) {
return null;
}
return (
<StyledContainer className="action-bar" position={position}>
{children}
</StyledContainer>
);
return <ActionBar selectedIds={selectedRowIds}>{children}</ActionBar>;
}

View File

@ -29,6 +29,8 @@ export const lightTheme = {
background: backgroundLight,
border: borderLight,
boxShadow: boxShadowLight,
selectedCardHover: color.blue20,
selectedCard: color.blue10,
font: fontLight,
},
};
@ -40,6 +42,8 @@ export const darkTheme: ThemeType = {
background: backgroundDark,
border: borderDark,
boxShadow: boxShadowDark,
selectedCardHover: color.blue70,
selectedCard: color.blue80,
font: fontDark,
},
};

View File

@ -1,6 +1,8 @@
import { useCallback, useMemo } from 'react';
import { useTheme } from '@emotion/react';
import { BoardActionBarButtonDeletePipelineProgress } from '@/pipeline-progress/components/BoardActionBarButtonDeletePipelineProgress';
import { EntityBoardActionBar } from '@/pipeline-progress/components/EntityBoardActionBar';
import { IconTargetArrow } from '@/ui/icons/index';
import { WithTopBarContainer } from '@/ui/layout/containers/WithTopBarContainer';
@ -10,8 +12,8 @@ import {
useGetPipelinesQuery,
useUpdateOnePipelineProgressMutation,
} from '../../generated/graphql';
import { Board } from '../../modules/opportunities/components/Board';
import { useBoard } from '../../modules/opportunities/hooks/useBoard';
import { Board } from '../../modules/pipeline-progress/components/Board';
import { useBoard } from '../../modules/pipeline-progress/hooks/useBoard';
export function Opportunities() {
const theme = useTheme();
@ -49,13 +51,18 @@ export function Opportunities() {
icon={<IconTargetArrow size={theme.icon.size.md} />}
>
{items && pipelineId ? (
<Board
pipelineId={pipelineId}
columns={columns || []}
initialBoard={initialBoard}
initialItems={items}
onUpdate={onUpdate}
/>
<>
<Board
pipelineId={pipelineId}
columns={columns || []}
initialBoard={initialBoard}
initialItems={items}
onUpdate={onUpdate}
/>
<EntityBoardActionBar>
<BoardActionBarButtonDeletePipelineProgress />
</EntityBoardActionBar>
</>
) : (
<></>
)}

View File

@ -108,6 +108,9 @@ export class AbilityFactory {
can(AbilityAction.Update, 'PipelineProgress', {
workspaceId: workspace.id,
});
can(AbilityAction.Delete, 'PipelineProgress', {
workspaceId: workspace.id,
});
return build();
}