Refactor UI folder (#2016)

* Added Overview page

* Revised Getting Started page

* Minor revision

* Edited readme, minor modifications to docs

* Removed sweep.yaml, .devcontainer, .ergomake

* Moved security.md to .github, added contributing.md

* changes as per code review

* updated contributing.md

* fixed broken links & added missing links in doc, improved structure

* fixed link in wsl setup

* fixed server link, added https cloning in yarn-setup

* removed package-lock.json

* added doc card, admonitions

* removed underline from nav buttons

* refactoring modules/ui

* refactoring modules/ui

* Change folder case

* Fix theme location

* Fix case 2

* Fix storybook

---------

Co-authored-by: Nimra Ahmed <nimra1408@gmail.com>
Co-authored-by: Nimra Ahmed <50912134+nimraahmed@users.noreply.github.com>
This commit is contained in:
Charles Bochet
2023-10-14 00:04:29 +02:00
committed by GitHub
parent a35ea5e8f9
commit 258685467b
732 changed files with 1106 additions and 1010 deletions

View File

@ -0,0 +1,120 @@
import React, { useContext } from 'react';
import styled from '@emotion/styled';
import { Tag } from '@/ui/display/tag/components/Tag';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { BoardColumnContext } from '../contexts/BoardColumnContext';
import { BoardColumnHotkeyScope } from '../types/BoardColumnHotkeyScope';
import { BoardColumnMenu } from './BoardColumnMenu';
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;
`;
export type BoardColumnProps = {
onDelete?: (id: string) => void;
onTitleEdit: (title: string, color: string) => void;
totalAmount?: number;
children: React.ReactNode;
numChildren: number;
stageId: string;
};
export const BoardColumn = ({
onDelete,
onTitleEdit,
totalAmount,
children,
numChildren,
stageId,
}: BoardColumnProps) => {
const boardColumn = 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);
};
if (!boardColumn) return <></>;
const { isFirstColumn, columnDefinition } = boardColumn;
return (
<StyledColumn isFirstColumn={isFirstColumn}>
<StyledHeader>
<Tag
onClick={handleTitleClick}
color={columnDefinition.colorCode ?? 'gray'}
text={columnDefinition.title}
/>
{!!totalAmount && <StyledAmount>${totalAmount}</StyledAmount>}
<StyledNumChildren>{numChildren}</StyledNumChildren>
</StyledHeader>
{isBoardColumnMenuOpen && (
<BoardColumnMenu
onClose={handleClose}
onDelete={onDelete}
onTitleEdit={onTitleEdit}
stageId={stageId}
/>
)}
{children}
</StyledColumn>
);
};

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 { StyledDropdownMenuSeparator } from '@/ui/layout/dropdown/components/StyledDropdownMenuSeparator';
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%;
`;
export type BoardColumnEditTitleMenuProps = {
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 BoardColumnEditTitleMenu = ({
onClose,
onDelete,
stageId,
onTitleEdit,
title,
color,
}: BoardColumnEditTitleMenuProps) => {
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>
<StyledDropdownMenuSeparator />
{COLUMN_COLOR_OPTIONS.map((colorOption) => (
<MenuItemSelectColor
key={colorOption.name}
onClick={() => {
handleColorChange(colorOption.id);
}}
color={colorOption.id}
selected={colorOption.id === color}
text={colorOption.name}
/>
))}
<StyledDropdownMenuSeparator />
<MenuItem
onClick={handleDelete}
LeftIcon={IconTrash}
text="Delete"
accent="danger"
/>
</DropdownMenuItemsContainer>
);
};

View File

@ -0,0 +1,189 @@
import { useCallback, useContext, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { useCreateCompanyProgress } from '@/companies/hooks/useCreateCompanyProgress';
import { useFilteredSearchCompanyQuery } from '@/companies/hooks/useFilteredSearchCompanyQuery';
import {
IconArrowLeft,
IconArrowRight,
IconPencil,
IconPlus,
} from '@/ui/display/icon';
import { useSnackBar } from '@/ui/feedback/snack-bar/hooks/useSnackBar';
import { SingleEntitySelect } from '@/ui/input/relation-picker/components/SingleEntitySelect';
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 { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { StyledDropdownMenu } from '@/ui/layout/dropdown/components/StyledDropdownMenu';
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 { BoardColumnEditTitleMenu } from './BoardColumnEditTitleMenu';
const StyledMenuContainer = styled.div`
position: absolute;
top: ${({ theme }) => theme.spacing(10)};
width: 200px;
z-index: 1;
`;
type BoardColumnMenuProps = {
onClose: () => void;
onDelete?: (id: string) => void;
onTitleEdit: (title: string, color: string) => void;
stageId: string;
};
type Menu = 'actions' | 'add' | 'title';
export const BoardColumnMenu = ({
onClose,
onDelete,
onTitleEdit,
stageId,
}: BoardColumnMenuProps) => {
const [currentMenu, setCurrentMenu] = useState('actions');
const column = useContext(BoardColumnContext);
const boardColumnMenuRef = useRef(null);
const { enqueueSnackBar } = useSnackBar();
const createCompanyProgress = useCreateCompanyProgress();
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}>
<StyledDropdownMenu 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' && (
<BoardColumnEditTitleMenu
color={columnDefinition.colorCode ?? 'gray'}
onClose={closeMenu}
onTitleEdit={onTitleEdit}
title={columnDefinition.title}
onDelete={onDelete}
stageId={stageId}
/>
)}
{currentMenu === 'add' && (
<SingleEntitySelect
disableBackgroundBlur
entitiesToSelect={companies.entitiesToSelect}
loading={companies.loading}
onCancel={closeMenu}
onEntitySelected={handleCompanySelected}
selectedEntity={companies.selectedEntities[0]}
/>
)}
</StyledDropdownMenu>
</StyledMenuContainer>
);
};

