2358 refactor entityboard to recordboard (#2652)

* renaming

* wip

* merge BoardColumn and RecordBoardColumn

* merge files

* remove unnecessary export

* Fix lint

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
bosiraphael
2023-11-22 17:31:56 +01:00
committed by GitHub
parent 8f12aea64a
commit 13d31072a7
69 changed files with 296 additions and 338 deletions

View File

@ -25,7 +25,6 @@ export const SingleEntityObjectFilterDropdownButton = ({
selectedFilter,
setFilterDefinitionUsedInDropdown,
setSelectedOperandInDropdown,
resetFilter,
} = useFilter();
const availableFilter = availableFilterDefinitions[0];

View File

@ -0,0 +1,11 @@
import React from 'react';
import { useRecoilValue } from 'recoil';
import { ActionBar } from '@/ui/navigation/action-bar/components/ActionBar';
import { selectedCardIdsSelector } from '../../states/selectors/selectedCardIdsSelector';
export const RecordBoardActionBar = () => {
const selectedCardIds = useRecoilValue(selectedCardIdsSelector);
return <ActionBar selectedIds={selectedCardIds}></ActionBar>;
};

View File

@ -0,0 +1,36 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconPlus } from '@/ui/display/icon/index';
const StyledButton = styled.button`
align-items: center;
align-self: baseline;
background-color: ${({ theme }) => theme.background.primary};
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(1)};
&:hover {
background-color: ${({ theme }) => theme.background.tertiary};
}
`;
type NewButtonProps = {
onClick: () => void;
};
export const NewButton = ({ onClick }: NewButtonProps) => {
const theme = useTheme();
return (
<StyledButton onClick={onClick}>
<IconPlus size={theme.icon.size.md} />
New
</StyledButton>
);
};

View File

@ -0,0 +1,168 @@
import { useCallback, useRef } from 'react';
import styled from '@emotion/styled';
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 { useRecoilValue } from 'recoil';
import { useUpdateOneObjectRecord } from '@/object-record/hooks/useUpdateOneObjectRecord';
import { Opportunity } from '@/pipeline/types/Opportunity';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { BoardColumnContext } from '@/ui/object/record-board/contexts/BoardColumnContext';
import { useSetCardSelected } from '@/ui/object/record-board/hooks/useSetCardSelected';
import { useUpdateBoardCardIds } from '@/ui/object/record-board/hooks/useUpdateBoardCardIds';
import { boardColumnsState } from '@/ui/object/record-board/states/boardColumnsState';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { logError } from '~/utils/logError';
import { BoardColumnRecoilScopeContext } from '../states/recoil-scope-contexts/BoardColumnRecoilScopeContext';
import { BoardColumnDefinition } from '../types/BoardColumnDefinition';
import { BoardOptions } from '../types/BoardOptions';
import { RecordBoardColumn } from './RecordBoardColumn';
export type RecordBoardProps = {
boardOptions: BoardOptions;
onColumnAdd?: (boardColumn: BoardColumnDefinition) => void;
onColumnDelete?: (boardColumnId: string) => void;
onEditColumnTitle: (columnId: string, title: string, color: string) => void;
};
const StyledBoard = styled.div`
border-top: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
flex: 1;
flex-direction: row;
margin-left: ${({ theme }) => theme.spacing(2)};
margin-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
`;
const StyledBoardHeader = styled.div`
position: relative;
z-index: 1;
`;
export const RecordBoard = ({
boardOptions,
onColumnDelete,
onEditColumnTitle,
}: RecordBoardProps) => {
const boardColumns = useRecoilValue(boardColumnsState);
const { updateOneObject: updateOneOpportunity } =
useUpdateOneObjectRecord<Opportunity>({
objectNameSingular: 'opportunity',
});
const { unselectAllActiveCards, setCardSelected } = useSetCardSelected();
const updatePipelineProgressStageInDB = useCallback(
async (pipelineProgressId: string, pipelineStepId: string) => {
await updateOneOpportunity?.({
idToUpdate: pipelineProgressId,
input: {
pipelineStepId: pipelineStepId,
},
});
},
[updateOneOpportunity],
);
useListenClickOutsideByClassName({
classNames: ['entity-board-card'],
excludeClassNames: ['action-bar', 'context-menu'],
callback: unselectAllActiveCards,
});
const updateBoardCardIds = useUpdateBoardCardIds();
const onDragEnd: OnDragEndResponder = useCallback(
async (result) => {
if (!boardColumns) return;
updateBoardCardIds(result);
try {
const draggedEntityId = result.draggableId;
const destinationColumnId = result.destination?.droppableId;
// TODO: abstract
if (
draggedEntityId &&
destinationColumnId &&
updatePipelineProgressStageInDB
) {
await updatePipelineProgressStageInDB(
draggedEntityId,
destinationColumnId,
);
}
} catch (e) {
logError(e);
}
},
[boardColumns, updatePipelineProgressStageInDB, updateBoardCardIds],
);
const sortedBoardColumns = [...boardColumns].sort((a, b) => {
return a.position - b.position;
});
const boardRef = useRef<HTMLDivElement>(null);
useScopedHotkeys(
'escape',
unselectAllActiveCards,
PageHotkeyScope.OpportunitiesPage,
);
return (
<StyledWrapper>
<StyledBoardHeader />
<ScrollWrapper>
<StyledBoard ref={boardRef}>
<DragDropContext onDragEnd={onDragEnd}>
{sortedBoardColumns.map((column) => (
<BoardColumnContext.Provider
key={column.id}
value={{
id: column.id,
columnDefinition: column,
isFirstColumn: column.position === 0,
isLastColumn:
column.position === sortedBoardColumns.length - 1,
}}
>
<RecoilScope
CustomRecoilScopeContext={BoardColumnRecoilScopeContext}
key={column.id}
>
<RecordBoardColumn
boardOptions={boardOptions}
onDelete={onColumnDelete}
onTitleEdit={onEditColumnTitle}
/>
</RecoilScope>
</BoardColumnContext.Provider>
))}
</DragDropContext>
</StyledBoard>
</ScrollWrapper>
<DragSelect
dragSelectable={boardRef}
onDragSelectionChange={setCardSelected}
/>
</StyledWrapper>
);
};

View File

@ -0,0 +1,53 @@
import { Draggable } from '@hello-pangea/dnd';
import { useSetRecoilState } from 'recoil';
import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState';
import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState';
import { useCurrentCardSelected } from '../hooks/useCurrentCardSelected';
import { BoardOptions } from '../types/BoardOptions';
export const RecordBoardCard = ({
boardOptions,
cardId,
index,
}: {
boardOptions: BoardOptions;
cardId: string;
index: number;
}) => {
const setContextMenuPosition = useSetRecoilState(contextMenuPositionState);
const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState);
const { setCurrentCardSelected } = useCurrentCardSelected();
const handleContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
setCurrentCardSelected(true);
setContextMenuPosition({
x: event.clientX,
y: event.clientY,
});
setContextMenuOpenState(true);
};
return (
<Draggable key={cardId} draggableId={cardId} index={index}>
{(draggableProvided) => (
<div
ref={draggableProvided?.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided?.dragHandleProps}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided?.draggableProps}
className="entity-board-card"
data-selectable-id={cardId}
data-select-disable
onContextMenu={handleContextMenu}
>
{<boardOptions.CardComponent />}
</div>
)}
</Draggable>
);
};

