Clean and re-organize post table refactoring (#1000)

* Clean and re-organize post table refactoring

* Fix tests
This commit is contained in:
Charles Bochet
2023-07-30 18:26:32 -07:00
committed by GitHub
parent 86a2d67efd
commit ade5e52e55
336 changed files with 638 additions and 2757 deletions

View File

@ -0,0 +1,9 @@
import { formatToHumanReadableDate } from '~/utils';
type OwnProps = {
value: Date | string | null;
};
export function DateInputDisplay({ value }: OwnProps) {
return <div>{value && formatToHumanReadableDate(value)}</div>;
}

View File

@ -0,0 +1,63 @@
import { forwardRef } from 'react';
import styled from '@emotion/styled';
import { formatToHumanReadableDate } from '~/utils';
import DatePicker from './DatePicker';
export type StyledCalendarContainerProps = {
editModeHorizontalAlign?: 'left' | 'right';
};
const StyledInputContainer = styled.div`
display: flex;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledCalendarContainer = styled.div<StyledCalendarContainerProps>`
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
margin-top: 1px;
position: absolute;
z-index: 1;
`;
type DivProps = React.HTMLProps<HTMLDivElement>;
export const DateDisplay = forwardRef<HTMLDivElement, DivProps>(
({ value, onClick }, ref) => (
<StyledInputContainer onClick={onClick} ref={ref}>
{value && formatToHumanReadableDate(new Date(value as string))}
</StyledInputContainer>
),
);
type DatePickerContainerProps = {
children: React.ReactNode;
};
export const DatePickerContainer = ({ children }: DatePickerContainerProps) => {
return <StyledCalendarContainer>{children}</StyledCalendarContainer>;
};
type OwnProps = {
value: Date | null | undefined;
onChange: (newDate: Date) => void;
};
export function DateInputEdit({ onChange, value }: OwnProps) {
return (
<DatePicker
date={value ?? new Date()}
onChangeHandler={onChange}
customInput={<DateDisplay />}
customCalendarContainer={DatePickerContainer}
/>
);
}

View File

@ -2,7 +2,7 @@ import React, { forwardRef, ReactElement, useState } from 'react';
import ReactDatePicker, { CalendarContainerProps } from 'react-datepicker';
import styled from '@emotion/styled';
import { overlayBackground } from '@/ui/themes/effects';
import { overlayBackground } from '@/ui/theme/constants/effects';
import 'react-datepicker/dist/react-datepicker.css';

View File

@ -0,0 +1,56 @@
import { ChangeEvent } from 'react';
import styled from '@emotion/styled';
import { StyledInput } from '@/ui/table/editable-cell/type/components/TextCellEdit';
type OwnProps = {
firstValue: string;
secondValue: string;
firstValuePlaceholder: string;
secondValuePlaceholder: string;
onChange: (firstValue: string, secondValue: string) => void;
};
export const StyledDoubleTextContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
text-align: center;
`;
const StyledNameInput = styled(StyledInput)`
padding: 0;
text-align: center;
width: auto;
`;
export function DoubleTextInputEdit({
firstValue,
secondValue,
firstValuePlaceholder,
secondValuePlaceholder,
onChange,
}: OwnProps) {
return (
<StyledDoubleTextContainer>
<StyledNameInput
size={firstValue.length}
autoFocus
placeholder={firstValuePlaceholder}
value={firstValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value, secondValue);
}}
/>
<StyledNameInput
size={secondValue.length}
autoComplete="off"
placeholder={secondValuePlaceholder}
value={secondValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(firstValue, event.target.value);
}}
/>
</StyledDoubleTextContainer>
);
}

View File