View File

@ -0,0 +1,123 @@
import { useContext } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { BoardContext } from '@/companies/states/contexts/BoardContext';
import { ViewBar } from '@/ui/data/view-bar/components/ViewBar';
import { ViewBarContext } from '@/ui/data/view-bar/contexts/ViewBarContext';
import { currentViewIdScopedState } from '@/ui/data/view-bar/states/currentViewIdScopedState';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { useRecoilScopeId } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopeId';
import { boardCardFieldsScopedState } from '../states/boardCardFieldsScopedState';
import { boardColumnsState } from '../states/boardColumnsState';
import { savedBoardCardFieldsFamilyState } from '../states/savedBoardCardFieldsFamilyState';
import { savedBoardColumnsState } from '../states/savedBoardColumnsState';
import { canPersistBoardCardFieldsScopedFamilySelector } from '../states/selectors/canPersistBoardCardFieldsScopedFamilySelector';
import { canPersistBoardColumnsSelector } from '../states/selectors/canPersistBoardColumnsSelector';
import { BoardColumnDefinition } from '../types/BoardColumnDefinition';
import { BoardOptionsHotkeyScope } from '../types/BoardOptionsHotkeyScope';
import { BoardScopeIds } from '../types/enums/BoardScopeIds';
import { BoardOptionsDropdown } from './BoardOptionsDropdown';
export type BoardHeaderProps = {
className?: string;
onStageAdd?: (boardColumn: BoardColumnDefinition) => void;
};
export const BoardHeader = ({ className, onStageAdd }: BoardHeaderProps) => {
const { onCurrentViewSubmit, ...viewBarContextProps } =
useContext(ViewBarContext);
const BoardRecoilScopeContext =
useContext(BoardContext).BoardRecoilScopeContext;
const ViewBarRecoilScopeContext =
useContext(ViewBarContext).ViewBarRecoilScopeContext;
const boardRecoilScopeId = useRecoilScopeId(BoardRecoilScopeContext);
const currentViewId = useRecoilScopedValue(
currentViewIdScopedState,
ViewBarRecoilScopeContext,
);
const canPersistBoardCardFields = useRecoilValue(
canPersistBoardCardFieldsScopedFamilySelector({
recoilScopeId: boardRecoilScopeId,
viewId: currentViewId,
}),
);
const canPersistBoardColumns = useRecoilValue(canPersistBoardColumnsSelector);
const [boardCardFields, setBoardCardFields] = useRecoilScopedState(
boardCardFieldsScopedState,
BoardRecoilScopeContext,
);
const [savedBoardCardFields, setSavedBoardCardFields] = useRecoilState(
savedBoardCardFieldsFamilyState(currentViewId),
);
const [_, setSearchParams] = useSearchParams();
const [boardColumns, setBoardColumns] = useRecoilState(boardColumnsState);
const [, setSavedBoardColumns] = useRecoilState(savedBoardColumnsState);
const savedBoardColumns = useRecoilValue(savedBoardColumnsState);
const handleViewBarReset = () => {
setBoardCardFields(savedBoardCardFields);
setBoardColumns(savedBoardColumns);
};
const handleViewSelect = useRecoilCallback(
({ set, snapshot }) =>
async (viewId: string) => {
const savedBoardCardFields = await snapshot.getPromise(
savedBoardCardFieldsFamilyState(viewId),
);
set(
boardCardFieldsScopedState(boardRecoilScopeId),
savedBoardCardFields,
);
setSearchParams({ view: viewId });
},
[boardRecoilScopeId, setSearchParams],
);
const handleCurrentViewSubmit = async () => {
if (canPersistBoardCardFields) {
setSavedBoardCardFields(boardCardFields);
}
if (canPersistBoardColumns) {
setSavedBoardColumns(boardColumns);
}
await onCurrentViewSubmit?.();
};
const canPersistView = canPersistBoardCardFields || canPersistBoardColumns;
return (
<ViewBarContext.Provider
value={{
...viewBarContextProps,
canPersistViewFields: canPersistView,
onCurrentViewSubmit: handleCurrentViewSubmit,
onViewBarReset: handleViewBarReset,
onViewSelect: handleViewSelect,
}}
>
<ViewBar
className={className}
optionsDropdownButton={
<BoardOptionsDropdown
customHotkeyScope={{ scope: BoardOptionsHotkeyScope.Dropdown }}
onStageAdd={onStageAdd}
/>
}
optionsDropdownScopeId={BoardScopeIds.OptionsDropdown}
/>
</ViewBarContext.Provider>
);
};

