Reorganize frontend and install Craco to alias modules (#190)
This commit is contained in:
36
front/src/modules/ui/components/buttons/IconButton.tsx
Normal file
36
front/src/modules/ui/components/buttons/IconButton.tsx
Normal 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>;
|
||||
}
|
||||
@ -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;
|
||||
`;
|
||||
@ -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}
|
||||
`;
|
||||
@ -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)};
|
||||
`;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
`;
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
`;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
`;
|
||||
79
front/src/modules/ui/components/form/Checkbox.tsx
Normal file
79
front/src/modules/ui/components/form/Checkbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
front/src/modules/ui/components/form/DatePicker.tsx
Normal file
260
front/src/modules/ui/components/form/DatePicker.tsx
Normal 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;
|
||||
62
front/src/modules/ui/components/inputs/DoubleTextInput.tsx
Normal file
62
front/src/modules/ui/components/inputs/DoubleTextInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
front/src/modules/ui/components/link/Link.tsx
Normal file
30
front/src/modules/ui/components/link/Link.tsx
Normal 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;
|
||||
66
front/src/modules/ui/components/table/CheckboxCell.tsx
Normal file
66
front/src/modules/ui/components/table/CheckboxCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
front/src/modules/ui/components/table/ColumnHead.tsx
Normal file
30
front/src/modules/ui/components/table/ColumnHead.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
front/src/modules/ui/components/table/EntityTable.tsx
Normal file
180
front/src/modules/ui/components/table/EntityTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
front/src/modules/ui/components/table/SelectAllCheckbox.tsx
Normal file
18
front/src/modules/ui/components/table/SelectAllCheckbox.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}: </StyledLabelKey>}
|
||||
{labelValue}
|
||||
<StyledDelete onClick={onRemove} data-testid={'remove-icon-' + id}>
|
||||
<TbX />
|
||||
</StyledDelete>
|
||||
</StyledChip>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortOrFilterChip;
|
||||
@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user