Reorganize frontend and install Craco to alias modules (#190)

This commit is contained in:
Charles Bochet
2023-06-04 11:23:09 +02:00
committed by GitHub
parent bbc80cd543
commit 7b858fd7c9
149 changed files with 3441 additions and 1158 deletions

View File

@ -0,0 +1,36 @@
import styled from '@emotion/styled';
const StyledIconButton = styled.button`
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: ${(props) => props.theme.text80};
color: ${(props) => props.theme.text100};
transition: color 0.1s ease-in-out, background 0.1s ease-in-out;
background: ${(props) => props.theme.blue};
color: ${(props) => props.theme.text0};
cursor: pointer;
&:disabled {
background: ${(props) => props.theme.quadraryBackground};
color: ${(props) => props.theme.text80};
cursor: default;
}
`;
export function IconButton({
icon,
...props
}: { icon: React.ReactNode } & React.ButtonHTMLAttributes<HTMLButtonElement>) {
return <StyledIconButton {...props}>{icon}</StyledIconButton>;
}

View File

@ -0,0 +1,12 @@
import styled from '@emotion/styled';
export const CellBaseContainer = styled.div`
position: relative;
box-sizing: border-box;
height: 32px;
display: flex;
align-items: center;
width: 100%;
cursor: pointer;
user-select: none;
`;

View File

@ -0,0 +1,29 @@
import styled from '@emotion/styled';
import { overlayBackground } from '../../layout/styles/themes';
type OwnProps = {
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
};
export const CellEditModeContainer = styled.div<OwnProps>`
display: flex;
align-items: center;
min-width: 100%;
min-height: 100%;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
margin-left: -2px;
position: absolute;
left: ${(props) =>
props.editModeHorizontalAlign === 'right' ? 'auto' : '0'};
right: ${(props) =>
props.editModeHorizontalAlign === 'right' ? '0' : 'auto'};
top: ${(props) => (props.editModeVerticalPosition === 'over' ? '0' : '100%')};
border: 1px solid ${(props) => props.theme.primaryBorder};
z-index: 1;
border-radius: 4px;
${overlayBackground}
`;

View File

@ -0,0 +1,12 @@
import styled from '@emotion/styled';
export const CellNormalModeContainer = styled.div`
display: flex;
align-items: center;
width: 100%;
height: 100%;
overflow: hidden;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
`;

View File

@ -0,0 +1,58 @@
import { ReactElement } from 'react';
import { useRecoilState } from 'recoil';
import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState';
import { CellBaseContainer } from './CellBaseContainer';
import { CellNormalModeContainer } from './CellNormalModeContainer';
import { EditableCellEditMode } from './EditableCellEditMode';
type OwnProps = {
editModeContent: ReactElement;
nonEditModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
isEditMode?: boolean;
isCreateMode?: boolean;
onOutsideClick?: () => void;
onInsideClick?: () => void;
};
export function EditableCell({
editModeContent,
nonEditModeContent,
editModeHorizontalAlign = 'left',
editModeVerticalPosition = 'over',
isEditMode = false,
onOutsideClick,
onInsideClick,
}: OwnProps) {
const [isSomeInputInEditMode, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
function handleOnClick() {
if (!isSomeInputInEditMode) {
onInsideClick?.();
setIsSomeInputInEditMode(true);
}
}
return (
<CellBaseContainer onClick={handleOnClick}>
{isEditMode ? (
<EditableCellEditMode
editModeContent={editModeContent}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
isEditMode={isEditMode}
onOutsideClick={onOutsideClick}
/>
) : (
<CellNormalModeContainer>
<>{nonEditModeContent}</>
</CellNormalModeContainer>
)}
</CellBaseContainer>
);
}

View File

@ -0,0 +1,89 @@
import { ReactElement, useMemo, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useRecoilState } from 'recoil';
import { debounce } from '@/utils/debounce';
import { useListenClickOutsideArrayOfRef } from '../../hooks/useListenClickOutsideArrayOfRef';
import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState';
import { CellEditModeContainer } from './CellEditModeContainer';
type OwnProps = {
editModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
isEditMode?: boolean;
onOutsideClick?: () => void;
onInsideClick?: () => void;
};
export function EditableCellEditMode({
editModeHorizontalAlign,
editModeVerticalPosition,
editModeContent,
isEditMode,
onOutsideClick,
}: OwnProps) {
const wrapperRef = useRef(null);
const [, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
const debouncedSetIsSomeInputInEditMode = useMemo(() => {
return debounce(setIsSomeInputInEditMode, 20);
}, [setIsSomeInputInEditMode]);
useListenClickOutsideArrayOfRef([wrapperRef], () => {
if (isEditMode) {
debouncedSetIsSomeInputInEditMode(false);
onOutsideClick?.();
}
});
useHotkeys(
'esc',
() => {
if (isEditMode) {
onOutsideClick?.();
debouncedSetIsSomeInputInEditMode(false);
}
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode],
);
useHotkeys(
'enter',
() => {
if (isEditMode) {
onOutsideClick?.();
debouncedSetIsSomeInputInEditMode(false);
}
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode],
);
return (
<CellEditModeContainer
data-testid="editable-cell-edit-mode-container"
ref={wrapperRef}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{editModeContent}
</CellEditModeContainer>
);
}

View File

@ -0,0 +1,67 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState';
import { CellBaseContainer } from './CellBaseContainer';
import { EditableCellMenuEditMode } from './EditableCellMenuEditMode';
const EditableCellMenuNormalModeContainer = styled.div`
display: flex;
align-items: center;
width: calc(100% - ${(props) => props.theme.spacing(5)});
height: 100%;
overflow: hidden;
`;
type OwnProps = {
editModeContent: ReactElement;
nonEditModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
isEditMode?: boolean;
isCreateMode?: boolean;
onOutsideClick?: () => void;
onInsideClick?: () => void;
};
// TODO: refactor
export function EditableCellMenu({
editModeContent,
nonEditModeContent,
editModeHorizontalAlign = 'left',
editModeVerticalPosition = 'over',
isEditMode = false,
onOutsideClick,
onInsideClick,
}: OwnProps) {
const [isSomeInputInEditMode, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
function handleOnClick() {
if (!isSomeInputInEditMode) {
onInsideClick?.();
setIsSomeInputInEditMode(true);
}
}
return (
<CellBaseContainer onClick={handleOnClick}>
<EditableCellMenuNormalModeContainer>
{nonEditModeContent}
</EditableCellMenuNormalModeContainer>
{isEditMode && (
<EditableCellMenuEditMode
editModeContent={editModeContent}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
isEditMode={isEditMode}
onOutsideClick={onOutsideClick}
onInsideClick={onInsideClick}
/>
)}
</CellBaseContainer>
);
}

View File

@ -0,0 +1,88 @@
import { ReactElement, useMemo, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useRecoilState } from 'recoil';
import { debounce } from '@/utils/debounce';
import { useListenClickOutsideArrayOfRef } from '../../hooks/useListenClickOutsideArrayOfRef';
import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState';
import { EditableCellMenuEditModeContainer } from './EditableCellMenuEditModeContainer';
type OwnProps = {
editModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
isEditMode?: boolean;
onOutsideClick?: () => void;
onInsideClick?: () => void;
};
export function EditableCellMenuEditMode({
editModeHorizontalAlign,
editModeVerticalPosition,
editModeContent,
isEditMode,
onOutsideClick,
}: OwnProps) {
const wrapperRef = useRef(null);
const [, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
const debouncedSetIsSomeInputInEditMode = useMemo(() => {
return debounce(setIsSomeInputInEditMode, 20);
}, [setIsSomeInputInEditMode]);
useListenClickOutsideArrayOfRef([wrapperRef], () => {
if (isEditMode) {
debouncedSetIsSomeInputInEditMode(false);
onOutsideClick?.();
}
});
useHotkeys(
'esc',
() => {
if (isEditMode) {
onOutsideClick?.();
debouncedSetIsSomeInputInEditMode(false);
}
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode],
);
useHotkeys(
'enter',
() => {
if (isEditMode) {
onOutsideClick?.();
debouncedSetIsSomeInputInEditMode(false);
}
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode],
);
return (
<EditableCellMenuEditModeContainer
ref={wrapperRef}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{editModeContent}
</EditableCellMenuEditModeContainer>
);
}

View File

@ -0,0 +1,27 @@
import styled from '@emotion/styled';
import { overlayBackground } from '../../layout/styles/themes';
type OwnProps = {
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
};
// TODO: refactor
export const EditableCellMenuEditModeContainer = styled.div<OwnProps>`
display: flex;
align-items: center;
min-width: 100%;
min-height: 100%;
position: absolute;
left: ${(props) =>
props.editModeHorizontalAlign === 'right' ? 'auto' : '-1px'};
right: ${(props) =>
props.editModeHorizontalAlign === 'right' ? '0' : 'auto'};
top: ${(props) => (props.editModeVerticalPosition === 'over' ? '0' : '100%')};
border: 1px solid ${(props) => props.theme.primaryBorder};
z-index: 1;
border-radius: 4px;
${overlayBackground}
`;

View File

@ -0,0 +1,98 @@
import { ChangeEvent, ComponentType, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { CellCommentChip } from '@/comments/components/comments/CellCommentChip';
import { textInputStyle } from '../../layout/styles/themes';
import { EditableCell } from './EditableCell';
export type EditableChipProps = {
placeholder?: string;
value: string;
picture: string;
changeHandler: (updated: string) => void;
editModeHorizontalAlign?: 'left' | 'right';
ChipComponent: ComponentType<{ name: string; picture: string }>;
commentCount?: number;
onCommentClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
};
// TODO: refactor
const StyledInplaceInput = styled.input`
width: 100%;
padding-left: ${(props) => props.theme.spacing(1)};
padding-right: ${(props) => props.theme.spacing(1)};
${textInputStyle}
`;
const StyledInplaceShow = styled.div`
display: flex;
width: 100%;
&::placeholder {
font-weight: 'bold';
color: props.theme.text20;
}
`;
function EditableChip({
value,
placeholder,
changeHandler,
picture,
editModeHorizontalAlign,
ChipComponent,
commentCount,
onCommentClick,
}: EditableChipProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(value);
const [isEditMode, setIsEditMode] = useState(false);
const showComment = commentCount ? commentCount > 0 : false;
function handleCommentClick(event: React.MouseEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
onCommentClick?.(event);
}
return (
<EditableCell
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
isEditMode={isEditMode}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<StyledInplaceInput
placeholder={placeholder || ''}
autoFocus
ref={inputRef}
value={inputValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
changeHandler(event.target.value);
}}
/>
}
nonEditModeContent={
<>
<StyledInplaceShow>
<ChipComponent name={inputValue} picture={picture} />
</StyledInplaceShow>
{showComment && (
<CellCommentChip
count={commentCount ?? 0}
onClick={handleCommentClick}
/>
)}
</>
}
></EditableCell>
);
}
export default EditableChip;

View File

@ -0,0 +1,87 @@
import { forwardRef, useState } from 'react';
import styled from '@emotion/styled';
import { humanReadableDate } from '@/utils/utils';
import DatePicker from '../form/DatePicker';
import { EditableCell } from './EditableCell';
export type EditableDateProps = {
value: Date;
changeHandler: (date: Date) => void;
editModeHorizontalAlign?: 'left' | 'right';
};
const StyledContainer = styled.div`
display: flex;
align-items: center;
`;
export type StyledCalendarContainerProps = {
editModeHorizontalAlign?: 'left' | 'right';
};
const StyledCalendarContainer = styled.div<StyledCalendarContainerProps>`
position: absolute;
border: 1px solid ${(props) => props.theme.primaryBorder};
border-radius: 8px;
box-shadow: 0px 3px 12px rgba(0, 0, 0, 0.09);
z-index: 1;
left: -10px;
top: 10px;
background: ${(props) => props.theme.secondaryBackground};
`;
export function EditableDate({
value,
changeHandler,
editModeHorizontalAlign,
}: EditableDateProps) {
const [inputValue, setInputValue] = useState(value);
const [isEditMode, setIsEditMode] = useState(false);
type DivProps = React.HTMLProps<HTMLDivElement>;
const DateDisplay = forwardRef<HTMLDivElement, DivProps>(
({ value, onClick }, ref) => (
<div onClick={onClick} ref={ref}>
{value && humanReadableDate(new Date(value as string))}
</div>
),
);
interface DatePickerContainerProps {
children: React.ReactNode;
}
const DatePickerContainer = ({ children }: DatePickerContainerProps) => {
return <StyledCalendarContainer>{children}</StyledCalendarContainer>;
};
return (
<EditableCell
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<StyledContainer>
<DatePicker
date={inputValue}
onChangeHandler={(date: Date) => {
changeHandler(date);
setInputValue(date);
}}
customInput={<DateDisplay />}
customCalendarContainer={DatePickerContainer}
/>
</StyledContainer>
}
nonEditModeContent={
<StyledContainer>
<div>{inputValue && humanReadableDate(inputValue)}</div>
</StyledContainer>
}
></EditableCell>
);
}

View File

@ -0,0 +1,75 @@
import { ChangeEvent, ReactElement, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '../../layout/styles/themes';
import { EditableCell } from './EditableCell';
type OwnProps = {
firstValue: string;
secondValue: string;
firstValuePlaceholder: string;
secondValuePlaceholder: string;
nonEditModeContent: ReactElement;
onChange: (firstValue: string, secondValue: string) => void;
};
const StyledContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
& > input:last-child {
padding-left: ${(props) => props.theme.spacing(2)};
border-left: 1px solid ${(props) => props.theme.primaryBorder};
}
`;
const StyledEditInplaceInput = styled.input`
width: 45%;
height: 18px;
${textInputStyle}
`;
export function EditableDoubleText({
firstValue,
secondValue,
firstValuePlaceholder,
secondValuePlaceholder,
nonEditModeContent,
onChange,
}: OwnProps) {
const firstValueInputRef = useRef<HTMLInputElement>(null);
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCell
onInsideClick={() => setIsEditMode(true)}
onOutsideClick={() => setIsEditMode(false)}
isEditMode={isEditMode}
editModeContent={
<StyledContainer>
<StyledEditInplaceInput
autoFocus
placeholder={firstValuePlaceholder}
ref={firstValueInputRef}
value={firstValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value, secondValue);
}}
/>
<StyledEditInplaceInput
placeholder={secondValuePlaceholder}
ref={firstValueInputRef}
value={secondValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(firstValue, event.target.value);
}}
/>
</StyledContainer>
}
nonEditModeContent={nonEditModeContent}
></EditableCell>
);
}

View File

@ -0,0 +1,73 @@
import { ChangeEvent, MouseEvent, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js';
import Link from '../link/Link';
import { EditableCell } from './EditableCell';
type OwnProps = {
placeholder?: string;
value: string;
changeHandler: (updated: string) => void;
};
type StyledEditModeProps = {
isEditMode: boolean;
};
// TODO: refactor
const StyledEditInplaceInput = styled.input<StyledEditModeProps>`
width: 100%;
border: none;
outline: none;
&::placeholder {
font-weight: bold;
color: ${(props) => props.theme.text20};
}
`;
export function EditablePhone({ value, placeholder, changeHandler }: OwnProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(value);
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCell
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
editModeContent={
<StyledEditInplaceInput
autoFocus
isEditMode={isEditMode}
placeholder={placeholder || ''}
ref={inputRef}
value={inputValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
changeHandler(event.target.value);
}}
/>
}
nonEditModeContent={
<div>
{isValidPhoneNumber(inputValue) ? (
<Link
href={parsePhoneNumber(inputValue, 'FR')?.getURI()}
onClick={(event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
}}
>
{parsePhoneNumber(inputValue, 'FR')?.formatInternational() ||
inputValue}
</Link>
) : (
<Link href="#">{inputValue}</Link>
)}
</div>
}
/>
);
}

View File

@ -0,0 +1,221 @@
import { ChangeEvent, ComponentType, useEffect, useState } from 'react';
import { FaPlus } from 'react-icons/fa';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { SearchConfigType } from '@/search/interfaces/interface';
import { useSearch } from '@/search/services/search';
import { AnyEntity } from '@/utils/interfaces/generic.interface';
import { isDefined } from '@/utils/type-guards/isDefined';
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
import { textInputStyle } from '../../layout/styles/themes';
import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState';
import { CellNormalModeContainer } from './CellNormalModeContainer';
import { EditableCellMenu } from './EditableCellMenu';
import { EditableRelationCreateButton } from './EditableRelationCreateButton';
import { HoverableMenuItem } from './HoverableMenuItem';
const StyledEditModeContainer = styled.div`
width: 200px;
// margin-left: calc(-1 * ${(props) => props.theme.spacing(2)});
// margin-right: calc(-1 * ${(props) => props.theme.spacing(2)});
`;
const StyledEditModeSelectedContainer = styled.div`
height: 31px;
display: flex;
align-items: center;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(1)};
`;
const StyledEditModeSearchContainer = styled.div`
height: 32px;
display: flex;
align-items: center;
border-top: 1px solid ${(props) => props.theme.primaryBorder};
padding-left: ${(props) => props.theme.spacing(1)};
padding-right: ${(props) => props.theme.spacing(1)};
`;
const StyledEditModeCreateButtonContainer = styled.div`
height: 36px;
display: flex;
align-items: center;
border-top: 1px solid ${(props) => props.theme.primaryBorder};
padding: ${(props) => props.theme.spacing(1)};
color: ${(props) => props.theme.text60};
`;
const StyledEditModeSearchInput = styled.input`
width: 100%;
${textInputStyle}
`;
const StyledEditModeResults = styled.div`
border-top: 1px solid ${(props) => props.theme.primaryBorder};
padding-left: ${(props) => props.theme.spacing(1)};
padding-right: ${(props) => props.theme.spacing(1)};
`;
const StyledEditModeResultItem = styled.div`
height: 32px;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
`;
const StyledCreateButtonIcon = styled.div`
color: ${(props) => props.theme.text100};
align-self: center;
padding-top: 4px;
`;
const StyledCreateButtonText = styled.div`
color: ${(props) => props.theme.text60};
`;
export type EditableRelationProps<
RelationType extends AnyEntity,
ChipComponentPropsType,
> = {
relation?: RelationType | null;
searchPlaceholder: string;
searchConfig: SearchConfigType<RelationType>;
onChange: (relation: RelationType) => void;
onChangeSearchInput?: (searchInput: string) => void;
editModeHorizontalAlign?: 'left' | 'right';
ChipComponent: ComponentType<ChipComponentPropsType>;
chipComponentPropsMapper: (
relation: RelationType,
) => ChipComponentPropsType & JSX.IntrinsicAttributes;
// TODO: refactor, newRelationName is too hard coded.
onCreate?: (newRelationName: string) => void;
};
// TODO: split this component
export function EditableRelation<
RelationType extends AnyEntity,
ChipComponentPropsType,
>({
relation,
searchPlaceholder,
searchConfig,
onChange,
onChangeSearchInput,
editModeHorizontalAlign,
ChipComponent,
chipComponentPropsMapper,
onCreate,
}: EditableRelationProps<RelationType, ChipComponentPropsType>) {
const [isEditMode, setIsEditMode] = useState(false);
const [, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
// TODO: Tie this to a react context
const [filterSearchResults, setSearchInput, setFilterSearch, searchInput] =
useSearch<RelationType>();
useEffect(() => {
if (isDefined(onChangeSearchInput)) {
onChangeSearchInput(searchInput);
}
}, [onChangeSearchInput, searchInput]);
const canCreate = isDefined(onCreate);
const createButtonIsVisible =
canCreate && isEditMode && isNonEmptyString(searchInput);
function handleCreateNewRelationButtonClick() {
onCreate?.(searchInput);
closeEditMode();
}
function closeEditMode() {
setIsEditMode(false);
setIsSomeInputInEditMode(false);
}
return (
<>
<EditableCellMenu
editModeHorizontalAlign={editModeHorizontalAlign}
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => {
if (!isEditMode) {
setIsEditMode(true);
}
}}
editModeContent={
<StyledEditModeContainer>
<StyledEditModeSelectedContainer>
{relation ? (
<ChipComponent {...chipComponentPropsMapper(relation)} />
) : (
<></>
)}
</StyledEditModeSelectedContainer>
<StyledEditModeSearchContainer>
<StyledEditModeSearchInput
autoFocus
placeholder={searchPlaceholder}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setFilterSearch(searchConfig);
setSearchInput(event.target.value);
}}
/>
</StyledEditModeSearchContainer>
{createButtonIsVisible && (
<StyledEditModeCreateButtonContainer>
<HoverableMenuItem>
<EditableRelationCreateButton
onClick={handleCreateNewRelationButtonClick}
>
<StyledCreateButtonIcon>
<FaPlus />
</StyledCreateButtonIcon>
<StyledCreateButtonText>Create new</StyledCreateButtonText>
</EditableRelationCreateButton>
</HoverableMenuItem>
</StyledEditModeCreateButtonContainer>
)}
<StyledEditModeResults>
{filterSearchResults.results &&
filterSearchResults.results.map((result, index) => (
<StyledEditModeResultItem
key={index}
onClick={() => {
onChange(result.value);
closeEditMode();
}}
>
<HoverableMenuItem>
<ChipComponent
{...chipComponentPropsMapper(result.value)}
/>
</HoverableMenuItem>
</StyledEditModeResultItem>
))}
</StyledEditModeResults>
</StyledEditModeContainer>
}
nonEditModeContent={
<CellNormalModeContainer>
{relation ? (
<ChipComponent {...chipComponentPropsMapper(relation)} />
) : (
<></>
)}
</CellNormalModeContainer>
}
/>
</>
);
}

View File

@ -0,0 +1,24 @@
import styled from '@emotion/styled';
export const EditableRelationCreateButton = styled.button`
display: flex;
align-items: center;
border: none;
font-size: ${(props) => props.theme.fontSizeMedium};
cursor: pointer;
user-select: none;
padding-top: ${(props) => props.theme.spacing(1)};
padding-bottom: ${(props) => props.theme.spacing(1)};
padding-left: ${(props) => props.theme.spacing(1)};
font-family: 'Inter';
border-radius: 4px;
width: 100%;
height: 31px;
background: none;
gap: 8px;
// :hover {
// background: rgba(0, 0, 0, 0.04);
// color: ${(props) => props.theme.text100};
// }
// margin-bottom: calc(${(props) => props.theme.spacing(1)} / 2);
`;

View File

@ -0,0 +1,61 @@
import { ChangeEvent, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '../../layout/styles/themes';
import { EditableCell } from './EditableCell';
type OwnProps = {
placeholder?: string;
content: string;
changeHandler: (updated: string) => void;
editModeHorizontalAlign?: 'left' | 'right';
};
type StyledEditModeProps = {
isEditMode: boolean;
};
// TODO: refactor
const StyledInplaceInput = styled.input<StyledEditModeProps>`
width: 100%;
${textInputStyle}
`;
const StyledNoEditText = styled.div`
width: 100%;
`;
export function EditableText({
content,
placeholder,
changeHandler,
editModeHorizontalAlign,
}: OwnProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(content);
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCell
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<StyledInplaceInput
isEditMode={isEditMode}
placeholder={placeholder || ''}
autoFocus
ref={inputRef}
value={inputValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
changeHandler(event.target.value);
}}
/>
}
nonEditModeContent={<StyledNoEditText>{inputValue}</StyledNoEditText>}
></EditableCell>
);
}

View File

@ -0,0 +1,19 @@
import styled from '@emotion/styled';
export const HoverableMenuItem = styled.div`
position: relative;
box-sizing: border-box;
height: 100%;
width: 100%;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
border-radius: 4px;
background: rgba(0, 0, 0, 0);
transition: background 0.1s ease;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
`;

View File

@ -0,0 +1,79 @@
import * as React from 'react';
import styled from '@emotion/styled';
type OwnProps = {
name: string;
id: string;
checked?: boolean;
indeterminate?: boolean;
onChange?: (newCheckedValue: boolean) => void;
};
const StyledContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
input[type='checkbox'] {
accent-color: ${(props) => props.theme.blue};
margin: 2px;
height: 14px;
width: 14px;
cursor: pointer;
user-select: none;
}
input[type='checkbox']::before {
content: '';
border: 1px solid ${(props) => props.theme.text40};
width: 12px;
height: 12px;
border-radius: 2px;
display: block;
}
input[type='checkbox']:hover::before {
border: 1px solid ${(props) => props.theme.text80};
}
input[type='checkbox']:checked::before {
border: 1px solid ${(props) => props.theme.blue};
}
`;
export function Checkbox({
name,
id,
checked,
onChange,
indeterminate,
}: OwnProps) {
const ref = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (ref.current === null) return;
if (typeof indeterminate === 'boolean') {
ref.current.indeterminate = !checked && indeterminate;
}
}, [ref, indeterminate, checked]);
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
if (onChange) {
onChange(event.target.checked);
}
}
return (
<StyledContainer>
<input
ref={ref}
type="checkbox"
data-testid="input-checkbox"
id={id}
name={name}
checked={checked}
onChange={handleInputChange}
/>
</StyledContainer>
);
}