View File

@ -0,0 +1,39 @@
import { useResetRecoilState } from 'recoil';
import { ViewBarDropdownButton } from '@/ui/data/view-bar/components/ViewBarDropdownButton';
import { viewEditModeState } from '@/ui/data/view-bar/states/viewEditModeState';
import { BoardScopeIds } from '../types/enums/BoardScopeIds';
import { BoardOptionsDropdownButton } from './BoardOptionsDropdownButton';
import {
BoardOptionsDropdownContent,
BoardOptionsDropdownContentProps,
} from './BoardOptionsDropdownContent';
type BoardOptionsDropdownProps = Pick<
BoardOptionsDropdownContentProps,
'customHotkeyScope' | 'onStageAdd'
>;
export const BoardOptionsDropdown = ({
customHotkeyScope,
onStageAdd,
}: BoardOptionsDropdownProps) => {
const resetViewEditMode = useResetRecoilState(viewEditModeState);
return (
<ViewBarDropdownButton
buttonComponent={<BoardOptionsDropdownButton />}
dropdownComponents={
<BoardOptionsDropdownContent
customHotkeyScope={customHotkeyScope}
onStageAdd={onStageAdd}
/>
}
dropdownHotkeyScope={customHotkeyScope}
dropdownId={BoardScopeIds.OptionsDropdown}
onClickOutside={resetViewEditMode}
/>
);
};

View File

@ -0,0 +1,23 @@
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { BoardScopeIds } from '../types/enums/BoardScopeIds';
export const BoardOptionsDropdownButton = () => {
const { isDropdownOpen, toggleDropdown } = useDropdown({
dropdownScopeId: BoardScopeIds.OptionsDropdown,
});
const handleClick = () => {
toggleDropdown();
};
return (
<StyledHeaderDropdownButton
isUnfolded={isDropdownOpen}
onClick={handleClick}
>
Options
</StyledHeaderDropdownButton>
);
};

View File