@ -0,0 +1,34 @@
import { MouseEvent } from 'react';
import styled from '@emotion/styled';
import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js';
import { RawLink } from '@/ui/link/components/RawLink';
const StyledRawLink = styled(RawLink)`
overflow: hidden;
a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
type OwnProps = {
value: string | null;
};
export function PhoneInputDisplay({ value }: OwnProps) {
return value && isValidPhoneNumber(value) ? (
<StyledRawLink
href={parsePhoneNumber(value, 'FR')?.getURI()}
onClick={(event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
}}
>
{parsePhoneNumber(value, 'FR')?.formatInternational() || value}
</StyledRawLink>
) : (
<StyledRawLink href="#">{value}</StyledRawLink>
);
}

View File

@ -0,0 +1,107 @@
import { useRef } from 'react';
import debounce from 'lodash.debounce';
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
import { DropdownMenuCheckableItem } from '@/ui/dropdown/components/DropdownMenuCheckableItem';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import { useListenClickOutside } from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { Avatar } from '@/users/components/Avatar';
import { isNonEmptyString } from '~/utils/isNonEmptyString';
import { EntityForSelect } from '../types/EntityForSelect';
export type EntitiesForMultipleEntitySelect<
CustomEntityForSelect extends EntityForSelect,
> = {
selectedEntities: CustomEntityForSelect[];
filteredSelectedEntities: CustomEntityForSelect[];
entitiesToSelect: CustomEntityForSelect[];
loading: boolean;
};
export function MultipleEntitySelect<
CustomEntityForSelect extends EntityForSelect,
>({
entities,
onChange,
onSubmit,
onSearchFilterChange,
searchFilter,
value,
}: {
entities: EntitiesForMultipleEntitySelect<CustomEntityForSelect>;
searchFilter: string;
onSearchFilterChange: (newSearchFilter: string) => void;
onChange: (value: Record<string, boolean>) => void;
onCancel?: () => void;
onSubmit?: () => void;
value: Record<string, boolean>;
}) {
const debouncedSetSearchFilter = debounce(onSearchFilterChange, 100, {
leading: true,
});
function handleFilterChange(event: React.ChangeEvent<HTMLInputElement>) {
debouncedSetSearchFilter(event.currentTarget.value);
onSearchFilterChange(event.currentTarget.value);
}
let entitiesInDropdown = [
...(entities.filteredSelectedEntities ?? []),
...(entities.entitiesToSelect ?? []),
];
entitiesInDropdown = entitiesInDropdown.filter((entity) =>
isNonEmptyString(entity.name),
);
const containerRef = useRef<HTMLDivElement>(null);
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
event.stopPropagation();
event.preventDefault();
onSubmit?.();
},
});
return (
<DropdownMenu ref={containerRef}>
<DropdownMenuSearch
value={searchFilter}
onChange={handleFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{entitiesInDropdown?.map((entity) => (
<DropdownMenuCheckableItem
key={entity.id}
checked={value[entity.id]}
onChange={(newCheckedValue) =>
onChange({ ...value, [entity.id]: newCheckedValue })
}
>
<Avatar
avatarUrl={entity.avatarUrl}
colorId={entity.id}
placeholder={entity.name}
size="md"
type={entity.avatarType ?? 'rounded'}
/>
{entity.name}
</DropdownMenuCheckableItem>
))}
{entitiesInDropdown?.length === 0 && (
<DropdownMenuItem>No result</DropdownMenuItem>
)}
</DropdownMenuItemsContainer>
</DropdownMenu>
);
}

View File

@ -0,0 +1,86 @@
import { useRef } from 'react';
import { useTheme } from '@emotion/react';
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import { IconPlus } from '@/ui/icon';
import { useListenClickOutside } from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
import { useEntitySelectSearch } from '../hooks/useEntitySelectSearch';
import { EntityForSelect } from '../types/EntityForSelect';
import { SingleEntitySelectBase } from './SingleEntitySelectBase';
export type EntitiesForSingleEntitySelect<
CustomEntityForSelect extends EntityForSelect,
> = {
loading: boolean;
selectedEntity: CustomEntityForSelect;
entitiesToSelect: CustomEntityForSelect[];
};
export function SingleEntitySelect<
CustomEntityForSelect extends EntityForSelect,
>({
entities,
onEntitySelected,
onCreate,
onCancel,
disableBackgroundBlur = false,
}: {
onCancel?: () => void;
onCreate?: () => void;
entities: EntitiesForSingleEntitySelect<CustomEntityForSelect>;
onEntitySelected: (entity: CustomEntityForSelect | null | undefined) => void;
disableBackgroundBlur?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
const { searchFilter, handleSearchFilterChange } = useEntitySelectSearch();
const showCreateButton = isDefined(onCreate) && searchFilter !== '';
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
event.stopPropagation();
event.preventDefault();
onCancel?.();
},
});
return (
<DropdownMenu disableBlur={disableBackgroundBlur} ref={containerRef}>
<DropdownMenuSearch
value={searchFilter}
onChange={handleSearchFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
{showCreateButton && (
<>
<DropdownMenuItemsContainer hasMaxHeight>
<DropdownMenuItem onClick={onCreate}>
<IconPlus size={theme.icon.size.md} />
Create new
</DropdownMenuItem>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
</>
)}
<SingleEntitySelectBase
entities={entities}
onEntitySelected={onEntitySelected}
onCancel={onCancel}
/>
</DropdownMenu>
);
}

View File

@ -0,0 +1,102 @@
import { useRef } from 'react';
import { Key } from 'ts-key-enum';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSelectableItem } from '@/ui/dropdown/components/DropdownMenuSelectableItem';
import { OverflowingTextWithTooltip } from '@/ui/tooltip/OverflowingTextWithTooltip';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { Avatar } from '@/users/components/Avatar';
import { isDefined } from '~/utils/isDefined';
import { isNonEmptyString } from '~/utils/isNonEmptyString';
import { useEntitySelectScroll } from '../hooks/useEntitySelectScroll';
import { EntityForSelect } from '../types/EntityForSelect';
import { RelationPickerHotkeyScope } from '../types/RelationPickerHotkeyScope';
import { DropdownMenuSkeletonItem } from './skeletons/DropdownMenuSkeletonItem';
export type EntitiesForSingleEntitySelect<
CustomEntityForSelect extends EntityForSelect,
> = {
selectedEntity: CustomEntityForSelect;
entitiesToSelect: CustomEntityForSelect[];
loading: boolean;
};
export function SingleEntitySelectBase<
CustomEntityForSelect extends EntityForSelect,
>({
entities,
onEntitySelected,
onCancel,
}: {
entities: EntitiesForSingleEntitySelect<CustomEntityForSelect>;
onEntitySelected: (entity: CustomEntityForSelect | null | undefined) => void;
onCancel?: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
let entitiesInDropdown = isDefined(entities.selectedEntity)
? [entities.selectedEntity, ...(entities.entitiesToSelect ?? [])]
: entities.entitiesToSelect ?? [];
entitiesInDropdown = entitiesInDropdown.filter((entity) =>
isNonEmptyString(entity.name),
);
const { hoveredIndex, resetScroll } = useEntitySelectScroll({
entities: entitiesInDropdown,
containerRef,
});
useScopedHotkeys(
Key.Enter,
() => {
onEntitySelected(entitiesInDropdown[hoveredIndex]);
resetScroll();
},
RelationPickerHotkeyScope.RelationPicker,
[entitiesInDropdown, hoveredIndex, onEntitySelected],
);
useScopedHotkeys(
Key.Escape,
() => {
onCancel?.();
},
RelationPickerHotkeyScope.RelationPicker,
[onCancel],
);
entitiesInDropdown = entitiesInDropdown.filter((entity) =>
isNonEmptyString(entity.name.trim()),
);
return (
<DropdownMenuItemsContainer ref={containerRef} hasMaxHeight>
{entities.loading ? (
<DropdownMenuSkeletonItem />
) : entitiesInDropdown.length === 0 ? (
<DropdownMenuItem>No result</DropdownMenuItem>
) : (
entitiesInDropdown?.map((entity, index) => (
<DropdownMenuSelectableItem
key={entity.id}
selected={entities.selectedEntity?.id === entity.id}
hovered={hoveredIndex === index}
onClick={() => onEntitySelected(entity)}
>
<Avatar
avatarUrl={entity.avatarUrl}
colorId={entity.id}
placeholder={entity.name}
size="md"
type={entity.avatarType ?? 'rounded'}
/>
<OverflowingTextWithTooltip text={entity.name} />
</DropdownMenuSelectableItem>
))
)}
</DropdownMenuItemsContainer>
);
}

View File

@ -0,0 +1,28 @@
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
const StyledDropdownMenuSkeletonContainer = styled.div`
--horizontal-padding: ${({ theme }) => theme.spacing(1)};
--vertical-padding: ${({ theme }) => theme.spacing(2)};
align-items: center;
border-radius: ${({ theme }) => theme.border.radius.sm};
gap: ${({ theme }) => theme.spacing(2)};
height: calc(32px - 2 * var(--vertical-padding));
padding: var(--vertical-padding) var(--horizontal-padding);
width: calc(100% - 2 * var(--horizontal-padding));
`;
export function DropdownMenuSkeletonItem() {
const theme = useTheme();
return (
<StyledDropdownMenuSkeletonContainer>
<SkeletonTheme
baseColor="transparent"
highlightColor={theme.background.tertiary}
>
<Skeleton height={16} />
</SkeletonTheme>
</StyledDropdownMenuSkeletonContainer>
);
}

View File

@ -0,0 +1,98 @@
import scrollIntoView from 'scroll-into-view';
import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { relationPickerHoverIndexScopedState } from '../states/relationPickerHoverIndexScopedState';
import { EntityForSelect } from '../types/EntityForSelect';
import { RelationPickerHotkeyScope } from '../types/RelationPickerHotkeyScope';
export function useEntitySelectScroll<
CustomEntityForSelect extends EntityForSelect,
>({
containerRef,
entities,
}: {
entities: CustomEntityForSelect[];
containerRef: React.RefObject<HTMLDivElement>;
}) {
const [hoveredIndex, setHoveredIndex] = useRecoilScopedState(
relationPickerHoverIndexScopedState,
);
function resetScroll() {
setHoveredIndex(0);
const currentHoveredRef = containerRef.current?.children[0] as HTMLElement;
scrollIntoView(currentHoveredRef, {
align: {
top: 0,
},
isScrollable: (target) => {
return target === containerRef.current;
},
time: 0,
});
}
useScopedHotkeys(
Key.ArrowUp,
() => {
setHoveredIndex((prevSelectedIndex) =>
Math.max(prevSelectedIndex - 1, 0),
);
const currentHoveredRef = containerRef.current?.children[
hoveredIndex
] as HTMLElement;
if (currentHoveredRef) {
scrollIntoView(currentHoveredRef, {
align: {
top: 0.5,
},
isScrollable: (target) => {
return target === containerRef.current;
},
time: 0,
});
}
},
RelationPickerHotkeyScope.RelationPicker,
[setHoveredIndex, entities],
);
useScopedHotkeys(
Key.ArrowDown,
() => {
setHoveredIndex((prevSelectedIndex) =>
Math.min(prevSelectedIndex + 1, (entities?.length ?? 0) - 1),
);
const currentHoveredRef = containerRef.current?.children[
hoveredIndex
] as HTMLElement;
if (currentHoveredRef) {
scrollIntoView(currentHoveredRef, {
align: {
top: 0.15,
},
isScrollable: (target) => {
return target === containerRef.current;
},
time: 0,
});
}
},
RelationPickerHotkeyScope.RelationPicker,
[setHoveredIndex, entities],
);
return {
hoveredIndex,
resetScroll,
};
}

View File

@ -0,0 +1,32 @@
import debounce from 'lodash.debounce';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { relationPickerHoverIndexScopedState } from '../states/relationPickerHoverIndexScopedState';
import { relationPickerSearchFilterScopedState } from '../states/relationPickerSearchFilterScopedState';
export function useEntitySelectSearch() {
const [, setHoveredIndex] = useRecoilScopedState(
relationPickerHoverIndexScopedState,
);
const [searchFilter, setSearchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
);
const debouncedSetSearchFilter = debounce(setSearchFilter, 100, {
leading: true,
});
function handleSearchFilterChange(
event: React.ChangeEvent<HTMLInputElement>,
) {
debouncedSetSearchFilter(event.currentTarget.value);
setHoveredIndex(0);
}
return {
searchFilter,
handleSearchFilterChange,
};
}

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const relationPickerHoverIndexScopedState = atomFamily<number, string>({
key: 'relationPickerHoverIndexScopedState',
default: 0,
});

View File

@ -0,0 +1,8 @@
import { atomFamily } from 'recoil';
export const relationPickerSearchFilterScopedState = atomFamily<string, string>(
{
key: 'relationPickerSearchFilterScopedState',
default: '',
},
);

View File

@ -0,0 +1,11 @@
import { AvatarType } from '@/users/components/Avatar';
import { EntityTypeForSelect } from './EntityTypeForSelect';
export type EntityForSelect = {
id: string;
entityType: EntityTypeForSelect;
name: string;
avatarUrl?: string;
avatarType?: AvatarType;
};

View File

@ -0,0 +1,12 @@
import { CommentableType, PipelineProgressableType } from '~/generated/graphql';
export enum Entity {
Company = 'Company',
Person = 'Person',
User = 'User',
}
export type EntityTypeForSelect =
| CommentableType
| PipelineProgressableType
| Entity;

View File

@ -0,0 +1,3 @@
export enum RelationPickerHotkeyScope {
RelationPicker = 'relation-picker',
}

View File

@ -9,10 +9,10 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { usePreviousHotkeyScope } from '@/ui/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { IconAlertCircle } from '@/ui/icon';
import { IconEye, IconEyeOff } from '@/ui/icon/index';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { InputHotkeyScope } from '../types/InputHotkeyScope';

View File

@ -0,0 +1,17 @@
import styled from '@emotion/styled';
import { overlayBackground } from '@/ui/theme/constants/effects';
export const TextInputContainer = styled.div`
align-items: center;
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
margin-left: -1px;
min-height: 32px;
width: inherit;
${overlayBackground}
z-index: 10;
`;

View File

@ -0,0 +1,8 @@
import styled from '@emotion/styled';
export const TextInputDisplay = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
`;