View File

@ -0,0 +1,260 @@
import React, { forwardRef, ReactElement, useState } from 'react';
import ReactDatePicker, { CalendarContainerProps } from 'react-datepicker';
import styled from '@emotion/styled';
import { overlayBackground } from '../../layout/styles/themes';
import 'react-datepicker/dist/react-datepicker.css';
export type DatePickerProps = {
isOpen?: boolean;
date: Date;
onChangeHandler: (date: Date) => void;
customInput?: ReactElement;
customCalendarContainer?(props: CalendarContainerProps): React.ReactNode;
};
const StyledContainer = styled.div`
& .react-datepicker {
border-color: ${(props) => props.theme.primaryBorder};
background: transparent;
font-family: 'Inter';
font-size: ${(props) => props.theme.fontSizeMedium};
border: none;
display: block;
font-weight: 400;
}
& .react-datepicker-popper {
position: relative !important;
inset: auto !important;
transform: none !important;
padding: 0 !important;
}
& .react-datepicker__triangle::after {
display: none;
}
& .react-datepicker__triangle::before {
display: none;
}
// Header
& .react-datepicker__header {
background: transparent;
border: none;
}
& .react-datepicker__header__dropdown {
display: flex;
margin-left: ${(props) => props.theme.spacing(1)};
margin-bottom: ${(props) => props.theme.spacing(1)};
}
& .react-datepicker__month-dropdown-container,
& .react-datepicker__year-dropdown-container {
text-align: left;
border-radius: 4px;
margin-left: ${(props) => props.theme.spacing(1)};
margin-right: 0;
padding: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(4)};
background-color: ${(props) => props.theme.tertiaryBackground};
}
& .react-datepicker__month-read-view--down-arrow,
& .react-datepicker__year-read-view--down-arrow {
height: 5px;
width: 5px;
border-width: 1px 1px 0 0;
border-color: ${(props) => props.theme.text40};
top: 3px;
right: -6px;
}
& .react-datepicker__year-read-view,
& .react-datepicker__month-read-view {
padding-right: ${(props) => props.theme.spacing(2)};
}
& .react-datepicker__month-dropdown-container {
width: 80px;
}
& .react-datepicker__year-dropdown-container {
width: 50px;
}
& .react-datepicker__month-dropdown,
& .react-datepicker__year-dropdown {
border: ${(props) => props.theme.primaryBorder};
${overlayBackground}
overflow-y: scroll;
top: ${(props) => props.theme.spacing(2)};
}
& .react-datepicker__month-dropdown {
left: ${(props) => props.theme.spacing(2)};
width: 160px;
height: 260px;
}
& .react-datepicker__year-dropdown {
left: calc(${(props) => props.theme.spacing(9)} + 80px);
width: 100px;
height: 260px;
}
& .react-datepicker__navigation--years {
display: none;
}
& .react-datepicker__month-option--selected,
& .react-datepicker__year-option--selected {
display: none;
}
& .react-datepicker__year-option,
& .react-datepicker__month-option {
text-align: left;
padding: ${(props) => props.theme.spacing(2)}
calc(${(props) => props.theme.spacing(2)} - 2px);
width: calc(100% - ${(props) => props.theme.spacing(4)});
border-radius: 2px;
color: ${(props) => props.theme.text60};
cursor: pointer;
margin: 2px;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
&:first-of-type {
display: none;
}
&:last-of-type {
display: none;
}
}
& .react-datepicker__current-month {
display: none;
}
& .react-datepicker__day-name {
color: ${(props) => props.theme.text60};
width: 34px;
height: 40px;
line-height: 40px;
}
& .react-datepicker__month-container {
float: none;
}
// Days
& .react-datepicker__month {
margin-top: 0;
}
& .react-datepicker__day {
width: 34px;
height: 34px;
line-height: 34px;
}
& .react-datepicker__day--selected {
background-color: ${(props) => props.theme.blue};
}
& .react-datepicker__navigation--previous,
& .react-datepicker__navigation--next {
height: 34px;
border-radius: 4px;
padding-top: 6px;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
}
& .react-datepicker__navigation--previous {
right: 38px;
top: 8px;
left: auto;
& > span {
margin-left: -6px;
}
}
& .react-datepicker__navigation--next {
right: 6px;
top: 8px;
& > span {
margin-left: 6px;
}
}
& .react-datepicker__navigation-icon::before {
height: 7px;
width: 7px;
border-width: 1px 1px 0 0;
border-color: ${(props) => props.theme.text40};
}
& .react-datepicker__day--outside-month {
color: ${(props) => props.theme.text40};
}
& .react-datepicker__day--keyboard-selected {
background-color: inherit;
}
`;
function DatePicker({
date,
onChangeHandler,
customInput,
customCalendarContainer,
}: DatePickerProps) {
const [startDate, setStartDate] = useState(date);
type DivProps = React.HTMLProps<HTMLDivElement>;
const DefaultDateDisplay = forwardRef<HTMLDivElement, DivProps>(
({ value, onClick }, ref) => (
<div onClick={onClick} ref={ref}>
{value &&
new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(value as string))}
</div>
),
);
return (
<StyledContainer>
<ReactDatePicker
open={true}
selected={startDate}
showMonthDropdown
showYearDropdown
onChange={(date: Date) => {
setStartDate(date);
onChangeHandler(date);
}}
customInput={customInput ? customInput : <DefaultDateDisplay />}
calendarContainer={
customCalendarContainer ? customCalendarContainer : undefined
}
/>
</StyledContainer>
);
}
export default DatePicker;

