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:
120
front/src/modules/ui/layout/board/components/BoardColumn.tsx
Normal file
120
front/src/modules/ui/layout/board/components/BoardColumn.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
189
front/src/modules/ui/layout/board/components/BoardColumnMenu.tsx
Normal file
189
front/src/modules/ui/layout/board/components/BoardColumnMenu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
123
front/src/modules/ui/layout/board/components/BoardHeader.tsx
Normal file
123
front/src/modules/ui/layout/board/components/BoardHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
187
front/src/modules/ui/layout/board/components/EntityBoard.tsx
Normal file
187
front/src/modules/ui/layout/board/components/EntityBoard.tsx
Normal 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>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
@ -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>;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>;
|
||||
};
|
||||
36
front/src/modules/ui/layout/board/components/NewButton.tsx
Normal file
36
front/src/modules/ui/layout/board/components/NewButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
10
front/src/modules/ui/layout/board/components/StyledBoard.tsx
Normal file
10
front/src/modules/ui/layout/board/components/StyledBoard.tsx
Normal 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)};
|
||||
`;
|
||||
@ -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 = {};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const BoardCardIdContext = createContext<string | null>(null);
|
||||
@ -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);
|
||||
@ -0,0 +1,5 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
import { BoardOptions } from '@/ui/layout/board/types/BoardOptions';
|
||||
|
||||
export const BoardOptionsContext = createContext<BoardOptions | null>(null);
|
||||
@ -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,
|
||||
},
|
||||
]),
|
||||
};
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
53
front/src/modules/ui/layout/board/hooks/useBoardColumns.ts
Normal file
53
front/src/modules/ui/layout/board/hooks/useBoardColumns.ts
Normal 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 };
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { BoardContext } from '@/companies/states/contexts/BoardContext';
|
||||
|
||||
export const useBoardContext = () => {
|
||||
return useContext(BoardContext);
|
||||
};
|
||||
@ -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(),
|
||||
},
|
||||
]),
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
26
front/src/modules/ui/layout/board/hooks/useRemoveCardIds.ts
Normal file
26
front/src/modules/ui/layout/board/hooks/useRemoveCardIds.ts
Normal 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)),
|
||||
);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
},
|
||||
[],
|
||||
);
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const activeCardIdsState = atom<string[]>({
|
||||
key: 'activeCardIdsState',
|
||||
default: [],
|
||||
});
|
||||
@ -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: [],
|
||||
});
|
||||
@ -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: [],
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const boardCardIdsByColumnIdFamilyState = atomFamily<string[], string>({
|
||||
key: 'boardCardIdsByColumnIdFamilyState',
|
||||
default: [],
|
||||
});
|
||||
@ -0,0 +1,8 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { BoardColumnDefinition } from '@/ui/layout/board/types/BoardColumnDefinition';
|
||||
|
||||
export const boardColumnsState = atom<BoardColumnDefinition[]>({
|
||||
key: 'boardColumnsState',
|
||||
default: [],
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const isBoardLoadedState = atom<boolean>({
|
||||
key: 'isBoardLoadedState',
|
||||
default: false,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const isCardSelectedFamilyState = atomFamily<boolean, string>({
|
||||
key: 'isCardSelectedFamilyState',
|
||||
default: false,
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const BoardColumnRecoilScopeContext = createContext<string | null>(null);
|
||||
@ -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: [],
|
||||
});
|
||||
@ -0,0 +1,8 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { BoardColumnDefinition } from '../types/BoardColumnDefinition';
|
||||
|
||||
export const savedBoardColumnsState = atom<BoardColumnDefinition[]>({
|
||||
key: 'savedBoardColumnsState',
|
||||
default: [],
|
||||
});
|
||||
@ -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 }), {}),
|
||||
});
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
@ -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)),
|
||||
),
|
||||
});
|
||||
@ -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)),
|
||||
});
|
||||
@ -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,
|
||||
];
|
||||
},
|
||||
});
|
||||
@ -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 }), {}),
|
||||
});
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
),
|
||||
});
|
||||
@ -0,0 +1,8 @@
|
||||
import { ThemeColor } from '@/ui/theme/constants/colors';
|
||||
|
||||
export type BoardColumnDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
index: number;
|
||||
colorCode?: ThemeColor;
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
export enum BoardColumnHotkeyScope {
|
||||
BoardColumn = 'board-column',
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
12
front/src/modules/ui/layout/board/types/BoardOptions.ts
Normal file
12
front/src/modules/ui/layout/board/types/BoardOptions.ts
Normal 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[];
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
export enum BoardOptionsHotkeyScope {
|
||||
Dropdown = 'board-options-dropdown',
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export enum ColumnHotkeyScope {
|
||||
EditColumnName = 'EditColumnNameHotkeyScope',
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export enum BoardScopeIds {
|
||||
OptionsDropdown = 'board-options',
|
||||
}
|
||||
@ -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 = {};
|
||||
@ -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 = {};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
103
front/src/modules/ui/layout/dropdown/components/DropdownMenu.tsx
Normal file
103
front/src/modules/ui/layout/dropdown/components/DropdownMenu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
));
|
||||
@ -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;
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
`;
|
||||
@ -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'};
|
||||
`;
|
||||
@ -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%;
|
||||
`;
|
||||
@ -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%;
|
||||
`;
|
||||
@ -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);
|
||||
}
|
||||
`;
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
};
|
||||
@ -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 },
|
||||
};
|
||||
65
front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts
Normal file
65
front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
]);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>();
|
||||
@ -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,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
|
||||
|
||||
export const isDropdownOpenScopedState = createScopedState<boolean>({
|
||||
key: 'isDropdownOpenScopedState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
216
front/src/modules/ui/layout/modal/components/Modal.tsx
Normal file
216
front/src/modules/ui/layout/modal/components/Modal.tsx
Normal 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;
|
||||
@ -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,
|
||||
};
|
||||
@ -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 },
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
export enum ModalHotkeyScope {
|
||||
Default = 'default',
|
||||
}
|
||||
@ -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';
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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';
|
||||
@ -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 = {
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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
Reference in New Issue
Block a user