View File

@ -0,0 +1,37 @@
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/theme/constants/effects';
import { TextInputContainer } from './TextInputContainer';
export const InplaceInputTextInput = styled.input`
margin: 0;
width: 100%;
${textInputStyle}
`;
type OwnProps = {
placeholder?: string;
value?: string;
onChange?: (newValue: string) => void;
autoFocus?: boolean;
};
export function TextInputEdit({
placeholder,
value,
onChange,
autoFocus,
}: OwnProps) {
return (
<TextInputContainer>
<InplaceInputTextInput
autoComplete="off"
autoFocus={autoFocus}
placeholder={placeholder}
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
</TextInputContainer>
);
}

View File

@ -0,0 +1,36 @@
import { MouseEvent } from 'react';
import styled from '@emotion/styled';
import { RawLink } from '@/ui/link/components/RawLink';
const StyledRawLink = styled(RawLink)`
overflow: hidden;
a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
type OwnProps = {
value: string;
};
export function InplaceInputURLDisplayMode({ value }: OwnProps) {
function handleClick(event: MouseEvent<HTMLElement>) {
event.stopPropagation();
}
const absoluteUrl = value
? value.startsWith('http')
? value
: 'https://' + value
: '';
return (
<StyledRawLink href={absoluteUrl} onClick={handleClick}>
{value}
</StyledRawLink>
);
}