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,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>
);
}