View File

@ -0,0 +1,62 @@
import { ChangeEvent, useRef } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '../../layout/styles/themes';
type OwnProps = {
leftValue: string;
rightValue: string;
leftValuePlaceholder: string;
rightValuePlaceholder: string;
onChange: (leftValue: string, rightValue: string) => void;
};
const StyledContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
& > input:last-child {
padding-left: ${(props) => props.theme.spacing(2)};
border-left: 1px solid ${(props) => props.theme.primaryBorder};
}
`;
const StyledEditInplaceInput = styled.input`
width: 45%;
height: 18px;
${textInputStyle}
`;
export function DoubleTextInput({
leftValue,
rightValue,
leftValuePlaceholder,
rightValuePlaceholder,
onChange,
}: OwnProps) {
const firstValueInputRef = useRef<HTMLInputElement>(null);
return (
<StyledContainer>
<StyledEditInplaceInput
autoFocus
placeholder={leftValuePlaceholder}
ref={firstValueInputRef}
value={leftValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value, rightValue);
}}
/>
<StyledEditInplaceInput
placeholder={rightValuePlaceholder}
ref={firstValueInputRef}
value={rightValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(leftValue, event.target.value);
}}
/>
</StyledContainer>
);
}

View File

@ -0,0 +1,30 @@
import * as React from 'react';
import { Link as ReactLink } from 'react-router-dom';
import styled from '@emotion/styled';
type OwnProps = {
href: string;
children?: React.ReactNode;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
};
const StyledClickable = styled.div`
display: flex;
a {
color: inherit;
text-decoration: none;
}
`;
function Link({ href, children, onClick }: OwnProps) {
return (
<StyledClickable>
<ReactLink onClick={onClick} to={href}>
{children}
</ReactLink>
</StyledClickable>
);
}
export default Link;

View File

@ -0,0 +1,66 @@
import * as React from 'react';
import styled from '@emotion/styled';
import { Checkbox } from '../form/Checkbox';
type OwnProps = {
name: string;
id: string;
checked?: boolean;
indeterminate?: boolean;
onChange?: (newCheckedValue: boolean) => void;
};
const StyledContainer = styled.div`
width: 32px;
height: 32px;
margin-left: -${(props) => props.theme.table.horizontalCellMargin};
padding-left: ${(props) => props.theme.table.horizontalCellMargin};
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
`;
export function CheckboxCell({
name,
id,
checked,
onChange,
indeterminate,
}: OwnProps) {
const [internalChecked, setInternalChecked] = React.useState(checked);
function handleContainerClick() {
handleCheckboxChange(!internalChecked);
}
React.useEffect(() => {
setInternalChecked(checked);
}, [checked]);
function handleCheckboxChange(newCheckedValue: boolean) {
setInternalChecked(newCheckedValue);
if (onChange) {
onChange(newCheckedValue);
}
}
return (
<StyledContainer
onClick={handleContainerClick}
data-testid="input-checkbox-cell-container"
>
<Checkbox
id={id}
name={name}
checked={internalChecked}
onChange={handleCheckboxChange}
indeterminate={indeterminate}
/>
</StyledContainer>
);
}

View File

@ -0,0 +1,30 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
type OwnProps = {
viewName: string;
viewIcon?: ReactNode;
};
const StyledTitle = styled.div`
display: flex;
flex-direction: row;
align-items: center;
height: ${(props) => props.theme.spacing(8)};
font-weight: 500;
padding-left: ${(props) => props.theme.spacing(2)};
`;
const StyledIcon = styled.div`
display: flex;
margin-right: ${(props) => props.theme.spacing(1)};
`;
export function ColumnHead({ viewName, viewIcon }: OwnProps) {
return (
<StyledTitle>
<StyledIcon>{viewIcon}</StyledIcon>
{viewName}
</StyledTitle>
);
}

View File

@ -0,0 +1,180 @@
import * as React from 'react';
import styled from '@emotion/styled';
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useRecoilState } from 'recoil';
import {
FilterConfigType,
SelectedFilterType,
} from '@/filters-and-sorts/interfaces/filters/interface';
import {
SelectedSortType,
SortType,
} from '@/filters-and-sorts/interfaces/sorts/interface';
import { useResetTableRowSelection } from '../../tables/hooks/useResetTableRowSelection';
import { currentRowSelectionState } from '../../tables/states/rowSelectionState';
import { TableHeader } from './table-header/TableHeader';
type OwnProps<
TData extends { id: string; __typename: 'companies' | 'people' },
SortField,
> = {
data: Array<TData>;
columns: Array<ColumnDef<TData, any>>;
viewName: string;
viewIcon?: React.ReactNode;
availableSorts?: Array<SortType<SortField>>;
availableFilters?: FilterConfigType<TData>[];
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onFiltersUpdate?: (filters: Array<SelectedFilterType<TData>>) => void;
onRowSelectionChange?: (rowSelection: string[]) => void;
};
const StyledTable = styled.table`
min-width: 1000px;
width: calc(100% - 2 * ${(props) => props.theme.table.horizontalCellMargin});
border-radius: 4px;
border-spacing: 0;
border-collapse: collapse;
margin-left: ${(props) => props.theme.table.horizontalCellMargin};
margin-right: ${(props) => props.theme.table.horizontalCellMargin};
table-layout: fixed;
th {
border-collapse: collapse;
color: ${(props) => props.theme.text40};
padding: 0;
border: 1px solid ${(props) => props.theme.tertiaryBackground};
text-align: left;
:last-child {
border-right-color: transparent;
}
:first-of-type {
border-left-color: transparent;
}
}
td {
border-collapse: collapse;
color: ${(props) => props.theme.text80};
padding: 0;
border: 1px solid ${(props) => props.theme.tertiaryBackground};
text-align: left;
:last-child {
border-right-color: transparent;
}
:first-of-type {
border-left-color: transparent;
}
}
`;
const StyledTableWithHeader = styled.div`
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
`;
const StyledTableScrollableContainer = styled.div`
overflow: auto;
height: 100%;
flex: 1;
`;
export function EntityTable<
TData extends { id: string; __typename: 'companies' | 'people' },
SortField,
>({
data,
columns,
viewName,
viewIcon,
availableSorts,
availableFilters,
onSortsUpdate,
onFiltersUpdate,
}: OwnProps<TData, SortField>) {
const [currentRowSelection, setCurrentRowSelection] = useRecoilState(
currentRowSelectionState,
);
const resetTableRowSelection = useResetTableRowSelection();
React.useEffect(() => {
resetTableRowSelection();
}, [resetTableRowSelection]);
const table = useReactTable<TData>({
data,
columns,
state: {
rowSelection: currentRowSelection,
},
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
onRowSelectionChange: setCurrentRowSelection,
getRowId: (row) => row.id,
});
return (
<StyledTableWithHeader>
<TableHeader
viewName={viewName}
viewIcon={viewIcon}
availableSorts={availableSorts}
availableFilters={availableFilters}
onSortsUpdate={onSortsUpdate}
onFiltersUpdate={onFiltersUpdate}
/>
<StyledTableScrollableContainer>
<StyledTable>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
style={{
width: `${header.getSize()}px`,
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row, index) => (
<tr key={row.id} data-testid={`row-id-${row.index}`}>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id + row.original.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
);
})}
</tr>
))}
</tbody>
</StyledTable>
</StyledTableScrollableContainer>
</StyledTableWithHeader>
);
}

View File

@ -0,0 +1,18 @@
import { CheckboxCell } from './CheckboxCell';
export const SelectAllCheckbox = ({
indeterminate,
onChange,
}: {
indeterminate?: boolean;
onChange?: (newCheckedValue: boolean) => void;
} & React.HTMLProps<HTMLInputElement>) => {
return (
<CheckboxCell
name="select-all-checkbox"
id="select-all-checkbox"
indeterminate={indeterminate}
onChange={onChange}
/>
);
};

View File

@ -0,0 +1,36 @@
import React from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { selectedRowIdsState } from '@/ui/tables/states/selectedRowIdsState';
type OwnProps = {
children: React.ReactNode | React.ReactNode[];
};
const StyledContainer = styled.div`
display: flex;
position: absolute;
z-index: 1;
height: 48px;
bottom: 38px;
background: ${(props) => props.theme.secondaryBackground};
align-items: center;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
left: 50%;
transform: translateX(-50%);
border-radius: 8px;
border: 1px solid ${(props) => props.theme.primaryBorder};
`;
export function EntityTableActionBar({ children }: OwnProps) {
const selectedRowIds = useRecoilValue(selectedRowIdsState);
if (selectedRowIds.length === 0) {
return <></>;
}
return <StyledContainer>{children}</StyledContainer>;
}

View File

@ -0,0 +1,49 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
type OwnProps = {
icon: ReactNode;
label: string;
type?: 'standard' | 'warning';
onClick: () => void;
};
type StyledButtonProps = {
type: 'standard' | 'warning';
};
const StyledButton = styled.div<StyledButtonProps>`
display: flex;
cursor: pointer;
user-select: none;
color: ${(props) =>
props.type === 'warning' ? props.theme.red : props.theme.text60};
justify-content: center;
padding: ${(props) => props.theme.spacing(2)};
border-radius: 4px;
transition: background 0.1s ease;
&:hover {
background: ${(props) => props.theme.tertiaryBackground};
}
`;
const StyledButtonLabel = styled.div`
margin-left: ${(props) => props.theme.spacing(2)};
font-weight: 500;
`;
export function EntityTableActionBarButton({
label,
icon,
type = 'standard',
onClick,
}: OwnProps) {
return (
<StyledButton type={type} onClick={onClick}>
{icon}
<StyledButtonLabel>{label}</StyledButtonLabel>
</StyledButton>
);
}

View File

@ -0,0 +1,23 @@
import { FaRegComment } from 'react-icons/fa';
import { useOpenRightDrawer } from '@/ui/layout/right-drawer/hooks/useOpenRightDrawer';
import { EntityTableActionBarButton } from './EntityTableActionBarButton';
export function TableActionBarButtonToggleComments() {
// TODO: here it would be nice to access the table context
// But let's see when we have custom entities and properties
const openRightDrawer = useOpenRightDrawer();
async function handleButtonClick() {
openRightDrawer('comments');
}
return (
<EntityTableActionBarButton
label="Comment"
icon={<FaRegComment size={16} />}
onClick={handleButtonClick}
/>
);
}

View File

@ -0,0 +1,207 @@
import { ReactNode, useRef } from 'react';
import { FaAngleDown } from 'react-icons/fa';
import styled from '@emotion/styled';
import { useOutsideAlerter } from '../../../hooks/useOutsideAlerter';
import {
overlayBackground,
textInputStyle,
} from '../../../layout/styles/themes';
type OwnProps = {
label: string;
isActive: boolean;
children?: ReactNode;
isUnfolded?: boolean;
setIsUnfolded?: React.Dispatch<React.SetStateAction<boolean>>;
resetState?: () => void;
};
const StyledDropdownButtonContainer = styled.div`
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
`;
type StyledDropdownButtonProps = {
isUnfolded: boolean;
isActive: boolean;
};
const StyledDropdownButton = styled.div<StyledDropdownButtonProps>`
display: flex;
margin-left: ${(props) => props.theme.spacing(3)};
cursor: pointer;
user-select: none;
background: ${(props) => props.theme.primaryBackground};
color: ${(props) => (props.isActive ? props.theme.blue : 'none')};
padding: ${(props) => props.theme.spacing(1)};
border-radius: 4px;
filter: ${(props) => (props.isUnfolded ? 'brightness(0.95)' : 'none')};
&:hover {
filter: brightness(0.95);
}
`;
const StyledDropdown = styled.ul`
--wraper-border: 1px;
--wraper-border-radius: 8px;
--outer-border-radius: calc(var(--wraper-border-radius) - 2px);
display: flex;
flex-direction: column;
position: absolute;
top: 14px;
right: 0;
border: var(--wraper-border) solid ${(props) => props.theme.primaryBorder};
border-radius: var(--wraper-border-radius);
padding: 0px;
min-width: 160px;
${overlayBackground}
li {
&:first-of-type {
border-top-left-radius: var(--outer-border-radius);
border-top-right-radius: var(--outer-border-radius);
}
&:last-of-type {
border-bottom-left-radius: var(--outer-border-radius);
border-bottom-right-radius: var(--outer-border-radius);
border-bottom: 0;
}
}
`;
const StyledDropdownItem = styled.li`
display: flex;
align-items: center;
width: calc(160px - ${(props) => props.theme.spacing(4)});
padding: ${(props) => props.theme.spacing(2)}
calc(${(props) => props.theme.spacing(2)} - 2px);
margin: 2px;
cursor: pointer;
user-select: none;
color: ${(props) => props.theme.text60};
border-radius: 2px;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
`;
const StyledDropdownItemClipped = styled.span`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;
const StyledDropdownTopOption = styled.li`
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(${(props) => props.theme.spacing(2)} + 2px)
calc(${(props) => props.theme.spacing(2)});
cursor: pointer;
user-select: none;
color: ${(props) => props.theme.text80};
font-weight: ${(props) => props.theme.fontWeightBold};
&:hover {
background: rgba(0, 0, 0, 0.04);
}
border-bottom: 1px solid ${(props) => props.theme.primaryBorder};
`;
const StyledIcon = styled.div`
display: flex;
margin-right: ${(props) => props.theme.spacing(1)};
min-width: ${(props) => props.theme.spacing(4)};
justify-content: center;
`;
const StyledSearchField = styled.li`
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
color: ${(props) => props.theme.text60};
font-weight: ${(props) => props.theme.fontWeightBold};
border-bottom: var(--wraper-border) solid
${(props) => props.theme.primaryBorder};
overflow: hidden;
input {
height: 36px;
width: 100%;
padding: 8px;
box-sizing: border-box;
font-family: ${(props) => props.theme.fontFamily};
border-radius: 8px;
${textInputStyle}
&:focus {
outline: 0 none;
}
}
`;
function DropdownButton({
label,
isActive,
children,
isUnfolded = false,
setIsUnfolded,
resetState,
}: OwnProps) {
const onButtonClick = () => {
setIsUnfolded && setIsUnfolded(!isUnfolded);
};
const onOutsideClick = () => {
setIsUnfolded && setIsUnfolded(false);
resetState && resetState();
};
const dropdownRef = useRef(null);
useOutsideAlerter(dropdownRef, onOutsideClick);
return (
<StyledDropdownButtonContainer>
<StyledDropdownButton
isUnfolded={isUnfolded}
onClick={onButtonClick}
isActive={isActive}
aria-selected={isActive}
>
{label}
</StyledDropdownButton>
{isUnfolded && (
<StyledDropdown ref={dropdownRef}>{children}</StyledDropdown>
)}
</StyledDropdownButtonContainer>
);
}
const StyleAngleDownContainer = styled.div`
margin-left: auto;
`;
function DropdownTopOptionAngleDown() {
return (
<StyleAngleDownContainer>
<FaAngleDown />
</StyleAngleDownContainer>
);
}
DropdownButton.StyledDropdownItem = StyledDropdownItem;
DropdownButton.StyledDropdownItemClipped = StyledDropdownItemClipped;
DropdownButton.StyledSearchField = StyledSearchField;
DropdownButton.StyledDropdownTopOption = StyledDropdownTopOption;
DropdownButton.StyledDropdownTopOptionAngleDown = DropdownTopOptionAngleDown;
DropdownButton.StyledIcon = StyledIcon;
export default DropdownButton;

