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',
}

View File

@ -0,0 +1,43 @@
import { DragDropContext, Droppable } from '@hello-pangea/dnd';
import { Meta, StoryObj } from '@storybook/react';
import { IconBell } from '@/ui/display/icon';
import { MenuItemDraggable } from '@/ui/navigation/menu-item/components/MenuItemDraggable';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DraggableItem } from '../components/DraggableItem';
const meta: Meta<typeof DraggableItem> = {
title: 'UI/DraggableList/DraggableItem',
component: DraggableItem,
decorators: [
(Story) => (
<DragDropContext onDragEnd={() => jest.fn()}>
<Droppable droppableId="droppable-id">
{(_provided) => <Story />}
</Droppable>
</DragDropContext>
),
ComponentDecorator,
],
parameters: {
container: { width: 100 },
},
argTypes: {
itemComponent: { control: { disable: true } },
},
args: {
draggableId: 'draggable-1',
index: 0,
isDragDisabled: false,
itemComponent: (
<MenuItemDraggable LeftIcon={IconBell} text="Draggable item 1" />
),
},
};
export default meta;
type Story = StoryObj<typeof DraggableItem>;
export const Default: Story = {};

View File

@ -0,0 +1,57 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconBell } from '@/ui/display/icon';
import { MenuItemDraggable } from '@/ui/navigation/menu-item/components/MenuItemDraggable';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DraggableItem } from '../components/DraggableItem';
import { DraggableList } from '../components/DraggableList';
const meta: Meta<typeof DraggableList> = {
title: 'UI/DraggableList/DraggableList',
component: DraggableList,
decorators: [ComponentDecorator],
parameters: {
onDragEnd: () => console.log('dragged'),
},
argTypes: {
draggableItems: { control: false },
},
args: {
draggableItems: (
<>
<DraggableItem
draggableId="draggable-1"
index={0}
isDragDisabled={false}
itemComponent={
<MenuItemDraggable
LeftIcon={IconBell}
text="Non Draggable item 1"
/>
}
/>
<DraggableItem
draggableId="draggable-2"
index={1}
itemComponent={
<MenuItemDraggable LeftIcon={IconBell} text="Draggable item 2" />
}
/>
<DraggableItem
draggableId="draggable-3"
index={2}
itemComponent={
<MenuItemDraggable LeftIcon={IconBell} text="Draggable item 3" />
}
/>
</>
),
},
};
export default meta;
type Story = StoryObj<typeof DraggableItem>;
export const Default: Story = {};

View File

@ -0,0 +1,55 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import { Draggable } from '@hello-pangea/dnd';
type DraggableItemProps = {
draggableId: string;
isDragDisabled?: boolean;
index: number;
itemComponent: JSX.Element;
};
export const DraggableItem = ({
draggableId,
isDragDisabled = false,
index,
itemComponent,
}: DraggableItemProps) => {
const theme = useTheme();
return (
<Draggable
key={draggableId}
draggableId={draggableId}
index={index}
isDragDisabled={isDragDisabled}
>
{(draggableProvided, draggableSnapshot) => {
const draggableStyle = draggableProvided.draggableProps.style;
const isDragged = draggableSnapshot.isDragging;
return (
<div
ref={draggableProvided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.dragHandleProps}
style={{
...draggableStyle,
left: 'auto',
top: 'auto',
transform: draggableStyle?.transform?.replace(
/\(-?\d+px,/,
'(0,',
),
background: isDragged
? theme.background.transparent.light
: 'none',
}}
>
{itemComponent}
</div>
);
}}
</Draggable>
);
};

View File

@ -0,0 +1,42 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import {
DragDropContext,
Droppable,
OnDragEndResponder,
} from '@hello-pangea/dnd';
import { v4 } from 'uuid';
type DraggableListProps = {
draggableItems: React.ReactNode;
onDragEnd: OnDragEndResponder;
};
const StyledDragDropItemsWrapper = styled.div`
width: 100%;
`;
export const DraggableList = ({
draggableItems,
onDragEnd,
}: DraggableListProps) => {
const [v4Persistable] = useState(v4());
return (
<DragDropContext onDragEnd={onDragEnd}>
<StyledDragDropItemsWrapper>
<Droppable droppableId={v4Persistable}>
{(provided) => (
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{draggableItems}
{provided.placeholder}
</div>
)}
</Droppable>
</StyledDragDropItemsWrapper>
</DragDropContext>
);
};

View File

@ -0,0 +1,103 @@
import { useRef } from 'react';
import { Keys } from 'react-hotkeys-hook';
import { flip, offset, Placement, useFloating } from '@floating-ui/react';
import { Key } from 'ts-key-enum';
import { HotkeyEffect } from '@/ui/utilities/hotkey/components/HotkeyEffect';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useDropdown } from '../hooks/useDropdown';
import { useInternalHotkeyScopeManagement } from '../hooks/useInternalHotkeyScopeManagement';
import { DropdownToggleEffect } from './DropdownToggleEffect';
type DropdownMenuProps = {
clickableComponent?: JSX.Element | JSX.Element[];
dropdownComponents: JSX.Element | JSX.Element[];
hotkey?: {
key: Keys;
scope: string;
};
dropdownHotkeyScope: HotkeyScope;
dropdownPlacement?: Placement;
dropdownOffset?: { x: number; y: number };
onClickOutside?: () => void;
onClose?: () => void;
onOpen?: () => void;
};
export const DropdownMenu = ({
clickableComponent,
dropdownComponents,
hotkey,
dropdownHotkeyScope,
dropdownPlacement = 'bottom-end',
dropdownOffset = { x: 0, y: 0 },
onClickOutside,
onClose,
onOpen,
}: DropdownMenuProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { isDropdownOpen, toggleDropdown, closeDropdown } = useDropdown();
const { refs, floatingStyles } = useFloating({
placement: dropdownPlacement,
middleware: [
flip(),
offset({ mainAxis: dropdownOffset.y, crossAxis: dropdownOffset.x }),
],
});
const handleHotkeyTriggered = () => {
toggleDropdown();
};
useListenClickOutside({
refs: [containerRef],
callback: () => {
onClickOutside?.();
if (isDropdownOpen) {
closeDropdown();
}
},
});
useInternalHotkeyScopeManagement({
dropdownHotkeyScopeFromParent: dropdownHotkeyScope,
});
useScopedHotkeys(
Key.Escape,
() => {
closeDropdown();
},
dropdownHotkeyScope.scope,
[closeDropdown],
);
return (
<div ref={containerRef}>
{clickableComponent && (
<div ref={refs.setReference} onClick={toggleDropdown}>
{clickableComponent}
</div>
)}
{hotkey && (
<HotkeyEffect
hotkey={hotkey}
onHotkeyTriggered={handleHotkeyTriggered}
/>
)}
{isDropdownOpen && (
<div data-select-disable ref={refs.setFloating} style={floatingStyles}>
{dropdownComponents}
</div>
)}
<DropdownToggleEffect onDropdownClose={onClose} onDropdownOpen={onOpen} />
</div>
);
};