@ -0,0 +1,256 @@
import { useContext, useRef, useState } from 'react';
import {
useRecoilCallback,
useRecoilState,
useRecoilValue,
useResetRecoilState,
} from 'recoil';
import { Key } from 'ts-key-enum';
import { v4 } from 'uuid';
import { BoardContext } from '@/companies/states/contexts/BoardContext';
import { ViewFieldsVisibilityDropdownSection } from '@/ui/data/view-bar/components/ViewFieldsVisibilityDropdownSection';
import { useUpsertView } from '@/ui/data/view-bar/hooks/useUpsertView';
import { currentViewScopedSelector } from '@/ui/data/view-bar/states/selectors/currentViewScopedSelector';
import { viewsByIdScopedSelector } from '@/ui/data/view-bar/states/selectors/viewsByIdScopedSelector';
import { viewEditModeState } from '@/ui/data/view-bar/states/viewEditModeState';
import {
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 { DropdownMenuInputContainer } from '@/ui/layout/dropdown/components/DropdownMenuInputContainer';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { StyledDropdownMenu } from '@/ui/layout/dropdown/components/StyledDropdownMenu';
import { StyledDropdownMenuSeparator } from '@/ui/layout/dropdown/components/StyledDropdownMenuSeparator';
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 { ThemeColor } from '@/ui/theme/constants/colors';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { useRecoilScopeId } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopeId';
import { useBoardCardFields } from '../hooks/useBoardCardFields';
import { boardCardFieldsScopedState } from '../states/boardCardFieldsScopedState';
import { boardColumnsState } from '../states/boardColumnsState';
import { savedBoardCardFieldsFamilyState } from '../states/savedBoardCardFieldsFamilyState';
import { hiddenBoardCardFieldsScopedSelector } from '../states/selectors/hiddenBoardCardFieldsScopedSelector';
import { visibleBoardCardFieldsScopedSelector } from '../states/selectors/visibleBoardCardFieldsScopedSelector';
import { BoardColumnDefinition } from '../types/BoardColumnDefinition';
export type BoardOptionsDropdownContentProps = {
customHotkeyScope: HotkeyScope;
onStageAdd?: (boardColumn: BoardColumnDefinition) => void;
};
type BoardOptionsMenu = 'fields' | 'stage-creation' | 'stages';
type ColumnForCreate = {
id: string;
colorCode: ThemeColor;
index: number;
title: string;
};
export const BoardOptionsDropdownContent = ({
customHotkeyScope,
onStageAdd,
}: BoardOptionsDropdownContentProps) => {
const { BoardRecoilScopeContext } = useContext(BoardContext);
const boardRecoilScopeId = useRecoilScopeId(BoardRecoilScopeContext);
const stageInputRef = useRef<HTMLInputElement>(null);
const viewEditInputRef = useRef<HTMLInputElement>(null);
const [currentMenu, setCurrentMenu] = useState<
BoardOptionsMenu | undefined
>();
const [boardColumns, setBoardColumns] = useRecoilState(boardColumnsState);
const hiddenBoardCardFields = useRecoilScopedValue(
hiddenBoardCardFieldsScopedSelector,
BoardRecoilScopeContext,
);
const hasHiddenFields = hiddenBoardCardFields.length > 0;
const visibleBoardCardFields = useRecoilScopedValue(
visibleBoardCardFieldsScopedSelector,
BoardRecoilScopeContext,
);
const hasVisibleFields = visibleBoardCardFields.length > 0;
const viewsById = useRecoilScopedValue(
viewsByIdScopedSelector,
BoardRecoilScopeContext, // TODO: replace with ViewBarRecoilScopeContext
);
const currentView = useRecoilScopedValue(
currentViewScopedSelector,
BoardRecoilScopeContext,
);
const viewEditMode = useRecoilValue(viewEditModeState);
const resetViewEditMode = useResetRecoilState(viewEditModeState);
const handleStageSubmit = () => {
if (currentMenu !== 'stage-creation' || !stageInputRef?.current?.value)
return;
const columnToCreate: ColumnForCreate = {
id: v4(),
colorCode: 'gray',
index: boardColumns.length,
title: stageInputRef.current.value,
};
setBoardColumns((previousBoardColumns) => [
...previousBoardColumns,
columnToCreate,
]);
onStageAdd?.(columnToCreate);
};
const { upsertView } = useUpsertView();
const handleViewNameSubmit = useRecoilCallback(
({ set, snapshot }) =>
async () => {
const boardCardFields = await snapshot.getPromise(
boardCardFieldsScopedState(boardRecoilScopeId),
);
const isCreateMode = viewEditMode.mode === 'create';
const name = viewEditInputRef.current?.value;
const view = await upsertView(name);
if (view && isCreateMode) {
set(savedBoardCardFieldsFamilyState(view.id), boardCardFields);
}
},
[boardRecoilScopeId, upsertView, viewEditMode.mode],
);
const resetMenu = () => setCurrentMenu(undefined);
const handleMenuNavigate = (menu: BoardOptionsMenu) => {
handleViewNameSubmit();
setCurrentMenu(menu);
};
const { handleFieldVisibilityChange } = useBoardCardFields();
const { closeDropdown } = useDropdown();
useScopedHotkeys(
Key.Escape,
() => {
resetViewEditMode();
closeDropdown();
},
customHotkeyScope.scope,
);
useScopedHotkeys(
Key.Enter,
() => {
handleStageSubmit();
handleViewNameSubmit();
resetViewEditMode();
closeDropdown();
},
customHotkeyScope.scope,
);
return (
<StyledDropdownMenu>
{!currentMenu && (
<>
<DropdownMenuInputContainer>
<DropdownMenuInput
ref={viewEditInputRef}
autoFocus={
viewEditMode.mode === 'create' || !!viewEditMode.viewId
}
placeholder={
viewEditMode.mode === 'create' ? 'New view' : 'View name'
}
defaultValue={
viewEditMode.mode === 'create'
? ''
: viewEditMode.viewId
? viewsById[viewEditMode.viewId]?.name
: currentView?.name
}
/>
</DropdownMenuInputContainer>
<StyledDropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItemNavigate
onClick={() => handleMenuNavigate('fields')}
LeftIcon={IconTag}
text="Fields"
/>
<MenuItemNavigate
onClick={() => handleMenuNavigate('stages')}
LeftIcon={IconLayoutKanban}
text="Stages"
/>
</DropdownMenuItemsContainer>
</>
)}
{currentMenu === 'stages' && (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetMenu}>
Stages
</DropdownMenuHeader>
<StyledDropdownMenuSeparator />
<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>
<StyledDropdownMenuSeparator />
{hasVisibleFields && (
<ViewFieldsVisibilityDropdownSection
title="Visible"
fields={visibleBoardCardFields}
onVisibilityChange={handleFieldVisibilityChange}
isDraggable={true}
/>
)}
{hasVisibleFields && hasHiddenFields && (
<StyledDropdownMenuSeparator />
)}
{hasHiddenFields && (
<ViewFieldsVisibilityDropdownSection
title="Hidden"
fields={hiddenBoardCardFields}
onVisibilityChange={handleFieldVisibilityChange}
isDraggable={false}
/>
)}
</>
)}
</StyledDropdownMenu>
);
};