View File

@ -0,0 +1,204 @@
import React, { useContext } from 'react';
import styled from '@emotion/styled';
import { Draggable, Droppable, DroppableProvided } from '@hello-pangea/dnd';
import { useRecoilValue } from 'recoil';
import { Tag } from '@/ui/display/tag/components/Tag';
import { RecordBoardCard } from '@/ui/object/record-board/components/RecordBoardCard';
import { BoardCardIdContext } from '@/ui/object/record-board/contexts/BoardCardIdContext';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { BoardColumnContext } from '../contexts/BoardColumnContext';
import { boardCardIdsByColumnIdFamilyState } from '../states/boardCardIdsByColumnIdFamilyState';
import { boardColumnTotalsFamilySelector } from '../states/selectors/boardColumnTotalsFamilySelector';
import { BoardColumnHotkeyScope } from '../types/BoardColumnHotkeyScope';
import { BoardOptions } from '../types/BoardOptions';
import { RecordBoardColumnDropdownMenu } from './RecordBoardColumnDropdownMenu';
const StyledPlaceholder = styled.div`
min-height: 1px;
`;
const StyledNewCardButtonContainer = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledColumnCardsContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
`;
const StyledColumn = styled.div<{ isFirstColumn: boolean }>`
background-color: ${({ theme }) => theme.background.primary};
border-left: 1px solid
${({ theme, isFirstColumn }) =>
isFirstColumn ? 'none' : theme.border.color.light};
display: flex;
flex-direction: column;
max-width: 200px;
min-width: 200px;
padding: ${({ theme }) => theme.spacing(2)};
position: relative;
`;
const StyledHeader = styled.div`
align-items: center;
cursor: pointer;
display: flex;
flex-direction: row;
height: 24px;
justify-content: left;
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledAmount = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
margin-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledNumChildren = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.border.radius.rounded};
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
height: 20px;
justify-content: center;
line-height: ${({ theme }) => theme.text.lineHeight.lg};
margin-left: auto;
width: 16px;
`;
type BoardColumnCardsContainerProps = {
children: React.ReactNode;
droppableProvided: DroppableProvided;
};
type RecordBoardColumnProps = {
boardOptions: BoardOptions;
onDelete?: (columnId: string) => void;
onTitleEdit: (columnId: string, title: string, color: string) => void;
};
const BoardColumnCardsContainer = ({
children,
droppableProvided,
}: BoardColumnCardsContainerProps) => {
return (
<StyledColumnCardsContainer
ref={droppableProvided?.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...droppableProvided?.droppableProps}
>
{children}
<StyledPlaceholder>{droppableProvided?.placeholder}</StyledPlaceholder>
</StyledColumnCardsContainer>
);
};
export const RecordBoardColumn = ({
boardOptions,
onDelete,
onTitleEdit,
}: RecordBoardColumnProps) => {
const column = useContext(BoardColumnContext);
const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] =
React.useState(false);
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const handleTitleClick = () => {
setIsBoardColumnMenuOpen(true);
setHotkeyScopeAndMemorizePreviousScope(BoardColumnHotkeyScope.BoardColumn, {
goto: false,
});
};
const handleClose = () => {
goBackToPreviousHotkeyScope();
setIsBoardColumnMenuOpen(false);
};
const boardColumnId = column?.id || '';
const boardColumnTotal = useRecoilValue(
boardColumnTotalsFamilySelector(boardColumnId),
);
const cardIds = useRecoilValue(
boardCardIdsByColumnIdFamilyState(boardColumnId),
);
const handleTitleEdit = (title: string, color: string) => {
onTitleEdit(boardColumnId, title, color);
};
if (!column) return <></>;
const { isFirstColumn, columnDefinition } = column;
return (
<Droppable droppableId={column.id}>
{(droppableProvided) => (
<StyledColumn isFirstColumn={isFirstColumn}>
<StyledHeader>
<Tag
onClick={handleTitleClick}
color={columnDefinition.colorCode ?? 'gray'}
text={columnDefinition.title}
/>
{!!boardColumnTotal && (
<StyledAmount>${boardColumnTotal}</StyledAmount>
)}
<StyledNumChildren>{cardIds.length}</StyledNumChildren>
</StyledHeader>
{isBoardColumnMenuOpen && (
<RecordBoardColumnDropdownMenu
onClose={handleClose}
onDelete={onDelete}
onTitleEdit={handleTitleEdit}
stageId={boardColumnId}
/>
)}
<BoardColumnCardsContainer droppableProvided={droppableProvided}>
{cardIds.map((cardId, index) => (
<BoardCardIdContext.Provider value={cardId} key={cardId}>
<RecordBoardCard
index={index}
cardId={cardId}
boardOptions={boardOptions}
/>
</BoardCardIdContext.Provider>
))}
<Draggable
draggableId={`new-${column.id}`}
index={cardIds.length}
isDragDisabled={true}
>
{(draggableProvided) => (
<div
ref={draggableProvided?.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided?.draggableProps}
>
<StyledNewCardButtonContainer>
<RecoilScope>{boardOptions.newCardComponent}</RecoilScope>
</StyledNewCardButtonContainer>
</div>
)}
</Draggable>
</BoardColumnCardsContainer>
</StyledColumn>
)}
</Droppable>
);
};

View File

@ -0,0 +1,186 @@
import { useCallback, useContext, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import {
IconArrowLeft,
IconArrowRight,
IconPencil,
IconPlus,
} from '@/ui/display/icon';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { relationPickerSearchFilterScopedState } from '@/ui/input/relation-picker/states/relationPickerSearchFilterScopedState';
import { EntityForSelect } from '@/ui/input/relation-picker/types/EntityForSelect';
import { RelationPickerHotkeyScope } from '@/ui/input/relation-picker/types/RelationPickerHotkeyScope';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { logError } from '~/utils/logError';
import { BoardColumnContext } from '../contexts/BoardColumnContext';
import { useBoardColumns } from '../hooks/useBoardColumns';
import { BoardColumnHotkeyScope } from '../types/BoardColumnHotkeyScope';
import { RecordBoardColumnEditTitleMenu } from './RecordBoardColumnEditTitleMenu';
const StyledMenuContainer = styled.div`
position: absolute;
top: ${({ theme }) => theme.spacing(10)};
width: 200px;
z-index: 1;
`;
type RecordBoardColumnDropdownMenuProps = {
onClose: () => void;
onDelete?: (id: string) => void;
onTitleEdit: (title: string, color: string) => void;
stageId: string;
};
type Menu = 'actions' | 'add' | 'title';
export const RecordBoardColumnDropdownMenu = ({
onClose,
onDelete,
onTitleEdit,
stageId,
}: RecordBoardColumnDropdownMenuProps) => {
const [currentMenu, setCurrentMenu] = useState('actions');
const column = useContext(BoardColumnContext);
const boardColumnMenuRef = useRef<HTMLDivElement>(null);
const { enqueueSnackBar } = useSnackBar();
const { handleMoveBoardColumn } = useBoardColumns();
const handleCompanySelected = (
selectedCompany: EntityForSelect | null | undefined,
) => {
if (!selectedCompany?.id) {
enqueueSnackBar(
'There was a problem with the company selection, please retry.',
{
variant: 'error',
},
);
logError('There was a problem with the company selection, please retry.');
return;
}
//createCompanyProgress(selectedCompany.id, stageId);
closeMenu();
};
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const closeMenu = useCallback(() => {
goBackToPreviousHotkeyScope();
onClose();
}, [goBackToPreviousHotkeyScope, onClose]);
const setMenu = (menu: Menu) => {
if (menu === 'add') {
setHotkeyScopeAndMemorizePreviousScope(
RelationPickerHotkeyScope.RelationPicker,
);
}
setCurrentMenu(menu);
};
const [relationPickerSearchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
);
// const companies = useFilteredSearchCompanyQuery({
// searchFilter: relationPickerSearchFilter,
// });
useListenClickOutside({
refs: [boardColumnMenuRef],
callback: closeMenu,
});
useScopedHotkeys(
[Key.Escape, Key.Enter],
closeMenu,
BoardColumnHotkeyScope.BoardColumn,
[],
);
if (!column) return <></>;
const { isFirstColumn, isLastColumn, columnDefinition } = column;
const handleColumnMoveLeft = () => {
closeMenu();
if (isFirstColumn) {
return;
}
handleMoveBoardColumn('left', columnDefinition);
};
const handleColumnMoveRight = () => {
closeMenu();
if (isLastColumn) {
return;
}
handleMoveBoardColumn('right', columnDefinition);
};
return (
<StyledMenuContainer ref={boardColumnMenuRef}>
<DropdownMenu data-select-disable>
{currentMenu === 'actions' && (
<DropdownMenuItemsContainer>
<MenuItem
onClick={() => setMenu('title')}
LeftIcon={IconPencil}
text="Edit"
/>
<MenuItem
LeftIcon={IconArrowLeft}
onClick={handleColumnMoveLeft}
text="Move left"
/>
<MenuItem
LeftIcon={IconArrowRight}
onClick={handleColumnMoveRight}
text="Move right"
/>
<MenuItem
onClick={() => setMenu('add')}
LeftIcon={IconPlus}
text="New opportunity"
/>
</DropdownMenuItemsContainer>
)}
{currentMenu === 'title' && (
<RecordBoardColumnEditTitleMenu
color={columnDefinition.colorCode ?? 'gray'}
onClose={closeMenu}
onTitleEdit={onTitleEdit}
title={columnDefinition.title}
onDelete={onDelete}
stageId={stageId}
/>
)}
{currentMenu === 'add' && (
<div>add</div>
// <SingleEntitySelect
// disableBackgroundBlur
// entitiesToSelect={companies.entitiesToSelect}
// loading={companies.loading}
// onCancel={closeMenu}
// onEntitySelected={handleCompanySelected}
// selectedEntity={companies.selectedEntities[0]}
// />
)}
</DropdownMenu>
</StyledMenuContainer>
);
};

View File

@ -0,0 +1,147 @@
import { ChangeEvent, useCallback, useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { IconTrash } from '@/ui/display/icon';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemSelectColor } from '@/ui/navigation/menu-item/components/MenuItemSelectColor';
import { ThemeColor } from '@/ui/theme/constants/colors';
import { textInputStyle } from '@/ui/theme/constants/effects';
import { debounce } from '~/utils/debounce';
import { boardColumnsState } from '../states/boardColumnsState';
const StyledEditTitleContainer = styled.div`
--vertical-padding: ${({ theme }) => theme.spacing(1)};
align-items: center;
display: flex;
flex-direction: row;
height: calc(36px - 2 * var(--vertical-padding));
padding: var(--vertical-padding) 0;
width: calc(100%);
`;
const StyledEditModeInput = styled.input`
${textInputStyle}
background: ${({ theme }) => theme.background.transparent.lighter};
border-color: ${({ theme }) => theme.color.blue};
border-radius: ${({ theme }) => theme.border.radius.sm};
border-style: solid;
border-width: 1px;
box-shadow: 0px 0px 0px 3px rgba(25, 97, 237, 0.1);
font-size: ${({ theme }) => theme.font.size.sm};
height: 100%;
width: 100%;
`;
type RecordBoardColumnEditTitleMenuProps = {
onClose: () => void;
onDelete?: (id: string) => void;
title: string;
onTitleEdit: (title: string, color: string) => void;
color: ThemeColor;
stageId: string;
};
type ColumnColorOption = {
name: string;
id: ThemeColor;
};
export const COLUMN_COLOR_OPTIONS: ColumnColorOption[] = [
{ name: 'Green', id: 'green' },
{ name: 'Turquoise', id: 'turquoise' },
{ name: 'Sky', id: 'sky' },
{ name: 'Blue', id: 'blue' },
{ name: 'Purple', id: 'purple' },
{ name: 'Pink', id: 'pink' },
{ name: 'Red', id: 'red' },
{ name: 'Orange', id: 'orange' },
{ name: 'Yellow', id: 'yellow' },
{ name: 'Gray', id: 'gray' },
];
export const RecordBoardColumnEditTitleMenu = ({
onClose,
onDelete,
stageId,
onTitleEdit,
title,
color,
}: RecordBoardColumnEditTitleMenuProps) => {
const [internalValue, setInternalValue] = useState(title);
const [, setBoardColumns] = useRecoilState(boardColumnsState);
const debouncedOnUpdateTitle = debounce(
(newTitle) => onTitleEdit(newTitle, color),
200,
);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const title = event.target.value;
setInternalValue(title);
debouncedOnUpdateTitle(title);
setBoardColumns((previousBoardColumns) =>
previousBoardColumns.map((column) =>
column.id === stageId ? { ...column, title: title } : column,
),
);
};
const handleColorChange = (newColor: ThemeColor) => {
onTitleEdit(title, newColor);
onClose();
setBoardColumns((previousBoardColumns) =>
previousBoardColumns.map((column) =>
column.id === stageId
? { ...column, colorCode: newColor ? newColor : 'gray' }
: column,
),
);
};
const handleDelete = useCallback(() => {
setBoardColumns((previousBoardColumns) =>
previousBoardColumns.filter((column) => column.id !== stageId),
);
onDelete?.(stageId);
onClose();
}, [onClose, onDelete, setBoardColumns, stageId]);
return (
<DropdownMenuItemsContainer>
<StyledEditTitleContainer>
<StyledEditModeInput
value={internalValue}
onChange={handleChange}
autoComplete="off"
autoFocus
/>
</StyledEditTitleContainer>
<DropdownMenuSeparator />
{COLUMN_COLOR_OPTIONS.map((colorOption) => (
<MenuItemSelectColor
key={colorOption.name}
onClick={() => {
handleColorChange(colorOption.id);
}}
color={colorOption.id}
selected={colorOption.id === color}
text={colorOption.name}
/>
))}
<DropdownMenuSeparator />
<MenuItem
onClick={handleDelete}
LeftIcon={IconTrash}
text="Delete"
accent="danger"
/>
</DropdownMenuItemsContainer>
);
};

View File

@ -0,0 +1,26 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import {
COLUMN_COLOR_OPTIONS,
RecordBoardColumnEditTitleMenu,
} from '../RecordBoardColumnEditTitleMenu';
const meta: Meta<typeof RecordBoardColumnEditTitleMenu> = {
title: 'UI/Layout/Board/BoardColumnMenu',
component: RecordBoardColumnEditTitleMenu,
decorators: [ComponentDecorator],
argTypes: {
color: {
control: 'select',
options: COLUMN_COLOR_OPTIONS.map(({ id }) => id),
},
},
args: { color: 'green', title: 'Column title' },
};
export default meta;
type Story = StoryObj<typeof RecordBoardColumnEditTitleMenu>;
export const AllTags: Story = {};

View File

@ -0,0 +1,2 @@
// We should either apply the constant all caps case or maybe define a more general enum to store those ids ?
export const BoardOptionsDropdownId = 'board-options';

View File

@ -0,0 +1,11 @@
import React from 'react';
import { useRecoilValue } from 'recoil';
import { ContextMenu } from '@/ui/navigation/context-menu/components/ContextMenu';
import { selectedCardIdsSelector } from '../../states/selectors/selectedCardIdsSelector';
export const RecordBoardContextMenu = () => {
const selectedCardIds = useRecoilValue(selectedCardIdsSelector);
return <ContextMenu selectedIds={selectedCardIds}></ContextMenu>;
};

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const BoardCardIdContext = createContext<string | null>(null);

View File

@ -0,0 +1,12 @@
import { createContext } from 'react';
import { BoardColumnDefinition } from '../types/BoardColumnDefinition';
type BoardColumn = {
id: string;
columnDefinition: BoardColumnDefinition;
isFirstColumn: boolean;
isLastColumn: boolean;
};
export const BoardColumnContext = createContext<BoardColumn | null>(null);

View File

@ -0,0 +1,5 @@
import { createContext } from 'react';
import { BoardOptions } from '@/ui/object/record-board/types/BoardOptions';
export const BoardOptionsContext = createContext<BoardOptions | null>(null);

View File

@ -0,0 +1,27 @@
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { IconTrash } from '@/ui/display/icon';
import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState';
import { useDeleteSelectedBoardCards } from '@/ui/object/record-board/hooks/useDeleteSelectedBoardCards';
export const useBoardActionBarEntries = () => {
const setActionBarEntriesRecoil = useSetRecoilState(actionBarEntriesState);
const deleteSelectedBoardCards = useDeleteSelectedBoardCards();
const setActionBarEntries = useCallback(() => {
setActionBarEntriesRecoil([
{
label: 'Delete',
Icon: IconTrash,
accent: 'danger',
onClick: deleteSelectedBoardCards,
},
]);
}, [deleteSelectedBoardCards, setActionBarEntriesRecoil]);
return {
setActionBarEntries,
};
};

View File

@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata';
import { savedBoardCardFieldsFamilyState } from '@/ui/object/record-board/states/savedBoardCardFieldsFamilyState';
import { BoardFieldDefinition } from '@/ui/object/record-board/types/BoardFieldDefinition';
import { ColumnDefinition } from '@/ui/object/record-table/types/ColumnDefinition';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { boardCardFieldsScopedState } from '../states/boardCardFieldsScopedState';
import { useBoardContext } from './useBoardContext';
export const useBoardCardFields = () => {
const { BoardRecoilScopeContext, onFieldsChange } = useBoardContext();
const [, setBoardCardFields] = useRecoilScopedState(
boardCardFieldsScopedState,
BoardRecoilScopeContext,
);
const [, setSavedBoardCardFields] = useRecoilScopedState(
savedBoardCardFieldsFamilyState,
BoardRecoilScopeContext,
);
const handleFieldVisibilityChange = (
field: Omit<ColumnDefinition<FieldMetadata>, 'size' | 'position'>,
) => {
setBoardCardFields((previousFields) =>
previousFields.map((previousField) =>
previousField.fieldMetadataId === field.fieldMetadataId
? { ...previousField, isVisible: !field.isVisible }
: previousField,
),
);
};
const handleFieldsChange = useCallback(
async (fields: BoardFieldDefinition<FieldMetadata>[]) => {
setSavedBoardCardFields(fields);
setBoardCardFields(fields);
await onFieldsChange?.(fields);
},
[setBoardCardFields, setSavedBoardCardFields, onFieldsChange],
);
const handleFieldsReorder = useCallback(
async (fields: BoardFieldDefinition<FieldMetadata>[]) => {
const updatedFields = fields.map((column, index) => ({
...column,
position: index,
}));
await handleFieldsChange(updatedFields);
},
[handleFieldsChange],
);
return { handleFieldVisibilityChange, handleFieldsReorder };
};

View File

@ -0,0 +1,55 @@
import { useRecoilState } from 'recoil';
import { useUpdateOneObjectRecord } from '@/object-record/hooks/useUpdateOneObjectRecord';
import { PipelineStep } from '@/pipeline/types/PipelineStep';
import { useMoveViewColumns } from '@/views/hooks/useMoveViewColumns';
import { boardColumnsState } from '../states/boardColumnsState';
import { BoardColumnDefinition } from '../types/BoardColumnDefinition';
export const useBoardColumns = () => {
const [boardColumns, setBoardColumns] = useRecoilState(boardColumnsState);
const { handleColumnMove } = useMoveViewColumns();
const { updateOneObject: updateOnePipelineStep } =
useUpdateOneObjectRecord<PipelineStep>({
objectNameSingular: 'pipelineStep',
});
const updatedPipelineSteps = (stages: BoardColumnDefinition[]) => {
if (!stages.length) return;
return Promise.all(
stages.map((stage) =>
updateOnePipelineStep?.({
idToUpdate: stage.id,
input: {
position: stage.position,
},
}),
),
);
};
const persistBoardColumns = async () => {
await updatedPipelineSteps(boardColumns);
};
const handleMoveBoardColumn = (
direction: 'left' | 'right',
column: BoardColumnDefinition,
) => {
const currentColumnArrayIndex = boardColumns.findIndex(
(tableColumn) => tableColumn.id === column.id,
);
const columns = handleColumnMove(
direction,
currentColumnArrayIndex,
boardColumns,
);
setBoardColumns(columns);
};
return { handleMoveBoardColumn, persistBoardColumns };
};

View File

@ -0,0 +1,7 @@
import { useContext } from 'react';
import { BoardContext } from '@/companies/states/contexts/BoardContext';
export const useBoardContext = () => {
return useContext(BoardContext);
};

View File

@ -0,0 +1,29 @@
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { IconTrash } from '@/ui/display/icon';
import { contextMenuEntriesState } from '@/ui/navigation/context-menu/states/contextMenuEntriesState';
import { useDeleteSelectedBoardCards } from '@/ui/object/record-board/hooks/useDeleteSelectedBoardCards';
export const useBoardContextMenuEntries = () => {
const setContextMenuEntriesRecoil = useSetRecoilState(
contextMenuEntriesState,
);
const deleteSelectedBoardCards = useDeleteSelectedBoardCards();
const setContextMenuEntries = useCallback(() => {
setContextMenuEntriesRecoil([
{
label: 'Delete',
Icon: IconTrash,
accent: 'danger',
onClick: deleteSelectedBoardCards,
},
]);
}, [deleteSelectedBoardCards, setContextMenuEntriesRecoil]);
return {
setContextMenuEntries,
};
};

View File

@ -0,0 +1,45 @@
import { useContext } from 'react';
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
import { actionBarOpenState } from '@/ui/navigation/action-bar/states/actionBarIsOpenState';
import { activeCardIdsState } from '@/ui/object/record-board/states/activeCardIdsState';
import { BoardCardIdContext } from '../contexts/BoardCardIdContext';
import { isCardSelectedFamilyState } from '../states/isCardSelectedFamilyState';
export const useCurrentCardSelected = () => {
const currentCardId = useContext(BoardCardIdContext);
const isCurrentCardSelected = useRecoilValue(
isCardSelectedFamilyState(currentCardId ?? ''),
);
const setActiveCardIds = useSetRecoilState(activeCardIdsState);
const setCurrentCardSelected = useRecoilCallback(
({ set }) =>
(selected: boolean) => {
if (!currentCardId) return;
set(isCardSelectedFamilyState(currentCardId), selected);
set(actionBarOpenState, selected);
if (selected) {
setActiveCardIds((prevActiveCardIds) => [
...prevActiveCardIds,
currentCardId,
]);
} else {
setActiveCardIds((prevActiveCardIds) =>
prevActiveCardIds.filter((id) => id !== currentCardId),
);
}
},
[currentCardId, setActiveCardIds],
);
return {
isCurrentCardSelected,
setCurrentCardSelected,
};
};

View File

@ -0,0 +1,41 @@
import { useApolloClient } from '@apollo/client';
import { useRecoilCallback } from 'recoil';
import { useDeleteOneObjectRecord } from '@/object-record/hooks/useDeleteOneObjectRecord';
import { Opportunity } from '@/pipeline/types/Opportunity';
import { selectedCardIdsSelector } from '../states/selectors/selectedCardIdsSelector';
import { useRemoveCardIds } from './useRemoveCardIds';
export const useDeleteSelectedBoardCards = () => {
const removeCardIds = useRemoveCardIds();
const apolloClient = useApolloClient();
const { deleteOneObject: deleteOneOpportunity } =
useDeleteOneObjectRecord<Opportunity>({
objectNameSingular: 'opportunity',
});
const deleteSelectedBoardCards = useRecoilCallback(
({ snapshot }) =>
async () => {
const selectedCardIds = snapshot
.getLoadable(selectedCardIdsSelector)
.getValue();
await Promise.all(
selectedCardIds.map(async (id) => {
await deleteOneOpportunity?.(id);
}),
);
removeCardIds(selectedCardIds);
selectedCardIds.forEach((id) => {
apolloClient.cache.evict({ id: `Opportunity:${id}` });
});
},
[apolloClient.cache, removeCardIds, deleteOneOpportunity],
);
return deleteSelectedBoardCards;
};

View File

@ -0,0 +1,26 @@
// 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 { useRecoilCallback } from 'recoil';
import { boardCardIdsByColumnIdFamilyState } from '../states/boardCardIdsByColumnIdFamilyState';
import { boardColumnsState } from '../states/boardColumnsState';
export const useRemoveCardIds = () =>
useRecoilCallback(
({ snapshot, set }) =>
(cardIdToRemove: string[]) => {
const boardColumns = snapshot
.getLoadable(boardColumnsState)
.valueOrThrow();
boardColumns.forEach((boardColumn) => {
const columnCardIds = snapshot
.getLoadable(boardCardIdsByColumnIdFamilyState(boardColumn.id))
.valueOrThrow();
set(
boardCardIdsByColumnIdFamilyState(boardColumn.id),
columnCardIds.filter((cardId) => !cardIdToRemove.includes(cardId)),
);
});
},
[],
);

View File

@ -0,0 +1,47 @@
import { useRecoilCallback } from 'recoil';
import { actionBarOpenState } from '@/ui/navigation/action-bar/states/actionBarIsOpenState';
import { activeCardIdsState } from '../states/activeCardIdsState';
import { isCardSelectedFamilyState } from '../states/isCardSelectedFamilyState';
export const useSetCardSelected = () => {
const setCardSelected = useRecoilCallback(
({ set, snapshot }) =>
(cardId: string, selected: boolean) => {
const activeCardIds = snapshot.getLoadable(activeCardIdsState).contents;
set(isCardSelectedFamilyState(cardId), selected);
set(actionBarOpenState, selected || activeCardIds.length > 0);
if (selected) {
set(activeCardIdsState, [...activeCardIds, cardId]);
} else {
set(
activeCardIdsState,
activeCardIds.filter((id: string) => id !== cardId),
);
}
},
);
const unselectAllActiveCards = useRecoilCallback(
({ set, snapshot }) =>
() => {
const activeCardIds = snapshot.getLoadable(activeCardIdsState).contents;
activeCardIds.forEach((cardId: string) => {
set(isCardSelectedFamilyState(cardId), false);
});
set(activeCardIdsState, []);
set(actionBarOpenState, false);
},
[],
);
return {
setCardSelected,
unselectAllActiveCards,
};
};

View File

@ -0,0 +1,85 @@
import { DropResult } 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 { useRecoilCallback } from 'recoil';
import { boardCardIdsByColumnIdFamilyState } from '../states/boardCardIdsByColumnIdFamilyState';
import { boardColumnsState } from '../states/boardColumnsState';
import { BoardColumnDefinition } from '../types/BoardColumnDefinition';
export const useUpdateBoardCardIds = () =>
useRecoilCallback(
({ snapshot, set }) =>
(result: DropResult) => {
const currentBoardColumns = snapshot
.getLoadable(boardColumnsState)
.valueOrThrow();
const newBoardColumns = [...currentBoardColumns];
const { destination, source } = result;
if (!destination) return;
const sourceColumnIndex = newBoardColumns.findIndex(
(boardColumn: BoardColumnDefinition) =>
boardColumn.id === source.droppableId,
);
const sourceColumn = newBoardColumns[sourceColumnIndex];
const destinationColumnIndex = newBoardColumns.findIndex(
(boardColumn: BoardColumnDefinition) =>
boardColumn.id === destination.droppableId,
);
const destinationColumn = newBoardColumns[destinationColumnIndex];
if (!destinationColumn || !sourceColumn) return;
const sourceCardIds = [
...snapshot
.getLoadable(boardCardIdsByColumnIdFamilyState(sourceColumn.id))
.valueOrThrow(),
];
const destinationCardIds = [
...snapshot
.getLoadable(
boardCardIdsByColumnIdFamilyState(destinationColumn.id),
)
.valueOrThrow(),
];
const destinationIndex =
destination.index >= destinationCardIds.length
? destinationCardIds.length - 1
: destination.index;
if (sourceColumn.id === destinationColumn.id) {
const [deletedCardId] = sourceCardIds.splice(source.index, 1);
sourceCardIds.splice(destinationIndex, 0, deletedCardId);
set(
boardCardIdsByColumnIdFamilyState(sourceColumn.id),
sourceCardIds,
);
} else {
const [removedCardId] = sourceCardIds.splice(source.index, 1);
destinationCardIds.splice(destinationIndex, 0, removedCardId);
set(
boardCardIdsByColumnIdFamilyState(sourceColumn.id),
sourceCardIds,
);
set(
boardCardIdsByColumnIdFamilyState(destinationColumn.id),
destinationCardIds,
);
}
return newBoardColumns;
},
[],
);

View File

@ -0,0 +1,37 @@
import { useView } from '@/views/hooks/useView';
import { Dropdown } from '../../../../layout/dropdown/components/Dropdown';
import { DropdownScope } from '../../../../layout/dropdown/scopes/DropdownScope';
import { BoardOptionsDropdownId } from '../../components/constants/BoardOptionsDropdownId';
import { BoardOptionsHotkeyScope } from '../../types/BoardOptionsHotkeyScope';
import { BoardOptionsDropdownButton } from './BoardOptionsDropdownButton';
import {
BoardOptionsDropdownContent,
BoardOptionsDropdownContentProps,
} from './BoardOptionsDropdownContent';
type BoardOptionsDropdownProps = Pick<
BoardOptionsDropdownContentProps,
'onStageAdd'
>;
export const BoardOptionsDropdown = ({
onStageAdd,
}: BoardOptionsDropdownProps) => {
const { setViewEditMode } = useView();
return (
<DropdownScope dropdownScopeId={BoardOptionsDropdownId}>
<Dropdown
clickableComponent={<BoardOptionsDropdownButton />}
dropdownComponents={
<BoardOptionsDropdownContent onStageAdd={onStageAdd} />
}
dropdownHotkeyScope={{ scope: BoardOptionsHotkeyScope.Dropdown }}
onClickOutside={() => setViewEditMode('none')}
dropdownMenuWidth={170}
/>
</DropdownScope>
);
};

View File

@ -0,0 +1,19 @@
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
export const BoardOptionsDropdownButton = () => {
const { isDropdownOpen, toggleDropdown } = useDropdown();
const handleClick = () => {
toggleDropdown();
};
return (
<StyledHeaderDropdownButton
isUnfolded={isDropdownOpen}
onClick={handleClick}
>
Options
</StyledHeaderDropdownButton>
);
};

View File

@ -0,0 +1,245 @@
import { useCallback, useContext, useRef, useState } from 'react';
import { OnDragEndResponder } from '@hello-pangea/dnd';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { v4 } from 'uuid';
import { BoardContext } from '@/companies/states/contexts/BoardContext';
import {
IconBaselineDensitySmall,
IconChevronLeft,
IconLayoutKanban,
IconPlus,
IconTag,
} from '@/ui/display/icon';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemNavigate } from '@/ui/navigation/menu-item/components/MenuItemNavigate';
import { MenuItemToggle } from '@/ui/navigation/menu-item/components/MenuItemToggle';
import { ThemeColor } from '@/ui/theme/constants/colors';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection';
import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates';
import { useView } from '@/views/hooks/useView';
import { useBoardCardFields } from '../../hooks/useBoardCardFields';
import { boardColumnsState } from '../../states/boardColumnsState';
import { isCompactViewEnabledState } from '../../states/isCompactViewEnabledState';
import { hiddenBoardCardFieldsScopedSelector } from '../../states/selectors/hiddenBoardCardFieldsScopedSelector';
import { visibleBoardCardFieldsScopedSelector } from '../../states/selectors/visibleBoardCardFieldsScopedSelector';
import { BoardColumnDefinition } from '../../types/BoardColumnDefinition';
import { BoardOptionsHotkeyScope } from '../../types/BoardOptionsHotkeyScope';
export type BoardOptionsDropdownContentProps = {
onStageAdd?: (boardColumn: BoardColumnDefinition) => void;
};
type BoardOptionsMenu = 'fields' | 'stage-creation' | 'stages';
type ColumnForCreate = {
id: string;
colorCode: ThemeColor;
position: number;
title: string;
};
export const BoardOptionsDropdownContent = ({
onStageAdd,
}: BoardOptionsDropdownContentProps) => {
const { setViewEditMode, handleViewNameSubmit } = useView();
const { viewEditModeState, currentViewSelector } = useViewScopedStates();
const { BoardRecoilScopeContext } = useContext(BoardContext);
const viewEditMode = useRecoilValue(viewEditModeState);
const currentView = useRecoilValue(currentViewSelector);
const stageInputRef = useRef<HTMLInputElement>(null);
const viewEditInputRef = useRef<HTMLInputElement>(null);
const [currentMenu, setCurrentMenu] = useState<
BoardOptionsMenu | undefined
>();
const [boardColumns, setBoardColumns] = useRecoilState(boardColumnsState);
const [isCompactViewEnabled, setIsCompactViewEnabled] = useRecoilState(
isCompactViewEnabledState,
);
const hiddenBoardCardFields = useRecoilScopedValue(
hiddenBoardCardFieldsScopedSelector,
BoardRecoilScopeContext,
);
const hasHiddenFields = hiddenBoardCardFields.length > 0;
const visibleBoardCardFields = useRecoilScopedValue(
visibleBoardCardFieldsScopedSelector,
BoardRecoilScopeContext,
);
const hasVisibleFields = visibleBoardCardFields.length > 0;
const handleStageSubmit = () => {
if (currentMenu !== 'stage-creation' || !stageInputRef?.current?.value)
return;
const columnToCreate: ColumnForCreate = {
id: v4(),
colorCode: 'gray',
position: boardColumns.length,
title: stageInputRef.current.value,
};
setBoardColumns((previousBoardColumns) => [
...previousBoardColumns,
columnToCreate,
]);
onStageAdd?.(columnToCreate);
};
const resetMenu = () => setCurrentMenu(undefined);
const handleMenuNavigate = (menu: BoardOptionsMenu) => {
handleViewNameSubmit();
setCurrentMenu(menu);
};
const { handleFieldVisibilityChange, handleFieldsReorder } =
useBoardCardFields();
const { closeDropdown } = useDropdown();
const handleReorderField: OnDragEndResponder = useCallback(
(result) => {
if (!result.destination) {
return;
}
const reorderFields = [...visibleBoardCardFields];
const [removed] = reorderFields.splice(result.source.index - 1, 1);
reorderFields.splice(result.destination.index - 1, 0, removed);
handleFieldsReorder(reorderFields);
},
[handleFieldsReorder, visibleBoardCardFields],
);
useScopedHotkeys(
Key.Escape,
() => {
setViewEditMode('none');
closeDropdown();
},
BoardOptionsHotkeyScope.Dropdown,
);
useScopedHotkeys(
Key.Enter,
() => {
const name = viewEditInputRef.current?.value;
resetMenu();
setViewEditMode('none');
closeDropdown();
handleStageSubmit();
handleViewNameSubmit(name);
closeDropdown();
},
BoardOptionsHotkeyScope.Dropdown,
);
return (
<>
{!currentMenu && (
<>
<DropdownMenuInput
ref={viewEditInputRef}
autoFocus={viewEditMode !== 'none'}
placeholder={
viewEditMode === 'create'
? 'New view'
: viewEditMode === 'edit'
? 'View name'
: ''
}
defaultValue={viewEditMode === 'create' ? '' : currentView?.name}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItemNavigate
onClick={() => handleMenuNavigate('fields')}
LeftIcon={IconTag}
text="Fields"
/>
<MenuItemNavigate
onClick={() => handleMenuNavigate('stages')}
LeftIcon={IconLayoutKanban}
text="Stages"
/>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconBaselineDensitySmall}
onToggleChange={setIsCompactViewEnabled}
toggled={isCompactViewEnabled}
text="Compact view"
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</>
)}
{currentMenu === 'stages' && (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetMenu}>
Stages
</DropdownMenuHeader>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItem
onClick={() => setCurrentMenu('stage-creation')}
LeftIcon={IconPlus}
text="Add stage"
/>
</DropdownMenuItemsContainer>
</>
)}
{currentMenu === 'stage-creation' && (
<DropdownMenuSearchInput
autoFocus
placeholder="New stage"
ref={stageInputRef}
/>
)}
{currentMenu === 'fields' && (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetMenu}>
Fields
</DropdownMenuHeader>
<DropdownMenuSeparator />
{hasVisibleFields && (
<ViewFieldsVisibilityDropdownSection
title="Visible"
fields={visibleBoardCardFields}
onVisibilityChange={handleFieldVisibilityChange}
isDraggable={true}
onDragEnd={handleReorderField}
/>
)}
{hasVisibleFields && hasHiddenFields && <DropdownMenuSeparator />}
{hasHiddenFields && (
<ViewFieldsVisibilityDropdownSection
title="Hidden"
fields={hiddenBoardCardFields}
onVisibilityChange={handleFieldVisibilityChange}
isDraggable={false}
/>
)}
</>
)}
</>
);
};

View File

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

View File

@ -0,0 +1,11 @@
import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata';
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
import { BoardFieldDefinition } from '../types/BoardFieldDefinition';
export const availableBoardCardFieldsScopedState = createScopedState<
BoardFieldDefinition<FieldMetadata>[]
>({
key: 'availableBoardCardFieldsScopedState',
defaultValue: [],
});

View File

@ -0,0 +1,13 @@
import { atomFamily } from 'recoil';
import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata';
import { BoardFieldDefinition } from '../types/BoardFieldDefinition';
export const boardCardFieldsScopedState = atomFamily<
BoardFieldDefinition<FieldMetadata>[],
string
>({
key: 'boardCardFieldsScopedState',
default: [],
});

View File

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

View File

@ -0,0 +1,8 @@
import { atom } from 'recoil';
import { BoardColumnDefinition } from '@/ui/object/record-board/types/BoardColumnDefinition';
export const boardColumnsState = atom<BoardColumnDefinition[]>({
key: 'boardColumnsState',
default: [],
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isBoardLoadedState = atom<boolean>({
key: 'isBoardLoadedState',
default: false,
});

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const isCardInCompactViewState = atomFamily<boolean, string>({
key: 'isCardInCompactViewState',
default: true,
});

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const isCardSelectedFamilyState = atomFamily<boolean, string>({
key: 'isCardSelectedFamilyState',
default: false,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isCompactViewEnabledState = atom<boolean>({
key: 'isCompactViewEnabledState',
default: false,
});

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const BoardColumnRecoilScopeContext = createContext<string | null>(null);

View File

@ -0,0 +1,13 @@
import { atomFamily } from 'recoil';
import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata';
import { BoardFieldDefinition } from '../types/BoardFieldDefinition';
export const savedBoardCardFieldsFamilyState = atomFamily<
BoardFieldDefinition<FieldMetadata>[],
string | undefined
>({
key: 'savedBoardCardFieldsFamilyState',
default: [],
});

View File

@ -0,0 +1,8 @@
import { atom } from 'recoil';
import { BoardColumnDefinition } from '../types/BoardColumnDefinition';
export const savedBoardColumnsState = atom<BoardColumnDefinition[]>({
key: 'savedBoardColumnsState',
default: [],
});

View File

@ -0,0 +1,16 @@
import { selectorFamily } from 'recoil';
import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata';
import { BoardFieldDefinition } from '../../types/BoardFieldDefinition';
import { boardCardFieldsScopedState } from '../boardCardFieldsScopedState';
export const boardCardFieldsByKeyScopedSelector = selectorFamily({
key: 'boardCardFieldsByKeyScopedSelector',
get:
(scopeId: string) =>
({ get }) =>
get(boardCardFieldsScopedState(scopeId)).reduce<
Record<string, BoardFieldDefinition<FieldMetadata>>
>((result, field) => ({ ...result, [field.fieldMetadataId]: field }), {}),
});

View File

@ -0,0 +1,29 @@
import { selectorFamily } from 'recoil';
import { companyProgressesFamilyState } from '@/companies/states/companyProgressesFamilyState';
import { boardCardIdsByColumnIdFamilyState } from '../boardCardIdsByColumnIdFamilyState';
// TODO: this state should be computed during the synchronization web-hook and put in a generic
// boardColumnTotalsFamilyState indexed by columnId.
export const boardColumnTotalsFamilySelector = selectorFamily({
key: 'boardColumnTotalsFamilySelector',
get:
(pipelineStepId: string) =>
({ get }) => {
const cardIds = get(boardCardIdsByColumnIdFamilyState(pipelineStepId));
const opportunities = cardIds.map((opportunityId: string) =>
get(companyProgressesFamilyState(opportunityId)),
);
const pipelineStepTotal: number =
opportunities?.reduce(
(acc: number, curr: any) =>
acc + curr?.opportunity.amount.amountMicros / 1000000,
0,
) || 0;
return pipelineStepTotal;
},
});

View File

@ -0,0 +1,23 @@
import { selectorFamily } from 'recoil';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { boardCardFieldsScopedState } from '../boardCardFieldsScopedState';
import { savedBoardCardFieldsFamilyState } from '../savedBoardCardFieldsFamilyState';
export const canPersistBoardCardFieldsScopedFamilySelector = selectorFamily({
key: 'canPersistBoardCardFieldsScopedFamilySelector',
get:
({
recoilScopeId,
viewId,
}: {
recoilScopeId: string;
viewId: string | undefined;
}) =>
({ get }) =>
!isDeeplyEqual(
get(savedBoardCardFieldsFamilyState(viewId)),
get(boardCardFieldsScopedState(recoilScopeId)),
),
});

View File

@ -0,0 +1,12 @@
import { selector } from 'recoil';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { boardColumnsState } from '../boardColumnsState';
import { savedBoardColumnsState } from '../savedBoardColumnsState';
export const canPersistBoardColumnsSelector = selector<boolean>({
key: 'canPersistBoardCardFieldsScopedFamilySelector',
get: ({ get }) =>
!isDeeplyEqual(get(boardColumnsState), get(savedBoardColumnsState)),
});

View File

@ -0,0 +1,22 @@
import { selectorFamily } from 'recoil';
import { availableBoardCardFieldsScopedState } from '../availableBoardCardFieldsScopedState';
import { boardCardFieldsScopedState } from '../boardCardFieldsScopedState';
export const hiddenBoardCardFieldsScopedSelector = selectorFamily({
key: 'hiddenBoardCardFieldsScopedSelector',
get:
(scopeId: string) =>
({ get }) => {
const fields = get(boardCardFieldsScopedState(scopeId));
const fieldKeys = fields.map(({ fieldMetadataId }) => fieldMetadataId);
const otherAvailableKeys = get(
availableBoardCardFieldsScopedState({ scopeId }),
).filter(({ fieldMetadataId }) => !fieldKeys.includes(fieldMetadataId));
return [
...fields.filter((field) => !field.isVisible),
...otherAvailableKeys,
];
},
});

View File

@ -0,0 +1,16 @@
import { selectorFamily } from 'recoil';
import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata';
import { BoardFieldDefinition } from '../../types/BoardFieldDefinition';
import { savedBoardCardFieldsFamilyState } from '../savedBoardCardFieldsFamilyState';
export const savedBoardCardFieldsByKeyFamilySelector = selectorFamily({
key: 'savedBoardCardFieldsByKeyFamilySelector',
get:
(viewId: string | undefined) =>
({ get }) =>
get(savedBoardCardFieldsFamilyState(viewId)).reduce<
Record<string, BoardFieldDefinition<FieldMetadata>>
>((result, field) => ({ ...result, [field.fieldMetadataId]: field }), {}),
});

View File

@ -0,0 +1,22 @@
import { selector } from 'recoil';
import { boardCardIdsByColumnIdFamilyState } from '../boardCardIdsByColumnIdFamilyState';
import { boardColumnsState } from '../boardColumnsState';
import { isCardSelectedFamilyState } from '../isCardSelectedFamilyState';
export const selectedCardIdsSelector = selector<string[]>({
key: 'selectedCardIdsSelector',
get: ({ get }) => {
const boardColumns = get(boardColumnsState);
const cardIds = boardColumns.flatMap((boardColumn) =>
get(boardCardIdsByColumnIdFamilyState(boardColumn.id)),
);
const selectedCardIds = cardIds.filter(
(cardId) => get(isCardSelectedFamilyState(cardId)) === true,
);
return selectedCardIds;
},
});

View File

@ -0,0 +1,13 @@
import { selectorFamily } from 'recoil';
import { boardCardFieldsScopedState } from '../boardCardFieldsScopedState';
export const visibleBoardCardFieldsScopedSelector = selectorFamily({
key: 'visibleBoardCardFieldsScopedSelector',
get:
(scopeId: string) =>
({ get }) =>
get(boardCardFieldsScopedState(scopeId))
.filter((field) => field.isVisible)
.sort((a, b) => a.position - b.position),
});

View File

@ -0,0 +1,8 @@
import { ThemeColor } from '@/ui/theme/constants/colors';
export type BoardColumnDefinition = {
id: string;
title: string;
position: number;
colorCode?: ThemeColor;
};

View File

@ -0,0 +1,3 @@
export enum BoardColumnHotkeyScope {
BoardColumn = 'board-column',
}

View File

@ -0,0 +1,9 @@
import { FieldDefinition } from '@/ui/object/field/types/FieldDefinition';
import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata';
export type BoardFieldDefinition<T extends FieldMetadata> =
FieldDefinition<T> & {
position: number;
isVisible?: boolean;
viewFieldId?: string;
};

View File

@ -0,0 +1,12 @@
import { ComponentType } from 'react';
import { Opportunity } from '@/pipeline/types/Opportunity';
import { FilterDefinitionByEntity } from '@/ui/object/object-filter-dropdown/types/FilterDefinitionByEntity';
import { SortDefinition } from '@/ui/object/object-sort-dropdown/types/SortDefinition';
export type BoardOptions = {
newCardComponent: React.ReactNode;
CardComponent: ComponentType;
filterDefinitions: FilterDefinitionByEntity<Opportunity>[];
sortDefinitions: SortDefinition[];
};

View File

@ -0,0 +1,3 @@
export enum BoardOptionsHotkeyScope {
Dropdown = 'board-options-dropdown',
}

View File

@ -0,0 +1,3 @@
export enum ColumnHotkeyScope {
EditColumnName = 'EditColumnNameHotkeyScope',
}