View File

@ -0,0 +1,47 @@
import { HTMLAttributes, useRef } from 'react';
import styled from '@emotion/styled';
import { StyledDropdownMenu } from '@/ui/layout/dropdown/components/StyledDropdownMenu';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
const StyledDropdownMenuContainer = styled.ul<{
anchor: 'left' | 'right';
}>`
padding: 0;
position: absolute;
${({ anchor }) => {
if (anchor === 'right') return 'right: 0';
}};
top: 14px;
`;
export type DropdownMenuContainerProps = {
anchor?: 'left' | 'right';
children: React.ReactNode;
onClose?: () => void;
width?: `${string}px` | 'auto' | number;
} & HTMLAttributes<HTMLUListElement>;
export const DropdownMenuContainer = ({
anchor = 'right',
children,
onClose,
width,
}: DropdownMenuContainerProps) => {
const dropdownRef = useRef(null);
useListenClickOutside({
refs: [dropdownRef],
callback: () => {
onClose?.();
},
});
return (
<StyledDropdownMenuContainer data-select-disable anchor={anchor}>
<StyledDropdownMenu ref={dropdownRef} width={width}>
{children}
</StyledDropdownMenu>
</StyledDropdownMenuContainer>
);
};

View File

@ -0,0 +1,66 @@
import { ComponentProps, MouseEvent } from 'react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
const StyledHeader = styled.li`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding: ${({ theme }) => theme.spacing(1)};
user-select: none;
`;
const StyledChildrenWrapper = styled.span`
padding: 0 ${({ theme }) => theme.spacing(1)};
`;
const StyledLightIconButton = styled(LightIconButton)`
display: inline-flex;
margin-left: auto;
margin-right: 0;
`;
type DropdownMenuHeaderProps = ComponentProps<'li'> & {
StartIcon?: IconComponent;
EndIcon?: IconComponent;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
testId?: string;
};
export const DropdownMenuHeader = ({
children,
StartIcon,
EndIcon,
onClick,
testId,
}: DropdownMenuHeaderProps) => {
return (
<StyledHeader data-testid={testId}>
{StartIcon && (
<LightIconButton
testId="dropdown-menu-header-end-icon"
Icon={StartIcon}
onClick={onClick}
accent="tertiary"
size="small"
/>
)}
<StyledChildrenWrapper>{children}</StyledChildrenWrapper>
{EndIcon && (
<StyledLightIconButton
testId="dropdown-menu-header-end-icon"
Icon={EndIcon}
onClick={onClick}
accent="tertiary"
size="small"
/>
)}
</StyledHeader>
);
};

View File

@ -0,0 +1,23 @@
import styled from '@emotion/styled';
import { rgba } from '@/ui/theme/constants/colors';
import { textInputStyle } from '@/ui/theme/constants/effects';
const StyledViewNameInput = styled.input`
${textInputStyle}
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
font-weight: ${({ theme }) => theme.font.weight.medium};
height: 32px;
position: relative;
width: 100%;
&:focus {
border-color: ${({ theme }) => theme.color.blue};
box-shadow: 0px 0px 0px 3px ${({ theme }) => rgba(theme.color.blue, 0.1)};
}
`;
export { StyledViewNameInput as DropdownMenuInput };

View File

@ -0,0 +1,9 @@
import styled from '@emotion/styled';
const StyledInputContainer = styled.div`
box-sizing: border-box;
padding: ${({ theme }) => theme.spacing(1)};
width: 100%;
`;
export { StyledInputContainer as DropdownMenuInputContainer };

View File