View File

@ -0,0 +1,187 @@
import { useCallback, useRef } from 'react';
import { getOperationName } from '@apollo/client/utilities';
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 { GET_PIPELINE_PROGRESS } from '@/pipeline/graphql/queries/getPipelineProgress';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { BoardHeader } from '@/ui/layout/board/components/BoardHeader';
import { StyledBoard } from '@/ui/layout/board/components/StyledBoard';
import { BoardColumnContext } from '@/ui/layout/board/contexts/BoardColumnContext';
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 {
PipelineProgress,
PipelineStage,
useUpdateOnePipelineProgressStageMutation,
} from '~/generated/graphql';
import { logError } from '~/utils/logError';
import { useCurrentCardSelected } from '../hooks/useCurrentCardSelected';
import { useSetCardSelected } from '../hooks/useSetCardSelected';
import { useUpdateBoardCardIds } from '../hooks/useUpdateBoardCardIds';
import { boardColumnsState } from '../states/boardColumnsState';
import { BoardColumnRecoilScopeContext } from '../states/recoil-scope-contexts/BoardColumnRecoilScopeContext';
import { BoardColumnDefinition } from '../types/BoardColumnDefinition';
import { BoardOptions } from '../types/BoardOptions';
import { EntityBoardColumn } from './EntityBoardColumn';
export type EntityBoardProps = {
boardOptions: BoardOptions;
onColumnAdd?: (boardColumn: BoardColumnDefinition) => void;
onColumnDelete?: (boardColumnId: string) => void;
onEditColumnTitle: (columnId: string, title: string, color: string) => void;
};
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
const StyledBoardHeader = styled(BoardHeader)`
position: relative;
z-index: 1;
` as typeof BoardHeader;
export const EntityBoard = ({
boardOptions,
onColumnAdd,
onColumnDelete,
onEditColumnTitle,
}: EntityBoardProps) => {
const boardColumns = useRecoilValue(boardColumnsState);
const setCardSelected = useSetCardSelected();
const [updatePipelineProgressStage] =
useUpdateOnePipelineProgressStageMutation();
const { unselectAllActiveCards } = useCurrentCardSelected();
const updatePipelineProgressStageInDB = useCallback(
async (
pipelineProgressId: NonNullable<PipelineProgress['id']>,
pipelineStageId: NonNullable<PipelineStage['id']>,
) => {
updatePipelineProgressStage({
variables: {
id: pipelineProgressId,
pipelineStageId,
},
optimisticResponse: {
__typename: 'Mutation',
updateOnePipelineProgress: {
__typename: 'PipelineProgress',
id: pipelineProgressId,
},
},
update: (cache) => {
cache.modify({
id: cache.identify({
id: pipelineProgressId,
__typename: 'PipelineProgress',
}),
fields: {
pipelineStageId: () => pipelineStageId,
},
});
},
refetchQueries: [getOperationName(GET_PIPELINE_PROGRESS) ?? ''],
});
},
[updatePipelineProgressStage],
);
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.index - b.index;
});
const boardRef = useRef<HTMLDivElement>(null);
useScopedHotkeys(
'escape',
unselectAllActiveCards,
PageHotkeyScope.OpportunitiesPage,
);
return (boardColumns?.length ?? 0) > 0 ? (
<StyledWrapper>
<StyledBoardHeader onStageAdd={onColumnAdd} />
<ScrollWrapper>
<StyledBoard ref={boardRef}>
<DragDropContext onDragEnd={onDragEnd}>
{sortedBoardColumns.map((column) => (
<BoardColumnContext.Provider
key={column.id}
value={{
id: column.id,
columnDefinition: column,
isFirstColumn: column.index === 0,
isLastColumn: column.index === sortedBoardColumns.length - 1,
}}
>
<RecoilScope
CustomRecoilScopeContext={BoardColumnRecoilScopeContext}
key={column.id}
>
<EntityBoardColumn
boardOptions={boardOptions}
onDelete={onColumnDelete}
onTitleEdit={onEditColumnTitle}
/>
</RecoilScope>
</BoardColumnContext.Provider>
))}
</DragDropContext>
</StyledBoard>
</ScrollWrapper>
<DragSelect
dragSelectable={boardRef}
onDragSelectionChange={setCardSelected}
/>
</StyledWrapper>
) : (
<></>
);
};

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 EntityBoardActionBar = () => {
const selectedCardIds = useRecoilValue(selectedCardIdsSelector);
return <ActionBar selectedIds={selectedCardIds}></ActionBar>;
};

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 EntityBoardCard = ({
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,123 @@
import { useContext } from 'react';
import styled from '@emotion/styled';
import { Draggable, Droppable, DroppableProvided } from '@hello-pangea/dnd';
import { useRecoilValue } from 'recoil';
import { BoardColumn } from '@/ui/layout/board/components/BoardColumn';
import { BoardCardIdContext } from '@/ui/layout/board/contexts/BoardCardIdContext';
import { BoardColumnContext } from '@/ui/layout/board/contexts/BoardColumnContext';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { boardCardIdsByColumnIdFamilyState } from '../states/boardCardIdsByColumnIdFamilyState';
import { boardColumnTotalsFamilySelector } from '../states/selectors/boardColumnTotalsFamilySelector';
import { BoardOptions } from '../types/BoardOptions';
import { EntityBoardCard } from './EntityBoardCard';
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;
`;
type BoardColumnCardsContainerProps = {
children: React.ReactNode;
droppableProvided: DroppableProvided;
};
type EntityBoardColumnProps = {
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 EntityBoardColumn = ({
boardOptions,
onDelete,
onTitleEdit,
}: EntityBoardColumnProps) => {
const column = useContext(BoardColumnContext);
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 <></>;
return (
<Droppable droppableId={column.id}>
{(droppableProvided) => (
<BoardColumn
onTitleEdit={handleTitleEdit}
onDelete={onDelete}
totalAmount={boardColumnTotal}
numChildren={cardIds.length}
stageId={column.id}
>
<BoardColumnCardsContainer droppableProvided={droppableProvided}>
{cardIds.map((cardId, index) => (
<BoardCardIdContext.Provider value={cardId} key={cardId}>
<EntityBoardCard
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>
</BoardColumn>
)}
</Droppable>
);
};

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 EntityBoardContextMenu = () => {
const selectedCardIds = useRecoilValue(selectedCardIdsSelector);
return <ContextMenu selectedIds={selectedCardIds}></ContextMenu>;
};

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,10 @@
import styled from '@emotion/styled';
export 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)};
`;

View File

@ -0,0 +1,26 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import {
BoardColumnEditTitleMenu,
COLUMN_COLOR_OPTIONS,
} from '../BoardColumnEditTitleMenu';
const meta: Meta<typeof BoardColumnEditTitleMenu> = {
title: 'UI/Board/BoardColumnMenu',
component: BoardColumnEditTitleMenu,
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 BoardColumnEditTitleMenu>;
export const AllTags: Story = {};

View File

@ -0,0 +1,53 @@
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { BoardContext } from '@/companies/states/contexts/BoardContext';
import { CompanyBoardRecoilScopeContext } from '@/companies/states/recoil-scope-contexts/CompanyBoardRecoilScopeContext';
import { ViewBarContext } from '@/ui/data/view-bar/contexts/ViewBarContext';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
import { BoardOptionsDropdown } from '../BoardOptionsDropdown';
const meta: Meta<typeof BoardOptionsDropdown> = {
title: 'UI/Board/Options/BoardOptionsDropdown',
component: BoardOptionsDropdown,
decorators: [
(Story, { parameters }) => (
<BoardContext.Provider
value={{
BoardRecoilScopeContext: parameters.customRecoilScopeContext,
}}
>
<ViewBarContext.Provider
value={{
ViewBarRecoilScopeContext: parameters.customRecoilScopeContext,
}}
>
<Story />
</ViewBarContext.Provider>
</BoardContext.Provider>
),
ComponentWithRecoilScopeDecorator,
ComponentDecorator,
],
parameters: {
customRecoilScopeContext: CompanyBoardRecoilScopeContext,
},
args: {
customHotkeyScope: { scope: 'scope' },
},
};
export default meta;
type Story = StoryObj<typeof BoardOptionsDropdown>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const dropdownButton = canvas.getByText('Options');
await userEvent.click(dropdownButton);
},
};

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/layout/board/types/BoardOptions';
export const BoardOptionsContext = createContext<BoardOptions | null>(null);

View File

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

View File

@ -0,0 +1,27 @@
import { ViewFieldForVisibility } from '@/ui/data/view-bar/types/ViewFieldForVisibility';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { boardCardFieldsScopedState } from '../states/boardCardFieldsScopedState';
import { useBoardContext } from './useBoardContext';
export const useBoardCardFields = () => {
const { BoardRecoilScopeContext } = useBoardContext();
const [, setBoardCardFields] = useRecoilScopedState(
boardCardFieldsScopedState,
BoardRecoilScopeContext,
);
const handleFieldVisibilityChange = (field: ViewFieldForVisibility) => {
setBoardCardFields((previousFields) =>
previousFields.map((previousField) =>
previousField.key === field.key
? { ...previousField, isVisible: !field.isVisible }
: previousField,
),
);
};
return { handleFieldVisibilityChange };
};

View File

@ -0,0 +1,53 @@
import { useRecoilState } from 'recoil';
import { useMoveViewColumns } from '@/views/hooks/useMoveViewColumns';
import { useUpdatePipelineStageMutation } from '~/generated/graphql';
import { boardColumnsState } from '../states/boardColumnsState';
import { BoardColumnDefinition } from '../types/BoardColumnDefinition';
export const useBoardColumns = () => {
const [boardColumns, setBoardColumns] = useRecoilState(boardColumnsState);
const { handleColumnMove } = useMoveViewColumns();
const [updatePipelineStageMutation] = useUpdatePipelineStageMutation();
const updatedPipelineStages = (stages: BoardColumnDefinition[]) => {
if (!stages.length) return;
return Promise.all(
stages.map((stage) =>
updatePipelineStageMutation({
variables: {
data: {
index: stage.index,
},
id: stage.id,
},
}),
),
);
};
const persistBoardColumns = async () => {
await updatedPipelineStages(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,24 @@
import { useSetRecoilState } from 'recoil';
import { IconTrash } from '@/ui/display/icon';
import { contextMenuEntriesState } from '@/ui/navigation/context-menu/states/contextMenuEntriesState';
import { useDeleteSelectedBoardCards } from './useDeleteSelectedBoardCards';
export const useBoardContextMenuEntries = () => {
const setContextMenuEntries = useSetRecoilState(contextMenuEntriesState);
const deleteSelectedBoardCards = useDeleteSelectedBoardCards();
return {
setContextMenuEntries: () =>
setContextMenuEntries([
{
label: 'Delete',
Icon: IconTrash,
accent: 'danger',
onClick: () => deleteSelectedBoardCards(),
},
]),
};
};

View File

@ -0,0 +1,61 @@
import { useContext } from 'react';
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
import { actionBarOpenState } from '@/ui/navigation/action-bar/states/actionBarIsOpenState';
import { BoardCardIdContext } from '../contexts/BoardCardIdContext';
import { activeCardIdsState } from '../states/activeCardIdsState';
import { isCardSelectedFamilyState } from '../states/isCardSelectedFamilyState';
export const useCurrentCardSelected = () => {
const currentCardId = useContext(BoardCardIdContext);
const isCardSelected = 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],
);
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 {
currentCardSelected: isCardSelected,
setCurrentCardSelected,
unselectAllActiveCards,
};
};

View File

@ -0,0 +1,40 @@
import { getOperationName } from '@apollo/client/utilities';
import { useRecoilValue } from 'recoil';
import { GET_PIPELINES } from '@/pipeline/graphql/queries/getPipelines';
import { useDeleteManyPipelineProgressMutation } from '~/generated/graphql';
import { selectedCardIdsSelector } from '../states/selectors/selectedCardIdsSelector';
import { useRemoveCardIds } from './useRemoveCardIds';
export const useDeleteSelectedBoardCards = () => {
const selectedCardIds = useRecoilValue(selectedCardIdsSelector);
const removeCardIds = useRemoveCardIds();
const [deletePipelineProgress] = useDeleteManyPipelineProgressMutation({
refetchQueries: [getOperationName(GET_PIPELINES) ?? ''],
});
const deleteSelectedBoardCards = async () => {
await deletePipelineProgress({
variables: {
ids: selectedCardIds,
},
optimisticResponse: {
__typename: 'Mutation',
deleteManyPipelineProgress: {
count: selectedCardIds.length,
},
},
update: (cache) => {
removeCardIds(selectedCardIds);
selectedCardIds.forEach((id) => {
cache.evict({ id: `PipelineProgress:${id}` });
});
},
});
};
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,29 @@
import { useRecoilCallback, useSetRecoilState } 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 setActionBarOpenState = useSetRecoilState(actionBarOpenState);
return useRecoilCallback(
({ set, snapshot }) =>
(cardId: string, selected: boolean) => {
const activeCardIds = snapshot.getLoadable(activeCardIdsState).contents;
set(isCardSelectedFamilyState(cardId), selected);
setActionBarOpenState(selected || activeCardIds.length > 0);
if (selected) {
set(activeCardIdsState, [...activeCardIds, cardId]);
} else {
set(
activeCardIdsState,
activeCardIds.filter((id: string) => id !== cardId),
);
}
},
);
};

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,6 @@
import { atom } from 'recoil';
export const activeCardIdsState = atom<string[]>({
key: 'activeCardIdsState',
default: [],
});

View File

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

View File

@ -0,0 +1,13 @@
import { atomFamily } from 'recoil';
import { FieldMetadata } from '@/ui/data/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/layout/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 isCardSelectedFamilyState = atomFamily<boolean, string>({
key: 'isCardSelectedFamilyState',
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/data/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/data/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.key]: field }), {}),
});

View File

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

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(({ key }) => key);
const otherAvailableKeys = get(
availableBoardCardFieldsScopedState(scopeId),
).filter(({ key }) => !fieldKeys.includes(key));
return [
...fields.filter((field) => !field.isVisible),
...otherAvailableKeys,
];
},
});

View File

@ -0,0 +1,16 @@
import { selectorFamily } from 'recoil';
import { FieldMetadata } from '@/ui/data/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.key]: 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,
),
});

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
import { ComponentType } from 'react';
import { FilterDefinitionByEntity } from '@/ui/data/view-bar/types/FilterDefinitionByEntity';
import { SortDefinition } from '@/ui/data/view-bar/types/SortDefinition';
import { PipelineProgress } from '~/generated/graphql';
export type BoardOptions = {
newCardComponent: React.ReactNode;
CardComponent: ComponentType;
filters: FilterDefinitionByEntity<PipelineProgress>[];
sorts: 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',
}

View File

@ -0,0 +1,3 @@
export enum BoardScopeIds {
OptionsDropdown = 'board-options',
}