View File

@ -0,0 +1,210 @@
import { ChangeEvent, useCallback, useState } from 'react';
import styled from '@emotion/styled';
import {
FilterableFieldsType,
FilterConfigType,
FilterOperandType,
SelectedFilterType,
} from '@/filters-and-sorts/interfaces/filters/interface';
import { SearchResultsType, useSearch } from '@/search/services/search';
import { humanReadableDate } from '@/utils/utils';
import DatePicker from '../../form/DatePicker';
import DropdownButton from './DropdownButton';
type OwnProps<TData extends FilterableFieldsType> = {
isFilterSelected: boolean;
availableFilters: FilterConfigType<TData>[];
onFilterSelect: (filter: SelectedFilterType<TData>) => void;
onFilterRemove: (filterId: SelectedFilterType<TData>['key']) => void;
};
export const FilterDropdownButton = <TData extends FilterableFieldsType>({
availableFilters,
onFilterSelect,
isFilterSelected,
onFilterRemove,
}: OwnProps<TData>) => {
const [isUnfolded, setIsUnfolded] = useState(false);
const [isOperandSelectionUnfolded, setIsOperandSelectionUnfolded] =
useState(false);
const [selectedFilter, setSelectedFilter] = useState<
FilterConfigType<TData> | undefined
>(undefined);
const [selectedFilterOperand, setSelectedFilterOperand] = useState<
FilterOperandType<TData> | undefined
>(undefined);
const [filterSearchResults, setSearchInput, setFilterSearch] = useSearch();
const resetState = useCallback(() => {
setIsOperandSelectionUnfolded(false);
setSelectedFilter(undefined);
setSelectedFilterOperand(undefined);
setFilterSearch(null);
}, [setFilterSearch]);
const renderOperandSelection = selectedFilter?.operands.map(
(filterOperand, index) => (
<DropdownButton.StyledDropdownItem
key={`select-filter-operand-${index}`}
onClick={() => {
setSelectedFilterOperand(filterOperand);
setIsOperandSelectionUnfolded(false);
}}
>
{filterOperand.label}
</DropdownButton.StyledDropdownItem>
),
);
const renderFilterSelection = availableFilters.map((filter, index) => (
<DropdownButton.StyledDropdownItem
key={`select-filter-${index}`}
onClick={() => {
setSelectedFilter(filter);
setSelectedFilterOperand(filter.operands[0]);
filter.searchConfig && setFilterSearch(filter.searchConfig);
setSearchInput('');
}}
>
<DropdownButton.StyledIcon>{filter.icon}</DropdownButton.StyledIcon>
{filter.label}
</DropdownButton.StyledDropdownItem>
));
const renderSearchResults = (
filterSearchResults: SearchResultsType,
selectedFilter: FilterConfigType<TData>,
selectedFilterOperand: FilterOperandType<TData>,
) => {
if (filterSearchResults.loading) {
return (
<DropdownButton.StyledDropdownItem data-testid="loading-search-results">
Loading
</DropdownButton.StyledDropdownItem>
);
}
return filterSearchResults.results.map((result, index) => {
return (
<DropdownButton.StyledDropdownItem
key={`fields-value-${index}`}
onClick={() => {
onFilterSelect({
key: selectedFilter.key,
label: selectedFilter.label,
value: result.value,
displayValue: result.render(result.value),
icon: selectedFilter.icon,
operand: selectedFilterOperand,
});
setIsUnfolded(false);
setSelectedFilter(undefined);
}}
>
<DropdownButton.StyledDropdownItemClipped>
{result.render(result.value)}
</DropdownButton.StyledDropdownItemClipped>
</DropdownButton.StyledDropdownItem>
);
});
};
function renderValueSelection(
selectedFilter: FilterConfigType<TData>,
selectedFilterOperand: FilterOperandType<TData>,
) {
return (
<>
<DropdownButton.StyledDropdownTopOption
key={'selected-filter-operand'}
onClick={() => setIsOperandSelectionUnfolded(true)}
>
{selectedFilterOperand.label}
<DropdownButton.StyledDropdownTopOptionAngleDown />
</DropdownButton.StyledDropdownTopOption>
<DropdownButton.StyledSearchField key={'search-filter'}>
{['text', 'relation'].includes(selectedFilter.type) && (
<input
type="text"
placeholder={selectedFilter.label}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
if (
selectedFilter.type === 'relation' &&
selectedFilter.searchConfig
) {
setFilterSearch(selectedFilter.searchConfig);
setSearchInput(event.target.value);
}
if (selectedFilter.type === 'text') {
if (event.target.value === '') {
onFilterRemove(selectedFilter.key);
} else {
onFilterSelect({
key: selectedFilter.key,
label: selectedFilter.label,
value: event.target.value,
displayValue: event.target.value,
icon: selectedFilter.icon,
operand: selectedFilterOperand,
});
}
}
}}
/>
)}
{selectedFilter.type === 'date' && (
<DatePicker
date={new Date()}
onChangeHandler={(date) => {
onFilterSelect({
key: selectedFilter.key,
label: selectedFilter.label,
value: date.toISOString(),
displayValue: humanReadableDate(date),
icon: selectedFilter.icon,
operand: selectedFilterOperand,
});
}}
customInput={<></>}
customCalendarContainer={styled.div`
top: -10px;
`}
/>
)}
</DropdownButton.StyledSearchField>
{selectedFilter.type === 'relation' &&
filterSearchResults &&
renderSearchResults(
filterSearchResults,
selectedFilter,
selectedFilterOperand,
)}
</>
);
}
return (
<DropdownButton
label="Filter"
isActive={isFilterSelected}
isUnfolded={isUnfolded}
setIsUnfolded={setIsUnfolded}
resetState={resetState}
>
{selectedFilter
? isOperandSelectionUnfolded
? renderOperandSelection
: renderValueSelection(selectedFilter, selectedFilterOperand)
: renderFilterSelection}
</DropdownButton>
);
};