@ -0,0 +1,55 @@
import styled from '@emotion/styled';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
const StyledDropdownMenuItemsExternalContainer = styled.div<{
hasMaxHeight?: boolean;
}>`
--padding: ${({ theme }) => theme.spacing(1)};
align-items: flex-start;
display: flex;
flex-direction: column;
gap: 2px;
height: 100%;
max-height: ${({ hasMaxHeight }) => (hasMaxHeight ? '180px' : 'none')};
overflow-y: auto;
padding: var(--padding);
padding-right: 0;
width: calc(100% - 1 * var(--padding));
`;
const StyledScrollWrapper = styled(ScrollWrapper)`
width: 100%;
`;
const StyledDropdownMenuItemsInternalContainer = styled.div`
align-items: flex-start;
display: flex;
flex-direction: column;
gap: 2px;
height: 100%;
width: 100%;
`;
export const DropdownMenuItemsContainer = ({
children,
hasMaxHeight,
}: {
children: React.ReactNode;
hasMaxHeight?: boolean;
}) => {
return (
<StyledDropdownMenuItemsExternalContainer hasMaxHeight={hasMaxHeight}>
<StyledScrollWrapper>
<StyledDropdownMenuItemsInternalContainer>
{children}
</StyledDropdownMenuItemsInternalContainer>
</StyledScrollWrapper>
</StyledDropdownMenuItemsExternalContainer>
);
};

View File

