Feat/multi relation filter (#2858)

* WIP

* Finished multi select filter

* Cleaned console log

* Fix naming

* Fixed naming
This commit is contained in:
Lucas Bordeau
2023-12-07 12:08:48 +01:00
committed by GitHub
parent b2912f4b4b
commit 06936c3c2a
18 changed files with 516 additions and 93 deletions

View File

@ -0,0 +1,85 @@
import { useEffect, useState } from 'react';
import { SelectableRecord } from '@/object-record/select/types/SelectableRecord';
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemMultiSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar';
import { Avatar } from '@/users/components/Avatar';
export const MultipleRecordSelectDropdown = ({
recordsToSelect,
loadingRecords,
filteredSelectedRecords,
onChange,
searchFilter,
}: {
recordsToSelect: SelectableRecord[];
filteredSelectedRecords: SelectableRecord[];
selectedRecords: SelectableRecord[];
searchFilter: string;
onChange: (
changedRecordToSelect: SelectableRecord,
newSelectedValue: boolean,
) => void;
loadingRecords: boolean;
}) => {
const handleRecordSelectChange = (
recordToSelect: SelectableRecord,
newSelectedValue: boolean,
) => {
onChange(
{
...recordToSelect,
isSelected: newSelectedValue,
},
newSelectedValue,
);
};
const [recordsInDropdown, setRecordInDropdown] = useState([
...(filteredSelectedRecords ?? []),
...(recordsToSelect ?? []),
]);
useEffect(() => {
if (!loadingRecords) {
setRecordInDropdown([
...(filteredSelectedRecords ?? []),
...(recordsToSelect ?? []),
]);
}
}, [recordsToSelect, filteredSelectedRecords, loadingRecords]);
const showNoResult =
recordsToSelect?.length === 0 &&
searchFilter !== '' &&
filteredSelectedRecords?.length === 0 &&
!loadingRecords;
return (
<DropdownMenuItemsContainer hasMaxHeight>
{recordsInDropdown?.map((record) => (
<MenuItemMultiSelectAvatar
key={record.id}
selected={record.isSelected}
onSelectChange={(newCheckedValue) =>
handleRecordSelectChange(record, newCheckedValue)
}
avatar={
<Avatar
avatarUrl={record.avatarUrl}
colorId={record.id}
placeholder={record.name}
size="md"
type={record.avatarType ?? 'rounded'}
/>
}
text={record.name}
/>
))}
{showNoResult && <MenuItem text="No result" />}
{loadingRecords && <DropdownMenuSkeletonItem />}
</DropdownMenuItemsContainer>
);
};

View File

@ -0,0 +1,159 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SelectableRecord } from '@/object-record/select/types/SelectableRecord';
import { getObjectFilterFields } from '@/object-record/select/utils/getObjectFilterFields';
import { getObjectOrderByField } from '@/object-record/select/utils/getObjectOrderByField';
import { isDefined } from '~/utils/isDefined';
export type OrderBy =
| 'AscNullsLast'
| 'DescNullsLast'
| 'AscNullsFirst'
| 'DescNullsFirst';
export const DEFAULT_SEARCH_REQUEST_LIMIT = 60;
export const useRecordsForSelect = ({
searchFilterText,
sortOrder = 'AscNullsLast',
selectedIds,
limit,
excludeEntityIds = [],
objectNameSingular,
}: {
searchFilterText: string;
sortOrder?: OrderBy;
selectedIds: string[];
limit?: number;
excludeEntityIds?: string[];
objectNameSingular: string;
}) => {
const { mapToObjectRecordIdentifier } = useObjectMetadataItem({
objectNameSingular,
});
const filters = [
{
fieldNames: getObjectFilterFields(objectNameSingular) ?? [],
filter: searchFilterText,
},
];
const orderByField = getObjectOrderByField(objectNameSingular);
const { loading: selectedRecordsLoading, records: selectedRecordsData } =
useFindManyRecords({
filter: {
id: {
in: selectedIds,
},
},
orderBy: {
[orderByField]: sortOrder,
},
objectNameSingular,
});
const searchFilter = filters
.map(({ fieldNames, filter }) => {
if (!isNonEmptyString(filter)) {
return undefined;
}
return {
or: fieldNames.map((fieldName) => {
const fieldNameParts = fieldName.split('.');
if (fieldNameParts.length > 1) {
// Composite field
return {
[fieldNameParts[0]]: {
[fieldNameParts[1]]: {
ilike: `%${filter}%`,
},
},
};
}
return {
[fieldName]: {
ilike: `%${filter}%`,
},
};
}),
};
})
.filter(isDefined);
const {
loading: filteredSelectedRecordsLoading,
records: filteredSelectedRecordsData,
} = useFindManyRecords({
filter: {
and: [
{
and: searchFilter,
},
{
id: {
in: selectedIds,
},
},
],
},
orderBy: {
[orderByField]: sortOrder,
},
objectNameSingular,
});
const { loading: recordsToSelectLoading, records: recordsToSelectData } =
useFindManyRecords({
filter: {
and: [
{
and: searchFilter,
},
{
not: {
id: {
in: [...selectedIds, ...excludeEntityIds],
},
},
},
],
},
limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT,
orderBy: {
[orderByField]: sortOrder,
},
objectNameSingular,
});
return {
selectedRecords: selectedRecordsData
.map(mapToObjectRecordIdentifier)
.map((record) => ({
...record,
isSelected: true,
})) as SelectableRecord[],
filteredSelectedRecords: filteredSelectedRecordsData
.map(mapToObjectRecordIdentifier)
.map((record) => ({
...record,
isSelected: true,
})) as SelectableRecord[],
recordsToSelect: recordsToSelectData
.map(mapToObjectRecordIdentifier)
.map((record) => ({
...record,
isSelected: false,
})) as SelectableRecord[],
loading:
recordsToSelectLoading ||
filteredSelectedRecordsLoading ||
selectedRecordsLoading,
};
};

View File

@ -0,0 +1,10 @@
import { AvatarType } from '@/users/components/Avatar';
export type SelectableRecord = {
id: string;
name: string;
avatarUrl?: string;
avatarType?: AvatarType;
record: any;
isSelected: boolean;
};

View File

@ -0,0 +1,11 @@
export const getObjectFilterFields = (objectSingleName: string) => {
if (objectSingleName === 'company') {
return ['name'];
}
if (['workspaceMember', 'person'].includes(objectSingleName)) {
return ['name.firstName', 'name.lastName'];
}
return ['name'];
};

View File

@ -0,0 +1,11 @@
export const getObjectOrderByField = (objectSingleName: string): string => {
if (objectSingleName === 'company') {
return 'name';
}
if (['workspaceMember', 'person'].includes(objectSingleName)) {
return 'name.firstName';
}
return 'createdAt';
};

View File

@ -0,0 +1,8 @@
import { AvatarType } from '@/users/components/Avatar';
export type ObjectRecordIdentifier = {
id: string;
name: string;
avatarUrl?: string;
avatarType?: AvatarType;
};