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:
@ -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>
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
@ -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>;
|
||||
}
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@ -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>
|
||||
),
|
||||
};
|
||||
1
front/src/modules/pipeline-progress/services/index.ts
Normal file
1
front/src/modules/pipeline-progress/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './update';
|
||||
9
front/src/modules/pipeline-progress/services/update.ts
Normal file
9
front/src/modules/pipeline-progress/services/update.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const selectedBoardItemsState = atom<string[]>({
|
||||
key: 'selectedBoardItemsState',
|
||||
default: [],
|
||||
});
|
||||
65
front/src/modules/ui/components/action-bar/ActionBar.tsx
Normal file
65
front/src/modules/ui/components/action-bar/ActionBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user