@ -0,0 +1,47 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/theme/constants/effects';
const StyledDropdownMenuSearchInputContainer = 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: 100%;
`;
const StyledInput = styled.input`
${textInputStyle}
font-size: ${({ theme }) => theme.font.size.sm};
width: 100%;
&[type='number']::-webkit-outer-spin-button,
&[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&[type='number'] {
-moz-appearance: textfield;
}
`;
export const DropdownMenuSearchInput = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>(({ value, onChange, autoFocus, placeholder = 'Search', type }, ref) => (
<StyledDropdownMenuSearchInputContainer>
<StyledInput
autoComplete="off"
{...{ autoFocus, onChange, placeholder, type, value }}
ref={ref}
/>
</StyledDropdownMenuSearchInputContainer>
));

View File

@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
export const DropdownToggleEffect = ({
onDropdownClose,
onDropdownOpen,
}: {
onDropdownClose?: () => void;
onDropdownOpen?: () => void;
}) => {
const { isDropdownOpen } = useDropdown();
useEffect(() => {
if (isDropdownOpen) {
onDropdownOpen?.();
} else {
onDropdownClose?.();
}
}, [isDropdownOpen, onDropdownClose, onDropdownOpen]);
return null;
};

View File

@ -0,0 +1,27 @@
import styled from '@emotion/styled';
type StyledDropdownButtonProps = {
isUnfolded: boolean;
isActive?: boolean;
};
export const StyledDropdownButtonContainer = styled.div<StyledDropdownButtonProps>`
align-items: center;
background: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ isActive, theme, color }) =>
color ?? (isActive ? theme.color.blue : 'none')};
cursor: pointer;
display: flex;
filter: ${(props) => (props.isUnfolded ? 'brightness(0.95)' : 'none')};
padding: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
user-select: none;
&:hover {
filter: brightness(0.95);
}
`;

View File

@ -0,0 +1,23 @@
import styled from '@emotion/styled';
export const StyledDropdownMenu = styled.div<{
disableBlur?: boolean;
width?: `${string}px` | 'auto' | number;
}>`
backdrop-filter: ${({ disableBlur }) =>
disableBlur ? 'none' : 'blur(20px)'};
background: ${({ theme }) => theme.background.transparent.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
flex-direction: column;
overflow: hidden;
width: ${({ width }) =>
width ? `${typeof width === 'number' ? `${width}px` : width}` : '160px'};
`;

View File

@ -0,0 +1,8 @@
import styled from '@emotion/styled';
export const StyledDropdownMenuSeparator = styled.div`
background-color: ${({ theme }) => theme.border.color.light};
height: 1px;
width: 100%;
`;

View File

@ -0,0 +1,11 @@
import styled from '@emotion/styled';
export const StyledDropdownMenuSubheader = styled.div`
background-color: ${({ theme }) => theme.background.transparent.lighter};
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xxs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
text-transform: uppercase;
width: 100%;
`;

View File

@ -0,0 +1,27 @@
import styled from '@emotion/styled';
type StyledDropdownButtonProps = {
isUnfolded?: boolean;
isActive?: boolean;
};
export const StyledHeaderDropdownButton = styled.div<StyledDropdownButtonProps>`
align-items: center;
background: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ isActive, theme, color }) =>
color ?? (isActive ? theme.color.blue : theme.font.color.secondary)};
cursor: pointer;
display: flex;
filter: ${(props) => (props.isUnfolded ? 'brightness(0.95)' : 'none')};
padding: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
user-select: none;
&:hover {
filter: brightness(0.95);
}
`;

View File

@ -0,0 +1,253 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemMultiSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar';
import { MenuItemSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemSelectAvatar';
import { Avatar } from '@/users/components/Avatar';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DropdownMenuHeader } from '../DropdownMenuHeader';
import { DropdownMenuInput } from '../DropdownMenuInput';
import { DropdownMenuInputContainer } from '../DropdownMenuInputContainer';
import { DropdownMenuItemsContainer } from '../DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '../DropdownMenuSearchInput';
import { StyledDropdownMenu } from '../StyledDropdownMenu';
import { StyledDropdownMenuSeparator } from '../StyledDropdownMenuSeparator';
import { StyledDropdownMenuSubheader } from '../StyledDropdownMenuSubheader';
const meta: Meta<typeof StyledDropdownMenu> = {
title: 'UI/Dropdown/DropdownMenu',
component: StyledDropdownMenu,
decorators: [ComponentDecorator],
argTypes: {
as: { table: { disable: true } },
children: { control: false },
theme: { table: { disable: true } },
width: { type: 'number', defaultValue: undefined },
},
};
export default meta;
type Story = StoryObj<typeof StyledDropdownMenu>;
const FakeContentBelow = () => (
<div style={{ position: 'absolute' }}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</div>
);
const avatarUrl =
'https://s3-alpha-sig.figma.com/img/bbb5/4905/f0a52cc2b9aaeb0a82a360d478dae8bf?Expires=1687132800&Signature=iVBr0BADa3LHoFVGbwqO-wxC51n1o~ZyFD-w7nyTyFP4yB-Y6zFawL-igewaFf6PrlumCyMJThDLAAc-s-Cu35SBL8BjzLQ6HymzCXbrblUADMB208PnMAvc1EEUDq8TyryFjRO~GggLBk5yR0EXzZ3zenqnDEGEoQZR~TRqS~uDF-GwQB3eX~VdnuiU2iittWJkajIDmZtpN3yWtl4H630A3opQvBnVHZjXAL5YPkdh87-a-H~6FusWvvfJxfNC2ZzbrARzXofo8dUFtH7zUXGCC~eUk~hIuLbLuz024lFQOjiWq2VKyB7dQQuGFpM-OZQEV8tSfkViP8uzDLTaCg__&Key-Pair-Id=APKAQ4GOSFWCVNEHN3O4';
const StyledFakeMenuContent = styled.div`
height: 400px;
width: 100%;
`;
const StyledFakeBelowContainer = styled.div`
height: 600px;
position: relative;
width: 300px;
`;
const StyledMenuAbsolutePositionWrapper = styled.div`
height: fit-content;
position: absolute;
width: fit-content;
`;
const mockSelectArray = [
{
id: '1',
name: 'Company A',
avatarUrl,
},
{
id: '2',
name: 'Company B',
avatarUrl,
},
{
id: '3',
name: 'Company C',
avatarUrl,
},
{
id: '4',
name: 'Person 2',
avatarUrl,
},
{
id: '5',
name: 'Company D',
avatarUrl,
},
{
id: '6',
name: 'Person 1',
avatarUrl,
},
];
const FakeSelectableMenuItemList = ({ hasAvatar }: { hasAvatar?: boolean }) => {
const [selectedItem, setSelectedItem] = useState<string | null>(null);
return (
<>
{mockSelectArray.map((item) => (
<MenuItemSelectAvatar
key={item.id}
selected={selectedItem === item.id}
onClick={() => setSelectedItem(item.id)}
avatar={
hasAvatar ? (
<Avatar
placeholder="A"
avatarUrl={item.avatarUrl}
size="md"
type="squared"
/>
) : undefined
}
text={item.name}
/>
))}
</>
);
};
const FakeCheckableMenuItemList = ({ hasAvatar }: { hasAvatar?: boolean }) => {
const [selectedItemsById, setSelectedItemsById] = useState<
Record<string, boolean>
>({});
return (
<>
{mockSelectArray.map((item) => (
<MenuItemMultiSelectAvatar
key={item.id}
selected={selectedItemsById[item.id]}
onSelectChange={(checked) =>
setSelectedItemsById((previous) => ({
...previous,
[item.id]: checked,
}))
}
avatar={
hasAvatar ? (
<Avatar
placeholder="A"
avatarUrl={item.avatarUrl}
size="md"
type="squared"
/>
) : undefined
}
text={item.name}
/>
))}
</>
);
};
const WithContentBelowDecorator: Decorator = (Story) => (
<StyledFakeBelowContainer>
<FakeContentBelow />
<StyledMenuAbsolutePositionWrapper>
<Story />
</StyledMenuAbsolutePositionWrapper>
</StyledFakeBelowContainer>
);
export const Empty: Story = {
args: { children: <StyledFakeMenuContent /> },
};
export const WithHeaders: Story = {
decorators: [WithContentBelowDecorator],
args: {
children: (
<>
<DropdownMenuHeader>Header</DropdownMenuHeader>
<StyledDropdownMenuSeparator />
<StyledDropdownMenuSubheader>Subheader 1</StyledDropdownMenuSubheader>
<DropdownMenuItemsContainer>
{mockSelectArray.slice(0, 3).map(({ name }) => (
<MenuItem text={name} />
))}
</DropdownMenuItemsContainer>
<StyledDropdownMenuSeparator />
<StyledDropdownMenuSubheader>Subheader 2</StyledDropdownMenuSubheader>
<DropdownMenuItemsContainer>
{mockSelectArray.slice(3).map(({ name }) => (
<MenuItem text={name} />
))}
</DropdownMenuItemsContainer>
</>
),
},
};
export const SearchWithLoadingMenu: Story = {
decorators: [WithContentBelowDecorator],
args: {
children: (
<>
<DropdownMenuSearchInput value="query" autoFocus />
<StyledDropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
<DropdownMenuSkeletonItem />
</DropdownMenuItemsContainer>
</>
),
},
};
export const WithInput: Story = {
decorators: [WithContentBelowDecorator],
args: {
children: (
<>
<DropdownMenuInputContainer>
<DropdownMenuInput defaultValue="Lorem ipsum" autoFocus />
</DropdownMenuInputContainer>
<StyledDropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{mockSelectArray.map(({ name }) => (
<MenuItem text={name} />
))}
</DropdownMenuItemsContainer>
</>
),
},
};
export const SelectableMenuItemWithAvatar: Story = {
decorators: [WithContentBelowDecorator],
args: {
children: (
<DropdownMenuItemsContainer hasMaxHeight>
<FakeSelectableMenuItemList hasAvatar />
</DropdownMenuItemsContainer>
),
},
};
export const CheckableMenuItemWithAvatar: Story = {
decorators: [WithContentBelowDecorator],
args: {
children: (
<DropdownMenuItemsContainer hasMaxHeight>
<FakeCheckableMenuItemList hasAvatar />
</DropdownMenuItemsContainer>
),
},
};

View File

@ -0,0 +1,25 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DropdownMenuInput } from '../DropdownMenuInput';
const meta: Meta<typeof DropdownMenuInput> = {
title: 'UI/Dropdown/DropdownMenuInput',
component: DropdownMenuInput,
decorators: [ComponentDecorator],
args: { defaultValue: 'Lorem ipsum' },
argTypes: {
as: { table: { disable: true } },
theme: { table: { disable: true } },
},
};
export default meta;
type Story = StoryObj<typeof DropdownMenuInput>;
export const Default: Story = {};
export const Focused: Story = {
args: { autoFocus: true },
};

View File

@ -0,0 +1,65 @@
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { DropdownScopeInternalContext } from '../scopes/scope-internal-context/DropdownScopeInternalContext';
import { useDropdownStates } from './useDropdownStates';
type UseDropdownProps = {
dropdownScopeId?: string;
};
export const useDropdown = (props?: UseDropdownProps) => {
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const scopeId = useAvailableScopeIdOrThrow(
DropdownScopeInternalContext,
props?.dropdownScopeId,
);
const {
dropdownHotkeyScope,
setDropdownHotkeyScope,
isDropdownOpen,
setIsDropdownOpen,
} = useDropdownStates({
scopeId,
});
const closeDropdownButton = () => {
goBackToPreviousHotkeyScope();
setIsDropdownOpen(false);
};
const openDropdownButton = () => {
setIsDropdownOpen(true);
if (dropdownHotkeyScope) {
setHotkeyScopeAndMemorizePreviousScope(
dropdownHotkeyScope.scope,
dropdownHotkeyScope.customScopes,
);
}
};
const toggleDropdownButton = () => {
if (isDropdownOpen) {
closeDropdownButton();
} else {
openDropdownButton();
}
};
return {
isDropdownOpen: isDropdownOpen,
closeDropdown: closeDropdownButton,
toggleDropdown: toggleDropdownButton,
openDropdown: openDropdownButton,
scopeId,
dropdownHotkeyScope,
setDropdownHotkeyScope,
};
};

View File

@ -0,0 +1,23 @@
import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2';
import { dropdownHotkeyScopeScopedState } from '../states/dropdownHotkeyScopeScopedState';
import { isDropdownOpenScopedState } from '../states/isDropdownOpenScopedState';
export const useDropdownStates = ({ scopeId }: { scopeId: string }) => {
const [isDropdownOpen, setIsDropdownOpen] = useRecoilScopedStateV2(
isDropdownOpenScopedState,
scopeId,
);
const [dropdownHotkeyScope, setDropdownHotkeyScope] = useRecoilScopedStateV2(
dropdownHotkeyScopeScopedState,
scopeId,
);
return {
isDropdownOpen,
setIsDropdownOpen,
dropdownHotkeyScope,
setDropdownHotkeyScope,
};
};

View File

@ -0,0 +1,24 @@
import { useEffect } from 'react';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { useDropdown } from './useDropdown';
export const useInternalHotkeyScopeManagement = ({
dropdownHotkeyScopeFromParent,
}: {
dropdownHotkeyScopeFromParent?: HotkeyScope;
}) => {
const { dropdownHotkeyScope, setDropdownHotkeyScope } = useDropdown();
useEffect(() => {
if (!isDeeplyEqual(dropdownHotkeyScopeFromParent, dropdownHotkeyScope)) {
setDropdownHotkeyScope(dropdownHotkeyScopeFromParent);
}
}, [
dropdownHotkeyScope,
dropdownHotkeyScopeFromParent,
setDropdownHotkeyScope,
]);
};

View File

@ -0,0 +1,19 @@
import { ReactNode } from 'react';
import { DropdownScopeInternalContext } from './scope-internal-context/DropdownScopeInternalContext';
type DropdownScopeProps = {
children: ReactNode;
dropdownScopeId: string;
};
export const DropdownScope = ({
children,
dropdownScopeId,
}: DropdownScopeProps) => {
return (
<DropdownScopeInternalContext.Provider value={{ scopeId: dropdownScopeId }}>
{children}
</DropdownScopeInternalContext.Provider>
);
};

View File

@ -0,0 +1,9 @@
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
type DropdownScopeInternalContextProps = ScopedStateKey & {
test?: string;
};
export const DropdownScopeInternalContext =
createScopeInternalContext<DropdownScopeInternalContextProps>();

View File

@ -0,0 +1,9 @@
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const dropdownHotkeyScopeScopedState = createScopedState<
HotkeyScope | null | undefined
>({
key: 'dropdownHotkeyScopeScopedState',
defaultValue: null,
});

View File

@ -0,0 +1,6 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const isDropdownOpenScopedState = createScopedState<boolean>({
key: 'isDropdownOpenScopedState',
defaultValue: false,
});

View File

@ -0,0 +1,132 @@
import { ReactNode, useState } from 'react';
import styled from '@emotion/styled';
import { AnimatePresence, LayoutGroup } from 'framer-motion';
import debounce from 'lodash.debounce';
import {
H1Title,
H1TitleFontColor,
} from '@/ui/display/typography/components/H1Title';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { Modal } from '@/ui/layout/modal/components/Modal';
import {
Section,
SectionAlignment,
SectionFontColor,
} from '@/ui/layout/section/components/Section';
export type ConfirmationModalProps = {
isOpen: boolean;
title: string;
subtitle: ReactNode;
setIsOpen: (val: boolean) => void;
onConfirmClick: () => void;
deleteButtonText?: string;
confirmationPlaceholder?: string;
confirmationValue?: string;
};
const StyledConfirmationModal = styled(Modal)`
border-radius: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(6)};
width: calc(400px - ${({ theme }) => theme.spacing(32)});
`;
const StyledCenteredButton = styled(Button)`
justify-content: center;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledCenteredTitle = styled.div`
text-align: center;
`;
export const StyledConfirmationButton = styled(StyledCenteredButton)`
border-color: ${({ theme }) => theme.color.red20};
box-shadow: none;
color: ${({ theme }) => theme.color.red};
font-size: ${({ theme }) => theme.font.size.md};
line-height: ${({ theme }) => theme.text.lineHeight.lg};
:hover {
background-color: ${({ theme }) => theme.color.red10};
}
`;
export const ConfirmationModal = ({
isOpen = false,
title,
subtitle,
setIsOpen,
onConfirmClick,
deleteButtonText = 'Delete',
confirmationValue,
confirmationPlaceholder,
}: ConfirmationModalProps) => {
const [inputConfirmationValue, setInputConfirmationValue] =
useState<string>('');
const [isValidValue, setIsValidValue] = useState(!confirmationValue);
const handleInputConfimrationValueChange = (value: string) => {
setInputConfirmationValue(value);
isValueMatchingInput(confirmationValue, value);
};
const isValueMatchingInput = debounce(
(value?: string, inputValue?: string) => {
setIsValidValue(Boolean(value && inputValue && value === inputValue));
},
250,
);
return (
<AnimatePresence mode="wait">
<LayoutGroup>
<StyledConfirmationModal
isOpen={isOpen}
onClose={() => {
if (isOpen) {
setIsOpen(false);
}
}}
onEnter={onConfirmClick}
>
<StyledCenteredTitle>
<H1Title title={title} fontColor={H1TitleFontColor.Primary} />
</StyledCenteredTitle>
<Section
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
{subtitle}
</Section>
{confirmationValue && (
<Section>
<TextInput
value={inputConfirmationValue}
onChange={handleInputConfimrationValueChange}
placeholder={confirmationPlaceholder}
fullWidth
key={'input-' + confirmationValue}
/>
</Section>
)}
<StyledCenteredButton
onClick={onConfirmClick}
variant="secondary"
accent="danger"
title={deleteButtonText}
disabled={!isValidValue}
fullWidth
/>
<StyledCenteredButton
onClick={() => setIsOpen(false)}
variant="secondary"
title="Cancel"
fullWidth
/>
</StyledConfirmationModal>
</LayoutGroup>
</AnimatePresence>
);
};

