Migrate to a monorepo structure (#2909)
This commit is contained in:
@ -0,0 +1,106 @@
|
||||
import { useRef } from 'react';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import debounce from 'lodash.debounce';
|
||||
|
||||
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
|
||||
import { MenuItemMultiSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
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 const 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,
|
||||
});
|
||||
|
||||
const 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} data-select-disable>
|
||||
<DropdownMenuSearchInput
|
||||
value={searchFilter}
|
||||
onChange={handleFilterChange}
|
||||
autoFocus
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
{entitiesInDropdown?.map((entity) => (
|
||||
<MenuItemMultiSelectAvatar
|
||||
key={entity.id}
|
||||
selected={value[entity.id]}
|
||||
onSelectChange={(newCheckedValue) =>
|
||||
onChange({ ...value, [entity.id]: newCheckedValue })
|
||||
}
|
||||
avatar={
|
||||
<Avatar
|
||||
avatarUrl={entity.avatarUrl}
|
||||
colorId={entity.id}
|
||||
placeholder={entity.name}
|
||||
size="md"
|
||||
type={entity.avatarType ?? 'rounded'}
|
||||
/>
|
||||
}
|
||||
text={entity.name}
|
||||
/>
|
||||
))}
|
||||
{entitiesInDropdown?.length === 0 && <MenuItem text="No result" />}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,95 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@apollo/client';
|
||||
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
|
||||
import { FieldDefinition } from '@/object-record/field/types/FieldDefinition';
|
||||
import { FieldRelationMetadata } from '@/object-record/field/types/FieldMetadata';
|
||||
import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect';
|
||||
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
|
||||
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
|
||||
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
|
||||
import { IconForbid } from '@/ui/display/icon';
|
||||
|
||||
export type RelationPickerProps = {
|
||||
recordId?: string;
|
||||
onSubmit: (newUser: EntityForSelect | null) => void;
|
||||
onCancel?: () => void;
|
||||
width?: number;
|
||||
excludeRecordIds?: string[];
|
||||
initialSearchFilter?: string | null;
|
||||
fieldDefinition: FieldDefinition<FieldRelationMetadata>;
|
||||
};
|
||||
|
||||
export const RelationPicker = ({
|
||||
recordId,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
excludeRecordIds,
|
||||
width,
|
||||
initialSearchFilter,
|
||||
fieldDefinition,
|
||||
}: RelationPickerProps) => {
|
||||
const { relationPickerSearchFilter, setRelationPickerSearchFilter } =
|
||||
useRelationPicker();
|
||||
|
||||
useEffect(() => {
|
||||
setRelationPickerSearchFilter(initialSearchFilter ?? '');
|
||||
}, [initialSearchFilter, setRelationPickerSearchFilter]);
|
||||
|
||||
// TODO: refactor useFilteredSearchEntityQuery
|
||||
const { findManyRecordsQuery } = useObjectMetadataItem({
|
||||
objectNameSingular:
|
||||
fieldDefinition.metadata.relationObjectMetadataNameSingular,
|
||||
});
|
||||
|
||||
const useFindManyQuery = (options: any) =>
|
||||
useQuery(findManyRecordsQuery, options);
|
||||
|
||||
const { identifiersMapper, searchQuery } = useRelationPicker();
|
||||
|
||||
const { objectNameSingular: relationObjectNameSingular } =
|
||||
useObjectNameSingularFromPlural({
|
||||
objectNamePlural:
|
||||
fieldDefinition.metadata.relationObjectMetadataNamePlural,
|
||||
});
|
||||
|
||||
const records = useFilteredSearchEntityQuery({
|
||||
queryHook: useFindManyQuery,
|
||||
filters: [
|
||||
{
|
||||
fieldNames:
|
||||
searchQuery?.computeFilterFields?.(
|
||||
fieldDefinition.metadata.relationObjectMetadataNameSingular,
|
||||
) ?? [],
|
||||
filter: relationPickerSearchFilter,
|
||||
},
|
||||
],
|
||||
orderByField: 'createdAt',
|
||||
mappingFunction: (record: any) =>
|
||||
identifiersMapper?.(
|
||||
record,
|
||||
fieldDefinition.metadata.relationObjectMetadataNameSingular,
|
||||
),
|
||||
selectedIds: recordId ? [recordId] : [],
|
||||
excludeEntityIds: excludeRecordIds,
|
||||
objectNameSingular: relationObjectNameSingular,
|
||||
});
|
||||
|
||||
const handleEntitySelected = async (selectedUser: any | null | undefined) => {
|
||||
onSubmit(selectedUser ?? null);
|
||||
};
|
||||
|
||||
return (
|
||||
<SingleEntitySelect
|
||||
EmptyIcon={IconForbid}
|
||||
emptyLabel={'No ' + fieldDefinition.label}
|
||||
entitiesToSelect={records.entitiesToSelect}
|
||||
loading={records.loading}
|
||||
onCancel={onCancel}
|
||||
onEntitySelected={handleEntitySelected}
|
||||
selectedEntity={records.selectedEntities[0]}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,91 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
import { useEntitySelectSearch } from '../hooks/useEntitySelectSearch';
|
||||
import { EntityForSelect } from '../types/EntityForSelect';
|
||||
|
||||
import {
|
||||
SingleEntitySelectBase,
|
||||
SingleEntitySelectBaseProps,
|
||||
} from './SingleEntitySelectBase';
|
||||
|
||||
export type SingleEntitySelectProps<
|
||||
CustomEntityForSelect extends EntityForSelect,
|
||||
> = {
|
||||
disableBackgroundBlur?: boolean;
|
||||
onCreate?: () => void;
|
||||
width?: number;
|
||||
} & Pick<
|
||||
SingleEntitySelectBaseProps<CustomEntityForSelect>,
|
||||
| 'EmptyIcon'
|
||||
| 'emptyLabel'
|
||||
| 'entitiesToSelect'
|
||||
| 'loading'
|
||||
| 'onCancel'
|
||||
| 'onEntitySelected'
|
||||
| 'selectedEntity'
|
||||
>;
|
||||
|
||||
export const SingleEntitySelect = <
|
||||
CustomEntityForSelect extends EntityForSelect,
|
||||
>({
|
||||
EmptyIcon,
|
||||
disableBackgroundBlur = false,
|
||||
emptyLabel,
|
||||
entitiesToSelect,
|
||||
loading,
|
||||
onCancel,
|
||||
onCreate,
|
||||
onEntitySelected,
|
||||
selectedEntity,
|
||||
width = 200,
|
||||
}: SingleEntitySelectProps<CustomEntityForSelect>) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { searchFilter, handleSearchFilterChange } = useEntitySelectSearch();
|
||||
|
||||
const showCreateButton = isDefined(onCreate) && searchFilter !== '';
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [containerRef],
|
||||
callback: (event) => {
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
onCancel?.();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
disableBlur={disableBackgroundBlur}
|
||||
ref={containerRef}
|
||||
width={width}
|
||||
data-select-disable
|
||||
>
|
||||
<DropdownMenuSearchInput
|
||||
value={searchFilter}
|
||||
onChange={handleSearchFilterChange}
|
||||
autoFocus
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<SingleEntitySelectBase
|
||||
{...{
|
||||
EmptyIcon,
|
||||
emptyLabel,
|
||||
entitiesToSelect,
|
||||
loading,
|
||||
onCancel,
|
||||
onCreate,
|
||||
onEntitySelected,
|
||||
selectedEntity,
|
||||
showCreateButton,
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,169 @@
|
||||
import { useRef } from 'react';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { IconPlus } from '@/ui/display/icon';
|
||||
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
|
||||
import { MenuItemSelect } from '@/ui/navigation/menu-item/components/MenuItemSelect';
|
||||
import { MenuItemSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemSelectAvatar';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import { Avatar } from '@/users/components/Avatar';
|
||||
import { assertNotNull } from '~/utils/assert';
|
||||
|
||||
import { CreateNewButton } from '../../../ui/input/relation-picker/components/CreateNewButton';
|
||||
import { DropdownMenuSkeletonItem } from '../../../ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
|
||||
import { CreateButtonId, EmptyButtonId } from '../constants';
|
||||
import { useEntitySelectScroll } from '../hooks/useEntitySelectScroll';
|
||||
import { EntityForSelect } from '../types/EntityForSelect';
|
||||
import { RelationPickerHotkeyScope } from '../types/RelationPickerHotkeyScope';
|
||||
|
||||
export type SingleEntitySelectBaseProps<
|
||||
CustomEntityForSelect extends EntityForSelect,
|
||||
> = {
|
||||
EmptyIcon?: IconComponent;
|
||||
emptyLabel?: string;
|
||||
entitiesToSelect: CustomEntityForSelect[];
|
||||
loading?: boolean;
|
||||
onCancel?: () => void;
|
||||
onEntitySelected: (entity?: CustomEntityForSelect) => void;
|
||||
selectedEntity?: CustomEntityForSelect;
|
||||
onCreate?: () => void;
|
||||
showCreateButton?: boolean;
|
||||
SelectAllIcon?: IconComponent;
|
||||
selectAllLabel?: string;
|
||||
isAllEntitySelected?: boolean;
|
||||
isAllEntitySelectShown?: boolean;
|
||||
onAllEntitySelected?: () => void;
|
||||
};
|
||||
|
||||
export const SingleEntitySelectBase = <
|
||||
CustomEntityForSelect extends EntityForSelect,
|
||||
>({
|
||||
EmptyIcon,
|
||||
emptyLabel,
|
||||
entitiesToSelect,
|
||||
loading,
|
||||
onCancel,
|
||||
onEntitySelected,
|
||||
selectedEntity,
|
||||
onCreate,
|
||||
showCreateButton,
|
||||
SelectAllIcon,
|
||||
selectAllLabel,
|
||||
isAllEntitySelected,
|
||||
isAllEntitySelectShown,
|
||||
onAllEntitySelected,
|
||||
}: SingleEntitySelectBaseProps<CustomEntityForSelect>) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const entitiesInDropdown = [selectedEntity, ...entitiesToSelect].filter(
|
||||
(entity): entity is CustomEntityForSelect =>
|
||||
assertNotNull(entity) && isNonEmptyString(entity.name),
|
||||
);
|
||||
|
||||
const { preselectedOptionId, resetScroll } = useEntitySelectScroll({
|
||||
selectableOptionIds: [
|
||||
EmptyButtonId,
|
||||
...entitiesInDropdown.map((item) => item.id),
|
||||
...(showCreateButton ? [CreateButtonId] : []),
|
||||
],
|
||||
containerRef,
|
||||
});
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.Enter,
|
||||
() => {
|
||||
if (showCreateButton && preselectedOptionId === CreateButtonId) {
|
||||
onCreate?.();
|
||||
} else {
|
||||
const entity = entitiesInDropdown.findIndex(
|
||||
(entity) => entity.id === preselectedOptionId,
|
||||
);
|
||||
onEntitySelected(entitiesInDropdown[entity]);
|
||||
}
|
||||
|
||||
resetScroll();
|
||||
},
|
||||
RelationPickerHotkeyScope.RelationPicker,
|
||||
[entitiesInDropdown, preselectedOptionId, onEntitySelected],
|
||||
);
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.Escape,
|
||||
() => {
|
||||
onCancel?.();
|
||||
},
|
||||
RelationPickerHotkeyScope.RelationPicker,
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
{loading ? (
|
||||
<DropdownMenuSkeletonItem />
|
||||
) : entitiesInDropdown.length === 0 && !isAllEntitySelectShown ? (
|
||||
<MenuItem text="No result" />
|
||||
) : (
|
||||
<>
|
||||
{isAllEntitySelectShown &&
|
||||
selectAllLabel &&
|
||||
onAllEntitySelected && (
|
||||
<MenuItemSelect
|
||||
onClick={() => onAllEntitySelected()}
|
||||
LeftIcon={SelectAllIcon}
|
||||
text={selectAllLabel}
|
||||
hovered={preselectedOptionId === EmptyButtonId}
|
||||
selected={!!isAllEntitySelected}
|
||||
/>
|
||||
)}
|
||||
{emptyLabel && (
|
||||
<MenuItemSelect
|
||||
onClick={() => onEntitySelected()}
|
||||
LeftIcon={EmptyIcon}
|
||||
text={emptyLabel}
|
||||
hovered={preselectedOptionId === EmptyButtonId}
|
||||
selected={!selectedEntity}
|
||||
/>
|
||||
)}
|
||||
{entitiesInDropdown?.map((entity) => (
|
||||
<MenuItemSelectAvatar
|
||||
key={entity.id}
|
||||
testId="menu-item"
|
||||
selected={selectedEntity?.id === entity.id}
|
||||
onClick={() => onEntitySelected(entity)}
|
||||
text={entity.name}
|
||||
hovered={preselectedOptionId === entity.id}
|
||||
avatar={
|
||||
<Avatar
|
||||
avatarUrl={entity.avatarUrl}
|
||||
colorId={entity.id}
|
||||
placeholder={entity.name}
|
||||
size="md"
|
||||
type={entity.avatarType ?? 'rounded'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
{showCreateButton && (
|
||||
<>
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
<DropdownMenuSeparator />
|
||||
<CreateNewButton
|
||||
onClick={onCreate}
|
||||
LeftIcon={IconPlus}
|
||||
text="Add New"
|
||||
hovered={preselectedOptionId === CreateButtonId}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,97 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { expect, userEvent, within } from '@storybook/test';
|
||||
|
||||
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
|
||||
import { IconUserCircle } from '@/ui/display/icon';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
|
||||
import { mockedPeopleData } from '~/testing/mock-data/people';
|
||||
import { sleep } from '~/testing/sleep';
|
||||
|
||||
import { EntityForSelect } from '../../types/EntityForSelect';
|
||||
import { SingleEntitySelect } from '../SingleEntitySelect';
|
||||
|
||||
const entities = mockedPeopleData.map<EntityForSelect>((person) => ({
|
||||
id: person.id,
|
||||
name: person.name.firstName + ' ' + person.name.lastName,
|
||||
record: person,
|
||||
}));
|
||||
|
||||
const meta: Meta<typeof SingleEntitySelect> = {
|
||||
title: 'UI/Input/RelationPicker/SingleEntitySelect',
|
||||
component: SingleEntitySelect,
|
||||
decorators: [ComponentDecorator, ComponentWithRecoilScopeDecorator],
|
||||
argTypes: {
|
||||
selectedEntity: {
|
||||
options: entities.map(({ name }) => name),
|
||||
mapping: entities.reduce(
|
||||
(result, entity) => ({ ...result, [entity.name]: entity }),
|
||||
{},
|
||||
),
|
||||
},
|
||||
},
|
||||
render: ({
|
||||
EmptyIcon,
|
||||
disableBackgroundBlur = false,
|
||||
emptyLabel,
|
||||
loading,
|
||||
onCancel,
|
||||
onCreate,
|
||||
onEntitySelected,
|
||||
selectedEntity,
|
||||
width,
|
||||
}) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { relationPickerSearchFilter } = useRelationPicker();
|
||||
|
||||
return (
|
||||
<SingleEntitySelect
|
||||
{...{
|
||||
EmptyIcon,
|
||||
disableBackgroundBlur,
|
||||
emptyLabel,
|
||||
loading,
|
||||
onCancel,
|
||||
onCreate,
|
||||
onEntitySelected,
|
||||
selectedEntity,
|
||||
width,
|
||||
}}
|
||||
entitiesToSelect={entities.filter(
|
||||
(entity) =>
|
||||
entity.id !== selectedEntity?.id &&
|
||||
entity.name.includes(relationPickerSearchFilter),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SingleEntitySelect>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithSelectedEntity: Story = {
|
||||
args: { selectedEntity: entities[2] },
|
||||
};
|
||||
|
||||
export const WithEmptyOption: Story = {
|
||||
args: {
|
||||
EmptyIcon: IconUserCircle,
|
||||
emptyLabel: 'Nobody',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSearchFilter: Story = {
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const searchInput = canvas.getByRole('textbox');
|
||||
|
||||
await step('Enter search text', async () => {
|
||||
await sleep(50);
|
||||
await userEvent.type(searchInput, 'a');
|
||||
await expect(searchInput).toHaveValue('a');
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export const CreateButtonId = 'create-button';
|
||||
export const EmptyButtonId = 'empty-button';
|
||||
@ -0,0 +1,31 @@
|
||||
import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
|
||||
import { getRelationPickerScopedStates } from '@/object-record/relation-picker/utils/getRelationPickerScopedStates';
|
||||
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
|
||||
|
||||
export const useRelationPickerScopedStates = (args?: {
|
||||
relationPickerScopedId?: string;
|
||||
}) => {
|
||||
const { relationPickerScopedId } = args ?? {};
|
||||
|
||||
const scopeId = useAvailableScopeIdOrThrow(
|
||||
RecordTableScopeInternalContext,
|
||||
relationPickerScopedId,
|
||||
);
|
||||
|
||||
const {
|
||||
identifiersMapperState,
|
||||
relationPickerSearchFilterState,
|
||||
relationPickerPreselectedIdState,
|
||||
searchQueryState,
|
||||
} = getRelationPickerScopedStates({
|
||||
relationPickerScopeId: scopeId,
|
||||
});
|
||||
|
||||
return {
|
||||
scopeId,
|
||||
identifiersMapperState,
|
||||
relationPickerSearchFilterState,
|
||||
relationPickerPreselectedIdState,
|
||||
searchQueryState,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,101 @@
|
||||
import scrollIntoView from 'scroll-into-view';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
|
||||
import { CreateButtonId } from '../constants';
|
||||
import { RelationPickerHotkeyScope } from '../types/RelationPickerHotkeyScope';
|
||||
import { getPreselectedIdIndex } from '../utils/getPreselectedIdIndex';
|
||||
|
||||
export const useEntitySelectScroll = ({
|
||||
containerRef,
|
||||
selectableOptionIds,
|
||||
}: {
|
||||
selectableOptionIds: string[];
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}) => {
|
||||
const { relationPickerPreselectedId, setRelationPickerPreselectedId } =
|
||||
useRelationPicker();
|
||||
|
||||
const preselectedIdIndex = getPreselectedIdIndex(
|
||||
selectableOptionIds,
|
||||
relationPickerPreselectedId ?? '',
|
||||
);
|
||||
|
||||
const resetScroll = () => {
|
||||
setRelationPickerPreselectedId('');
|
||||
|
||||
const preselectedRef = containerRef.current?.children[0] as HTMLElement;
|
||||
|
||||
scrollIntoView(preselectedRef, {
|
||||
align: {
|
||||
top: 0,
|
||||
},
|
||||
isScrollable: (target) => {
|
||||
return target === containerRef.current;
|
||||
},
|
||||
time: 0,
|
||||
});
|
||||
};
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.ArrowUp,
|
||||
() => {
|
||||
const previousSelectableIndex = Math.max(preselectedIdIndex - 1, 0);
|
||||
const previousSelectableId = selectableOptionIds[previousSelectableIndex];
|
||||
setRelationPickerPreselectedId(previousSelectableId);
|
||||
const preselectedRef = containerRef.current?.children[
|
||||
previousSelectableIndex
|
||||
] as HTMLElement;
|
||||
|
||||
if (preselectedRef) {
|
||||
scrollIntoView(preselectedRef, {
|
||||
align: {
|
||||
top: 0.5,
|
||||
},
|
||||
isScrollable: (target) => {
|
||||
return target === containerRef.current;
|
||||
},
|
||||
time: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
RelationPickerHotkeyScope.RelationPicker,
|
||||
[selectableOptionIds],
|
||||
);
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.ArrowDown,
|
||||
() => {
|
||||
const nextSelectableIndex = Math.min(
|
||||
preselectedIdIndex + 1,
|
||||
selectableOptionIds?.length - 1,
|
||||
);
|
||||
const nextSelectableId = selectableOptionIds[nextSelectableIndex];
|
||||
setRelationPickerPreselectedId(nextSelectableId);
|
||||
if (nextSelectableId !== CreateButtonId) {
|
||||
const preselectedRef = containerRef.current?.children[
|
||||
nextSelectableIndex
|
||||
] as HTMLElement;
|
||||
|
||||
if (preselectedRef) {
|
||||
scrollIntoView(preselectedRef, {
|
||||
align: {
|
||||
top: 0.15,
|
||||
},
|
||||
isScrollable: (target) => target === containerRef.current,
|
||||
time: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
RelationPickerHotkeyScope.RelationPicker,
|
||||
[selectableOptionIds],
|
||||
);
|
||||
|
||||
return {
|
||||
preselectedOptionId: relationPickerPreselectedId,
|
||||
resetScroll,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
import debounce from 'lodash.debounce';
|
||||
|
||||
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
|
||||
|
||||
export const useEntitySelectSearch = () => {
|
||||
const {
|
||||
setRelationPickerPreselectedId,
|
||||
relationPickerSearchFilter,
|
||||
setRelationPickerSearchFilter,
|
||||
} = useRelationPicker();
|
||||
|
||||
const debouncedSetSearchFilter = debounce(
|
||||
setRelationPickerSearchFilter,
|
||||
100,
|
||||
{
|
||||
leading: true,
|
||||
},
|
||||
);
|
||||
|
||||
const handleSearchFilterChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
debouncedSetSearchFilter(event.currentTarget.value);
|
||||
setRelationPickerPreselectedId('');
|
||||
};
|
||||
|
||||
return {
|
||||
searchFilter: relationPickerSearchFilter,
|
||||
handleSearchFilterChange,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,49 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates';
|
||||
import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext';
|
||||
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
|
||||
|
||||
type useRelationPickeProps = {
|
||||
relationPickerScopeId?: string;
|
||||
};
|
||||
|
||||
export const useRelationPicker = (props?: useRelationPickeProps) => {
|
||||
const scopeId = useAvailableScopeIdOrThrow(
|
||||
RelationPickerScopeInternalContext,
|
||||
props?.relationPickerScopeId,
|
||||
);
|
||||
|
||||
const {
|
||||
identifiersMapperState,
|
||||
searchQueryState,
|
||||
relationPickerSearchFilterState,
|
||||
relationPickerPreselectedIdState,
|
||||
} = useRelationPickerScopedStates({
|
||||
relationPickerScopedId: scopeId,
|
||||
});
|
||||
|
||||
const [identifiersMapper, setIdentifiersMapper] = useRecoilState(
|
||||
identifiersMapperState,
|
||||
);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useRecoilState(searchQueryState);
|
||||
|
||||
const [relationPickerSearchFilter, setRelationPickerSearchFilter] =
|
||||
useRecoilState(relationPickerSearchFilterState);
|
||||
|
||||
const [relationPickerPreselectedId, setRelationPickerPreselectedId] =
|
||||
useRecoilState(relationPickerPreselectedIdState);
|
||||
|
||||
return {
|
||||
scopeId,
|
||||
identifiersMapper,
|
||||
setIdentifiersMapper,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
relationPickerSearchFilter,
|
||||
setRelationPickerSearchFilter,
|
||||
relationPickerPreselectedId,
|
||||
setRelationPickerPreselectedId,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,21 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext';
|
||||
|
||||
type RelationPickerScopeProps = {
|
||||
children: ReactNode;
|
||||
relationPickerScopeId: string;
|
||||
};
|
||||
|
||||
export const RelationPickerScope = ({
|
||||
children,
|
||||
relationPickerScopeId,
|
||||
}: RelationPickerScopeProps) => {
|
||||
return (
|
||||
<RelationPickerScopeInternalContext.Provider
|
||||
value={{ scopeId: relationPickerScopeId }}
|
||||
>
|
||||
{children}
|
||||
</RelationPickerScopeInternalContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
|
||||
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
|
||||
|
||||
type RelationPickerScopeInternalContextProps = ScopedStateKey;
|
||||
|
||||
export const RelationPickerScopeInternalContext =
|
||||
createScopeInternalContext<RelationPickerScopeInternalContextProps>();
|
||||
@ -0,0 +1,8 @@
|
||||
import { IdentifiersMapper } from '@/object-record/relation-picker/types/IdentifiersMapper';
|
||||
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
|
||||
|
||||
export const identifiersMapperScopedState =
|
||||
createScopedState<IdentifiersMapper | null>({
|
||||
key: 'identifiersMapperScopedState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@ -0,0 +1,5 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const RelationPickerRecoilScopeContext = createContext<string | null>(
|
||||
'relation-picker-context',
|
||||
);
|
||||
@ -0,0 +1,8 @@
|
||||
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
|
||||
|
||||
export const relationPickerPreselectedIdScopedState = createScopedState<
|
||||
string | undefined
|
||||
>({
|
||||
key: 'relationPickerPreselectedIdScopedState',
|
||||
defaultValue: undefined,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
|
||||
|
||||
export const relationPickerSearchFilterScopedState = createScopedState<string>({
|
||||
key: 'relationPickerSearchFilterScopedState',
|
||||
defaultValue: '',
|
||||
});
|
||||
@ -0,0 +1,7 @@
|
||||
import { SearchQuery } from '@/object-record/relation-picker/types/SearchQuery';
|
||||
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
|
||||
|
||||
export const searchQueryScopedState = createScopedState<SearchQuery | null>({
|
||||
key: 'searchQueryScopedState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import { AvatarType } from '@/users/components/Avatar';
|
||||
|
||||
export type EntityForSelect = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
avatarType?: AvatarType;
|
||||
record: any;
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import { AvatarType } from '@/users/components/Avatar';
|
||||
|
||||
type RecordMappedToIdentifiers = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
avatarType: AvatarType;
|
||||
record: any;
|
||||
};
|
||||
|
||||
export type IdentifiersMapper = (
|
||||
record: any,
|
||||
relationPickerType: string,
|
||||
) => RecordMappedToIdentifiers | undefined;
|
||||
@ -0,0 +1,3 @@
|
||||
export enum RelationPickerHotkeyScope {
|
||||
RelationPicker = 'relation-picker',
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export type SearchQuery = {
|
||||
computeFilterFields: (relationPickerType: string) => string[];
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
export const getPreselectedIdIndex = (
|
||||
selectableOptionIds: string[],
|
||||
preselectedOptionId: string,
|
||||
) => {
|
||||
const preselectedIdIndex = selectableOptionIds.findIndex(
|
||||
(option) => option === preselectedOptionId,
|
||||
);
|
||||
|
||||
return preselectedIdIndex === -1 ? 0 : preselectedIdIndex;
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
import { identifiersMapperScopedState } from '@/object-record/relation-picker/states/identifiersMapperScopedState';
|
||||
import { relationPickerPreselectedIdScopedState } from '@/object-record/relation-picker/states/relationPickerPreselectedIdScopedState';
|
||||
import { relationPickerSearchFilterScopedState } from '@/object-record/relation-picker/states/relationPickerSearchFilterScopedState';
|
||||
import { searchQueryScopedState } from '@/object-record/relation-picker/states/searchQueryScopedState';
|
||||
import { getScopedState } from '@/ui/utilities/recoil-scope/utils/getScopedState';
|
||||
|
||||
export const getRelationPickerScopedStates = ({
|
||||
relationPickerScopeId,
|
||||
}: {
|
||||
relationPickerScopeId: string;
|
||||
}) => {
|
||||
const identifiersMapperState = getScopedState(
|
||||
identifiersMapperScopedState,
|
||||
relationPickerScopeId,
|
||||
);
|
||||
|
||||
const searchQueryState = getScopedState(
|
||||
searchQueryScopedState,
|
||||
relationPickerScopeId,
|
||||
);
|
||||
|
||||
const relationPickerPreselectedIdState = getScopedState(
|
||||
relationPickerPreselectedIdScopedState,
|
||||
relationPickerScopeId,
|
||||
);
|
||||
|
||||
const relationPickerSearchFilterState = getScopedState(
|
||||
relationPickerSearchFilterScopedState,
|
||||
relationPickerScopeId,
|
||||
);
|
||||
|
||||
return {
|
||||
identifiersMapperState,
|
||||
relationPickerSearchFilterState,
|
||||
relationPickerPreselectedIdState,
|
||||
searchQueryState,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user