View File

@ -0,0 +1,94 @@
import { FaArrowDown, FaArrowUp } from 'react-icons/fa';
import styled from '@emotion/styled';
import {
FilterableFieldsType,
SelectedFilterType,
} from '@/filters-and-sorts/interfaces/filters/interface';
import { SelectedSortType } from '@/filters-and-sorts/interfaces/sorts/interface';
import SortOrFilterChip from './SortOrFilterChip';
type OwnProps<SortField, TData extends FilterableFieldsType> = {
sorts: Array<SelectedSortType<SortField>>;
onRemoveSort: (sortId: SelectedSortType<SortField>['key']) => void;
filters: Array<SelectedFilterType<TData>>;
onRemoveFilter: (filterId: SelectedFilterType<TData>['key']) => void;
onCancelClick: () => void;
};
const StyledBar = styled.div`
display: flex;
flex-direction: row;
border-top: 1px solid ${(props) => props.theme.primaryBorder};
align-items: center;
justify-content: space-between;
height: 40px;
`;
const StyledCancelButton = styled.button`
margin-left: auto;
border: none;
background-color: inherit;
padding: ${(props) => {
const horiz = props.theme.spacing(2);
const vert = props.theme.spacing(1);
return `${vert} ${horiz} ${vert} ${horiz}`;
}};
color: ${(props) => props.theme.text40};
font-weight: 500;
margin-right: ${(props) => props.theme.spacing(2)};
cursor: pointer;
user-select: none;
&:hover {
border-radius: ${(props) => props.theme.spacing(1)};
background-color: ${(props) => props.theme.tertiaryBackground};
}
`;
function SortAndFilterBar<SortField, TData extends FilterableFieldsType>({
sorts,
onRemoveSort,
filters,
onRemoveFilter,
onCancelClick,
}: OwnProps<SortField, TData>) {
return (
<StyledBar>
{sorts.map((sort) => {
return (
<SortOrFilterChip
key={sort.key}
labelValue={sort.label}
id={sort.key}
icon={sort.order === 'desc' ? <FaArrowDown /> : <FaArrowUp />}
onRemove={() => onRemoveSort(sort.key)}
/>
);
})}
{filters.map((filter) => {
return (
<SortOrFilterChip
key={filter.key}
labelKey={filter.label}
labelValue={`${filter.operand.label} ${filter.displayValue}`}
id={filter.key}
icon={filter.icon}
onRemove={() => onRemoveFilter(filter.key)}
/>
);
})}
{filters.length + sorts.length > 0 && (
<StyledCancelButton
data-testid={'cancel-button'}
onClick={onCancelClick}
>
Cancel
</StyledCancelButton>
)}
</StyledBar>
);
}
export default SortAndFilterBar;

