Uniformize folder structure (#693)

* Uniformize folder structure

* Fix icons

* Fix icons

* Fix tests

* Fix tests
This commit is contained in:
Charles Bochet
2023-07-16 14:29:28 -07:00
committed by GitHub
parent 900ec5572f
commit 6ced8434bd
462 changed files with 931 additions and 960 deletions

View File

@ -0,0 +1,89 @@
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 { DropdownMenuItemContainer } from '@/ui/dropdown/components/DropdownMenuItemContainer';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import { Avatar } from '@/users/components/Avatar';
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,
onItemCheckChange,
onSearchFilterChange,
searchFilter,
}: {
entities: EntitiesForMultipleEntitySelect<CustomEntityForSelect>;
searchFilter: string;
onSearchFilterChange: (newSearchFilter: string) => void;
onItemCheckChange: (
newCheckedValue: boolean,
entity: CustomEntityForSelect,
) => void;
}) {
const debouncedSetSearchFilter = debounce(onSearchFilterChange, 100, {
leading: true,
});
function handleFilterChange(event: React.ChangeEvent<HTMLInputElement>) {
debouncedSetSearchFilter(event.currentTarget.value);
onSearchFilterChange(event.currentTarget.value);
}
const entitiesInDropdown = [
...(entities.filteredSelectedEntities ?? []),
...(entities.entitiesToSelect ?? []),
];
return (
<DropdownMenu>
<DropdownMenuSearch
value={searchFilter}
onChange={handleFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemContainer>
{entitiesInDropdown?.map((entity) => (
<DropdownMenuCheckableItem
key={entity.id}
checked={
entities.selectedEntities
?.map((selectedEntity) => selectedEntity.id)
?.includes(entity.id) ?? false
}
onChange={(newCheckedValue) =>
onItemCheckChange(newCheckedValue, entity)
}
>
<Avatar
avatarUrl={entity.avatarUrl}
colorId={entity.id}
placeholder={entity.name}
size={16}
type={entity.avatarType ?? 'rounded'}
/>
{entity.name}
</DropdownMenuCheckableItem>
))}
{entitiesInDropdown?.length === 0 && (
<DropdownMenuItem>No result</DropdownMenuItem>
)}
</DropdownMenuItemContainer>
</DropdownMenu>
);
}

View File

@ -0,0 +1,79 @@
import { useRef } from 'react';
import { useTheme } from '@emotion/react';
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
import { DropdownMenuButton } from '@/ui/dropdown/components/DropdownMenuButton';
import { DropdownMenuItemContainer } from '@/ui/dropdown/components/DropdownMenuItemContainer';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
import { IconPlus } from '@/ui/icon';
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) => void;
disableBackgroundBlur?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
const { searchFilter, handleSearchFilterChange } = useEntitySelectSearch();
const showCreateButton = isDefined(onCreate) && searchFilter !== '';
useListenClickOutsideArrayOfRef([containerRef], () => {
onCancel?.();
});
return (
<DropdownMenu disableBlur={disableBackgroundBlur} ref={containerRef}>
<DropdownMenuSearch
value={searchFilter}
onChange={handleSearchFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
{showCreateButton && (
<>
<DropdownMenuItemContainer style={{ maxHeight: 180 }}>
<DropdownMenuButton onClick={onCreate}>
<IconPlus size={theme.icon.size.md} />
Create new
</DropdownMenuButton>
</DropdownMenuItemContainer>
<DropdownMenuSeparator />
</>
)}
<SingleEntitySelectBase
entities={entities}
onEntitySelected={onEntitySelected}
onCancel={onCancel}
/>
</DropdownMenu>
);
}

View File

@ -0,0 +1,95 @@
import { useRef } from 'react';
import { Key } from 'ts-key-enum';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemContainer } from '@/ui/dropdown/components/DropdownMenuItemContainer';
import { DropdownMenuSelectableItem } from '@/ui/dropdown/components/DropdownMenuSelectableItem';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { Avatar } from '@/users/components/Avatar';
import { isDefined } from '~/utils/isDefined';
import { useEntitySelectScroll } from '../hooks/useEntitySelectScroll';
import { EntityForSelect } from '../types/EntityForSelect';
import { RelationPickerHotkeyScope } from '../types/RelationPickerHotkeyScope';
import { CompanyPickerSkeleton } from './skeletons/CompanyPickerSkeleton';
import { DropdownMenuItemContainerSkeleton } from './skeletons/DropdownMenuItemContainerSkeleton';
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) => void;
onCancel?: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const entitiesInDropdown = isDefined(entities.selectedEntity)
? [entities.selectedEntity, ...(entities.entitiesToSelect ?? [])]
: entities.entitiesToSelect ?? [];
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],
);
return (
<DropdownMenuItemContainer ref={containerRef}>
{entities.loading ? (
<DropdownMenuItemContainerSkeleton>
<CompanyPickerSkeleton count={10} />
</DropdownMenuItemContainerSkeleton>
) : 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={16}
type={entity.avatarType ?? 'rounded'}
/>
{entity.name}
</DropdownMenuSelectableItem>
))
)}
</DropdownMenuItemContainer>
);
}

View File

@ -0,0 +1,35 @@
import Skeleton from 'react-loading-skeleton';
import styled from '@emotion/styled';
type OwnProps = {
count: number;
};
export const SkeletonContainer = styled.div`
align-items: center;
display: inline-flex;
margin-bottom: ${({ theme }) => theme.spacing(1)};
position: relative;
width: 100%;
`;
export const SkeletonEntityName = styled.div`
margin-left: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export function CompanyPickerSkeleton({ count }: OwnProps) {
const loadSkeletons = Array(count).fill(1);
return (
<>
{loadSkeletons.map((_, i) => (
<SkeletonContainer key={i}>
<Skeleton width={15} height={15} />
<SkeletonEntityName>
<Skeleton height={8} />
</SkeletonEntityName>
</SkeletonContainer>
))}
</>
);
}

View File

@ -0,0 +1,20 @@
import styled from '@emotion/styled';
export const DropdownMenuItemContainerSkeleton = styled.div`
--horizontal-padding: ${({ theme }) => theme.spacing(1.5)};
--vertical-padding: ${({ theme }) => theme.spacing(2)};
align-items: center;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
gap: ${({ theme }) => theme.spacing(2)};
height: calc(100% - 2 * var(--vertical-padding));
padding: var(--vertical-padding) var(--horizontal-padding);
width: calc(100% - 2 * var(--horizontal-padding));
`;

View File

@ -0,0 +1,98 @@
import scrollIntoView from 'scroll-into-view';
import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { useRecoilScopedState } from '@/ui/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/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',
}