View File

@ -0,0 +1,216 @@
import React, { useEffect, useRef } from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { Key } from 'ts-key-enum';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
ClickOutsideMode,
useListenClickOutside,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { ModalHotkeyScope } from './types/ModalHotkeyScope';
const StyledModalDiv = styled(motion.div)<{
size?: ModalSize;
padding?: ModalPadding;
}>`
display: flex;
flex-direction: column;
background: ${({ theme }) => theme.background.primary};
color: ${({ theme }) => theme.font.color.primary};
border-radius: ${({ theme }) => theme.border.radius.md};
overflow: hidden;
max-height: 90vh;
z-index: 10000; // should be higher than Backdrop's z-index
width: ${({ size, theme }) => {
switch (size) {
case 'small':
return theme.modal.size.sm;
case 'medium':
return theme.modal.size.md;
case 'large':
return theme.modal.size.lg;
default:
return 'auto';
}
}};
padding: ${({ padding, theme }) => {
switch (padding) {
case 'none':
return theme.spacing(0);
case 'small':
return theme.spacing(2);
case 'medium':
return theme.spacing(4);
case 'large':
return theme.spacing(6);
default:
return 'auto';
}
}};
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(5)};
`;
const StyledContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
overflow-y: auto;
padding: ${({ theme }) => theme.spacing(10)};
`;
const StyledFooter = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(5)};
`;
const StyledBackDrop = styled(motion.div)`
align-items: center;
background: ${({ theme }) => theme.background.overlay};
display: flex;
height: 100%;
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100%;
z-index: 9999;
`;
/**
* Modal components
*/
type ModalHeaderProps = React.PropsWithChildren & React.ComponentProps<'div'>;
const ModalHeader = ({ children }: ModalHeaderProps) => (
<StyledHeader>{children}</StyledHeader>
);
type ModalContentProps = React.PropsWithChildren & React.ComponentProps<'div'>;
const ModalContent = ({ children, className }: ModalContentProps) => (
<StyledContent className={className}>{children}</StyledContent>
);
type ModalFooterProps = React.PropsWithChildren & React.ComponentProps<'div'>;
const ModalFooter = ({ children }: ModalFooterProps) => (
<StyledFooter>{children}</StyledFooter>
);
/**
* Modal
*/
export type ModalSize = 'small' | 'medium' | 'large';
export type ModalPadding = 'none' | 'small' | 'medium' | 'large';
type ModalProps = React.PropsWithChildren &
React.ComponentProps<'div'> & {
isOpen?: boolean;
onClose?: () => void;
hotkeyScope?: ModalHotkeyScope;
onEnter?: () => void;
size?: ModalSize;
padding?: ModalPadding;
};
const modalVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
};
export const Modal = ({
isOpen = false,
children,
onClose,
hotkeyScope = ModalHotkeyScope.Default,
onEnter,
size = 'medium',
padding = 'medium',
}: ModalProps) => {
const modalRef = useRef<HTMLDivElement>(null);
useListenClickOutside({
refs: [modalRef],
callback: () => onClose?.(),
mode: ClickOutsideMode.absolute,
});
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
useScopedHotkeys(
[Key.Escape],
() => {
onClose?.();
},
hotkeyScope,
[onClose],
);
useScopedHotkeys(
[Key.Enter],
() => {
onEnter?.();
},
hotkeyScope,
);
useEffect(() => {
if (isOpen) {
setHotkeyScopeAndMemorizePreviousScope(hotkeyScope);
} else {
goBackToPreviousHotkeyScope();
}
}, [
goBackToPreviousHotkeyScope,
hotkeyScope,
isOpen,
setHotkeyScopeAndMemorizePreviousScope,
]);
return isOpen ? (
<StyledBackDrop>
<StyledModalDiv
// framer-motion seems to have typing problems with refs
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ref={modalRef}
size={size}
padding={padding}
initial="hidden"
animate="visible"
exit="exit"
layout
variants={modalVariants}
>
{children}
</StyledModalDiv>
</StyledBackDrop>
) : (
<></>
);
};
Modal.Header = ModalHeader;
Modal.Content = ModalContent;
Modal.Footer = ModalFooter;