View File

@ -0,0 +1,88 @@
import { useCallback, useState } from 'react';
import {
SelectedSortType,
SortType,
} from '@/filters-and-sorts/interfaces/sorts/interface';
import DropdownButton from './DropdownButton';
type OwnProps<SortField> = {
isSortSelected: boolean;
onSortSelect: (sort: SelectedSortType<SortField>) => void;
availableSorts: SortType<SortField>[];
};
const options: Array<SelectedSortType<any>['order']> = ['asc', 'desc'];
export function SortDropdownButton<SortField>({
isSortSelected,
availableSorts,
onSortSelect,
}: OwnProps<SortField>) {
const [isUnfolded, setIsUnfolded] = useState(false);
const [isOptionUnfolded, setIsOptionUnfolded] = useState(false);
const [selectedSortDirection, setSelectedSortDirection] =
useState<SelectedSortType<SortField>['order']>('asc');
const onSortItemSelect = useCallback(
(sort: SortType<SortField>) => {
onSortSelect({ ...sort, order: selectedSortDirection });
},
[onSortSelect, selectedSortDirection],
);
const resetState = useCallback(() => {
setIsOptionUnfolded(false);
setSelectedSortDirection('asc');
}, []);
return (
<DropdownButton
label="Sort"
isActive={isSortSelected}
isUnfolded={isUnfolded}
setIsUnfolded={setIsUnfolded}
resetState={resetState}
>
{isOptionUnfolded
? options.map((option, index) => (
<DropdownButton.StyledDropdownItem
key={index}
onClick={() => {
setSelectedSortDirection(option);
setIsOptionUnfolded(false);
}}
>
{option === 'asc' ? 'Ascending' : 'Descending'}
</DropdownButton.StyledDropdownItem>
))
: [
<DropdownButton.StyledDropdownTopOption
key={0}
onClick={() => setIsOptionUnfolded(true)}
>
{selectedSortDirection === 'asc' ? 'Ascending' : 'Descending'}
<DropdownButton.StyledDropdownTopOptionAngleDown />
</DropdownButton.StyledDropdownTopOption>,
...availableSorts.map((sort, index) => (
<DropdownButton.StyledDropdownItem
key={index + 1}
onClick={() => {
setIsUnfolded(false);
onSortItemSelect(sort);
}}
>
<DropdownButton.StyledIcon>
{sort.icon}
</DropdownButton.StyledIcon>
{sort.label}
</DropdownButton.StyledDropdownItem>
)),
]}
</DropdownButton>
);
}

View File

@ -0,0 +1,64 @@
import { ReactNode } from 'react';
import { TbX } from 'react-icons/tb';
import styled from '@emotion/styled';
type OwnProps = {
id: string;
labelKey?: string;
labelValue: string;
icon: ReactNode;
onRemove: () => void;
};
const StyledChip = styled.div`
border-radius: 50px;
display: flex;
flex-direction: row;
background-color: ${(props) => props.theme.blueHighTransparency};
border: 1px solid ${(props) => props.theme.blueLowTransparency};
color: ${(props) => props.theme.blue};
padding: ${(props) => props.theme.spacing(1) + ' ' + props.theme.spacing(2)};
margin-left: ${(props) => props.theme.spacing(2)};
font-size: ${(props) => props.theme.fontSizeSmall};
align-items: center;
`;
const StyledIcon = styled.div`
margin-right: ${(props) => props.theme.spacing(1)};
display: flex;
align-items: center;
`;
const StyledDelete = styled.div`
margin-left: ${(props) => props.theme.spacing(2)};
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
font-size: ${(props) => props.theme.fontSizeSmall};
margin-top: 1px;
`;
const StyledLabelKey = styled.div`
font-weight: 500;
`;
function SortOrFilterChip({
id,
labelKey,
labelValue,
icon,
onRemove,
}: OwnProps) {
return (
<StyledChip>
<StyledIcon>{icon}</StyledIcon>
{labelKey && <StyledLabelKey>{labelKey}:&nbsp;</StyledLabelKey>}
{labelValue}
<StyledDelete onClick={onRemove} data-testid={'remove-icon-' + id}>
<TbX />
</StyledDelete>
</StyledChip>
);
}
export default SortOrFilterChip;