View File

@ -0,0 +1,33 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { ConfirmationModal } from '../ConfirmationModal';
const meta: Meta<typeof ConfirmationModal> = {
title: 'UI/Modal/ConfirmationModal',
component: ConfirmationModal,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof ConfirmationModal>;
export const Default: Story = {
args: {
isOpen: true,
title: 'Pariatur labore.',
subtitle: 'Velit dolore aliquip laborum occaecat fugiat.',
deleteButtonText: 'Delete',
},
decorators: [ComponentDecorator],
};
export const InputConfirmation: Story = {
args: {
confirmationValue: 'email@test.dev',
confirmationPlaceholder: 'email@test.dev',
...Default.args,
},
decorators: Default.decorators,
};

View File

@ -0,0 +1,40 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { Modal } from '../Modal';
import { ModalHotkeyScope } from '../types/ModalHotkeyScope';
const meta: Meta<typeof Modal> = {
title: 'UI/Modal/Modal',
component: Modal,
};
export default meta;
type Story = StoryObj<typeof Modal>;
export const Default: Story = {
args: {
isOpen: true,
size: 'medium',
padding: 'medium',
hotkeyScope: ModalHotkeyScope.Default,
children: (
<>
<Modal.Header>Stay in touch</Modal.Header>
<Modal.Content>
This is a dummy newletter form so don't bother trying to test it. Not
that I expect you to, anyways. :)
</Modal.Content>
<Modal.Footer>
By using Twenty, you're opting for the finest CRM experience you'll
ever encounter.
</Modal.Footer>
</>
),
},
decorators: [ComponentDecorator],
argTypes: {
children: { control: false },
},
};