View File

@ -0,0 +1,168 @@
import { ReactNode, useCallback, useState } from 'react';
import styled from '@emotion/styled';
import {
FilterableFieldsType,
FilterConfigType,
SelectedFilterType,
} from '@/filters-and-sorts/interfaces/filters/interface';
import {
SelectedSortType,
SortType,
} from '@/filters-and-sorts/interfaces/sorts/interface';
import { FilterDropdownButton } from './FilterDropdownButton';
import SortAndFilterBar from './SortAndFilterBar';
import { SortDropdownButton } from './SortDropdownButton';
type OwnProps<SortField, TData extends FilterableFieldsType> = {
viewName: string;
viewIcon?: ReactNode;
availableSorts?: Array<SortType<SortField>>;
availableFilters?: FilterConfigType<TData>[];
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onFiltersUpdate?: (sorts: Array<SelectedFilterType<TData>>) => void;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledTableHeader = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 40px;
color: ${(props) => props.theme.text60};
font-weight: 500;
padding-left: ${(props) => props.theme.spacing(3)};
padding-right: ${(props) => props.theme.spacing(1)};
`;
const StyledIcon = styled.div`
display: flex;
margin-right: ${(props) => props.theme.spacing(2)};
& > svg {
font-size: ${(props) => props.theme.fontSizeLarge};
}
`;
const StyledViewSection = styled.div`
display: flex;
`;
const StyledFilters = styled.div`
display: flex;
font-weight: 400;
margin-right: ${(props) => props.theme.spacing(2)};
`;
export function TableHeader<SortField, TData extends FilterableFieldsType>({
viewName,
viewIcon,
availableSorts,
availableFilters,
onSortsUpdate,
onFiltersUpdate,
}: OwnProps<SortField, TData>) {
const [sorts, innerSetSorts] = useState<Array<SelectedSortType<SortField>>>(
[],
);
const [filters, innerSetFilters] = useState<Array<SelectedFilterType<TData>>>(
[],
);
const sortSelect = useCallback(
(newSort: SelectedSortType<SortField>) => {
const newSorts = updateSortOrFilterByKey(sorts, newSort);
innerSetSorts(newSorts);
onSortsUpdate && onSortsUpdate(newSorts);
},
[onSortsUpdate, sorts],
);
const sortUnselect = useCallback(
(sortKey: string) => {
const newSorts = sorts.filter((sort) => sort.key !== sortKey);
innerSetSorts(newSorts);
onSortsUpdate && onSortsUpdate(newSorts);
},
[onSortsUpdate, sorts],
);
const filterSelect = useCallback(
(filter: SelectedFilterType<TData>) => {
const newFilters = updateSortOrFilterByKey(filters, filter);
innerSetFilters(newFilters);
onFiltersUpdate && onFiltersUpdate(newFilters);
},
[onFiltersUpdate, filters],
);
const filterUnselect = useCallback(
(filterId: SelectedFilterType<TData>['key']) => {
const newFilters = filters.filter((filter) => filter.key !== filterId);
innerSetFilters(newFilters);
onFiltersUpdate && onFiltersUpdate(newFilters);
},
[onFiltersUpdate, filters],
);
return (
<StyledContainer>
<StyledTableHeader>
<StyledViewSection>
<StyledIcon>{viewIcon}</StyledIcon>
{viewName}
</StyledViewSection>
<StyledFilters>
<FilterDropdownButton
isFilterSelected={filters.length > 0}
availableFilters={availableFilters || []}
onFilterSelect={filterSelect}
onFilterRemove={filterUnselect}
/>
<SortDropdownButton<SortField>
isSortSelected={sorts.length > 0}
availableSorts={availableSorts || []}
onSortSelect={sortSelect}
/>
</StyledFilters>
</StyledTableHeader>
{sorts.length + filters.length > 0 && (
<SortAndFilterBar
sorts={sorts}
filters={filters}
onRemoveSort={sortUnselect}
onRemoveFilter={filterUnselect}
onCancelClick={() => {
innerSetFilters([]);
onFiltersUpdate && onFiltersUpdate([]);
innerSetSorts([]);
onSortsUpdate && onSortsUpdate([]);
}}
/>
)}
</StyledContainer>
);
}
function updateSortOrFilterByKey<SortOrFilter extends { key: string }>(
sorts: Readonly<SortOrFilter[]>,
newSort: SortOrFilter,
): SortOrFilter[] {
const newSorts = [...sorts];
const existingSortIndex = sorts.findIndex((sort) => sort.key === newSort.key);
if (existingSortIndex !== -1) {
newSorts[existingSortIndex] = newSort;
} else {
newSorts.push(newSort);
}
return newSorts;
}