View File

@ -0,0 +1,3 @@
export enum ModalHotkeyScope {
Default = 'default',
}

View File

@ -6,7 +6,7 @@ import { AuthModal } from '@/auth/components/Modal';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { CommandMenu } from '@/command-menu/components/CommandMenu';
import { NavbarAnimatedContainer } from '@/ui/navbar/components/NavbarAnimatedContainer';
import { NavbarAnimatedContainer } from '@/ui/navigation/navbar/components/NavbarAnimatedContainer';
import { MOBILE_VIEWPORT } from '@/ui/theme/constants/theme';
import { AppNavbar } from '~/AppNavbar';
import { CompaniesMockMode } from '~/pages/companies/CompaniesMockMode';

View File

@ -1,5 +1,5 @@
import { IconButton } from '@/ui/button/components/IconButton';
import { IconPlus } from '@/ui/icon';
import { IconPlus } from '@/ui/display/icon';
import { IconButton } from '@/ui/input/button/components/IconButton';
type PageAddButtonProps = {
onClick: () => void;

View File

@ -1,5 +1,5 @@
import { IconButton } from '@/ui/button/components/IconButton';
import { IconHeart } from '@/ui/icon';
import { IconHeart } from '@/ui/display/icon';
import { IconButton } from '@/ui/input/button/components/IconButton';
type PageFavoriteButtonProps = {
isFavorite: boolean;

View File

@ -4,11 +4,14 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconButton, IconButtonSize } from '@/ui/button/components/IconButton';
import { IconChevronLeft } from '@/ui/icon/index';
import { IconComponent } from '@/ui/icon/types/IconComponent';
import NavCollapseButton from '@/ui/navbar/components/NavCollapseButton';
import { OverflowingTextWithTooltip } from '@/ui/tooltip/OverflowingTextWithTooltip';
import { IconChevronLeft } from '@/ui/display/icon/index';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { OverflowingTextWithTooltip } from '@/ui/display/tooltip/OverflowingTextWithTooltip';
import {
IconButton,
IconButtonSize,
} from '@/ui/input/button/components/IconButton';
import NavCollapseButton from '@/ui/navigation/navbar/components/NavCollapseButton';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { isNavbarOpenedState } from '../states/isNavbarOpenedState';

View File

@ -1,4 +1,4 @@
import { TableHotkeyScope } from '@/ui/data-table/types/TableHotkeyScope';
import { TableHotkeyScope } from '@/ui/data/data-table/types/TableHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
type PageHotkeysEffectProps = {

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { RightDrawer } from '@/ui/right-drawer/components/RightDrawer';
import { RightDrawer } from '@/ui/layout/right-drawer/components/RightDrawer';
import { PagePanel } from './PagePanel';

View File

@ -1,7 +1,7 @@
import { JSX } from 'react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/icon/types/IconComponent';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { PageHeader } from './PageHeader';

View File

@ -0,0 +1,103 @@
import { useRef } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
ClickOutsideMode,
useListenClickOutside,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { isDefined } from '~/utils/isDefined';
import { leftNavbarWidth } from '../../../navigation/navbar/constants';
import { useRightDrawer } from '../hooks/useRightDrawer';
import { isRightDrawerExpandedState } from '../states/isRightDrawerExpandedState';
import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerHotkeyScope } from '../types/RightDrawerHotkeyScope';
import { RightDrawerRouter } from './RightDrawerRouter';
const StyledContainer = styled(motion.div)`
background: ${({ theme }) => theme.background.primary};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
height: 100%;
overflow-x: hidden;
position: fixed;
right: 0;
top: 0;
z-index: 100;
`;
const StyledRightDrawer = styled.div`
display: flex;
flex-direction: row;
width: 100%;
`;
export const RightDrawer = () => {
const [isRightDrawerOpen, setIsRightDrawerOpen] = useRecoilState(
isRightDrawerOpenState,
);
const [isRightDrawerExpanded] = useRecoilState(isRightDrawerExpandedState);
const [rightDrawerPage] = useRecoilState(rightDrawerPageState);
const { closeRightDrawer } = useRightDrawer();
const rightDrawerRef = useRef(null);
useListenClickOutside({
refs: [rightDrawerRef],
callback: () => closeRightDrawer(),
mode: ClickOutsideMode.absolute,
});
const theme = useTheme();
useScopedHotkeys(
[Key.Escape],
() => closeRightDrawer(),
RightDrawerHotkeyScope.RightDrawer,
[setIsRightDrawerOpen],
);
const isMobile = useIsMobile();
const rightDrawerWidthExpanded = `calc(100% - ${
leftNavbarWidth.desktop
} - ${theme.spacing(2)})`;
const rightDrawerWidth = isRightDrawerOpen
? isMobile
? '100%'
: isRightDrawerExpanded
? rightDrawerWidthExpanded
: theme.rightDrawerWidth
: '0';
if (!isDefined(rightDrawerPage)) {
return <></>;
}
return (
<StyledContainer
animate={{
width: rightDrawerWidth,
}}
transition={{
duration: theme.animation.duration.normal,
}}
>
<StyledRightDrawer ref={rightDrawerRef}>
{isRightDrawerOpen && <RightDrawerRouter />}
</StyledRightDrawer>
</StyledContainer>
);
};

View File

@ -0,0 +1,51 @@
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { RightDrawerCreateActivity } from '@/activities/right-drawer/components/create/RightDrawerCreateActivity';
import { RightDrawerEditActivity } from '@/activities/right-drawer/components/edit/RightDrawerEditActivity';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPages } from '../types/RightDrawerPages';
import { RightDrawerTopBar } from './RightDrawerTopBar';
const StyledRightDrawerPage = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
`;
const StyledRightDrawerBody = styled.div`
display: flex;
flex-direction: column;
height: calc(
100vh - ${({ theme }) => theme.spacing(14)} - 1px
); // (-1 for border)
overflow: auto;
position: relative;
`;
export const RightDrawerRouter = () => {
const [rightDrawerPage] = useRecoilState(rightDrawerPageState);
let page = <></>;
switch (rightDrawerPage) {
case RightDrawerPages.CreateActivity:
page = <RightDrawerCreateActivity />;
break;
case RightDrawerPages.EditActivity:
page = <RightDrawerEditActivity />;
break;
default:
break;
}
return (
<StyledRightDrawerPage>
<RightDrawerTopBar />
<StyledRightDrawerBody>{page}</StyledRightDrawerBody>
</StyledRightDrawerPage>
);
};

View File

@ -0,0 +1,44 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { ActivityActionBar } from '@/activities/right-drawer/components/ActivityActionBar';
import { viewableActivityIdState } from '@/activities/states/viewableActivityIdState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { RightDrawerTopBarCloseButton } from './RightDrawerTopBarCloseButton';
import { RightDrawerTopBarExpandButton } from './RightDrawerTopBarExpandButton';
const StyledRightDrawerTopBar = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
flex-direction: row;
font-size: ${({ theme }) => theme.font.size.md};
gap: ${({ theme }) => theme.spacing(1)};
height: 56px;
justify-content: space-between;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledTopBarWrapper = styled.div`
display: flex;
`;
export const RightDrawerTopBar = () => {
const isMobile = useIsMobile();
const viewableActivityId = useRecoilValue(viewableActivityIdState);
return (
<StyledRightDrawerTopBar>
<StyledTopBarWrapper>
<RightDrawerTopBarCloseButton />
{!isMobile && <RightDrawerTopBarExpandButton />}
</StyledTopBarWrapper>
<ActivityActionBar activityId={viewableActivityId ?? ''} />
</StyledRightDrawerTopBar>
);
};

View File

@ -0,0 +1,21 @@
import { IconChevronsRight } from '@/ui/display/icon/index';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { useRightDrawer } from '../hooks/useRightDrawer';
export const RightDrawerTopBarCloseButton = () => {
const { closeRightDrawer } = useRightDrawer();
const handleButtonClick = () => {
closeRightDrawer();
};
return (
<LightIconButton
Icon={IconChevronsRight}
onClick={handleButtonClick}
size="medium"
accent="tertiary"
/>
);
};

Some files were not shown because too many files have changed in this diff Show More