Feat/activities custom objects (#3213)

* WIP

* WIP - MultiObjectSearch

* WIP

* WIP

* Finished working version

* Fix

* Fixed and cleaned

* Fix

* Disabled files and emails for custom objects

* Cleaned console.log

* Fixed attachment

* Fixed

* fix lint

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2024-01-05 09:08:33 +01:00
committed by GitHub
parent c15e138d72
commit b112b74022
72 changed files with 1611 additions and 551 deletions

View File

@ -0,0 +1,28 @@
import * as React from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { EntityChip } from '@/ui/display/chip/components/EntityChip';
export type RecordChipProps = {
objectNameSingular: string;
record: ObjectRecord;
};
export const RecordChip = ({ objectNameSingular, record }: RecordChipProps) => {
const { mapToObjectRecordIdentifier } = useObjectMetadataItem({
objectNameSingular,
});
const objectRecordIdentifier = mapToObjectRecordIdentifier(record);
return (
<EntityChip
entityId={record.id}
name={objectRecordIdentifier.name}
avatarType={objectRecordIdentifier.avatarType}
avatarUrl={objectRecordIdentifier.avatarUrl ?? undefined}
linkToEntity={objectRecordIdentifier.linkToShowPage}
/>
);
};

View File

@ -77,13 +77,6 @@ export const RecordShowPage = () => {
objectNameSingular,
});
const objectMetadataType =
objectMetadataItem?.nameSingular === 'company'
? 'Company'
: objectMetadataItem?.nameSingular === 'person'
? 'Person'
: 'Custom';
const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => {
const updateEntity = ({ variables }: RecordUpdateHookParams) => {
updateOneRecord?.({
@ -171,7 +164,7 @@ export const RecordShowPage = () => {
hasBackButton
Icon={IconBuildingSkyscraper}
>
{record && objectMetadataType !== 'Custom' && (
{record && (
<>
<PageFavoriteButton
isFavorite={isFavorite}
@ -181,7 +174,7 @@ export const RecordShowPage = () => {
key="add"
entity={{
id: record.id,
type: objectMetadataType,
targetObjectNameSingular: objectMetadataItem?.nameSingular,
}}
/>
<ShowPageMoreButton
@ -275,15 +268,9 @@ export const RecordShowPage = () => {
)}
</ShowPageLeftContainer>
<ShowPageRightContainer
entity={{
id: record?.id || '',
// TODO: refacto
type:
objectMetadataItem?.nameSingular === 'company'
? 'Company'
: objectMetadataItem?.nameSingular === 'person'
? 'Person'
: 'Custom',
targetableObject={{
id: record?.id ?? '',
targetObjectNameSingular: objectMetadataItem?.nameSingular,
}}
timeline
tasks

View File

@ -10,6 +10,7 @@ export const ChipFieldDisplay = () => {
basePathToShowPage,
} = useChipField();
// TODO: remove this and use ObjectRecordChip instead
const identifiers = identifiersMapper?.(record, objectNameSingular ?? '');
return (

View File

@ -5,11 +5,10 @@ import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimis
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useGenerateEmptyRecord } from '@/object-record/hooks/useGenerateEmptyRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from '~/utils/string/capitalize';
export const useCreateManyRecords = <
T extends Record<string, unknown> & { id: string },
>({
export const useCreateManyRecords = <T extends ObjectRecord>({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const { triggerOptimisticEffects } = useOptimisticEffect({
@ -27,17 +26,16 @@ export const useCreateManyRecords = <
const apolloClient = useApolloClient();
const createManyRecords = async (data: Record<string, any>[]) => {
const createManyRecords = async (data: Partial<T>[]) => {
const withIds = data.map((record) => ({
...record,
id: (record.id as string) ?? v4(),
}));
withIds.forEach((record) => {
const emptyRecord: Record<string, unknown> | undefined =
generateEmptyRecord({
id: record.id,
});
const emptyRecord: T | undefined = generateEmptyRecord({
id: record.id,
} as T);
if (emptyRecord) {
triggerOptimisticEffects({

View File

@ -34,7 +34,7 @@ export const useCreateOneRecord = <T>({
const createOneRecord = async (input: Record<string, any>) => {
const recordId = v4();
const generatedEmptyRecord = generateEmptyRecord<Record<string, unknown>>({
const generatedEmptyRecord = generateEmptyRecord({
id: recordId,
createdAt: new Date().toISOString(),
...input,

View File

@ -1,4 +1,5 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
export const useGenerateEmptyRecord = ({
@ -7,11 +8,11 @@ export const useGenerateEmptyRecord = ({
objectMetadataItem: ObjectMetadataItem;
}) => {
// Todo fix typing once we generate the return base on Metadata
const generateEmptyRecord = <T>(input: Partial<T> & { id: string }) => {
const generateEmptyRecord = <T extends ObjectRecord>(input: T) => {
// Todo replace this by runtime typing
const validatedInput = input as { id: string } & { [key: string]: any };
const validatedInput = input as T;
const emptyRecord = {} as Record<string, any>;
const emptyRecord = {} as any;
for (const fieldMetadataItem of objectMetadataItem.fields) {
emptyRecord[fieldMetadataItem.name] =
@ -19,7 +20,7 @@ export const useGenerateEmptyRecord = ({
generateEmptyFieldValue(fieldMetadataItem);
}
return emptyRecord;
return emptyRecord as T;
};
return {

View File

@ -0,0 +1,84 @@
import { gql } from '@apollo/client';
import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
export const useGenerateFindManyRecordsForMultipleMetadataItemsQuery = ({
objectMetadataItems,
depth,
}: {
objectMetadataItems: ObjectMetadataItem[];
depth?: number;
}) => {
const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery();
const capitalizedObjectNameSingulars = objectMetadataItems.map(
({ nameSingular }) => capitalize(nameSingular),
);
const filterPerMetadataItemArray = capitalizedObjectNameSingulars
.map(
(capitalizedObjectNameSingular) =>
`$filter${capitalizedObjectNameSingular}: ${capitalizedObjectNameSingular}FilterInput`,
)
.join(', ');
const orderByPerMetadataItemArray = capitalizedObjectNameSingulars
.map(
(capitalizedObjectNameSingular) =>
`$orderBy${capitalizedObjectNameSingular}: ${capitalizedObjectNameSingular}OrderByInput`,
)
.join(', ');
const lastCursorPerMetadataItemArray = capitalizedObjectNameSingulars
.map(
(capitalizedObjectNameSingular) =>
`$lastCursor${capitalizedObjectNameSingular}: String`,
)
.join(', ');
const limitPerMetadataItemArray = capitalizedObjectNameSingulars
.map(
(capitalizedObjectNameSingular) =>
`$limit${capitalizedObjectNameSingular}: Float = 5`,
)
.join(', ');
return gql`
query FindManyRecordsMultipleMetadataItems(
${filterPerMetadataItemArray},
${orderByPerMetadataItemArray},
${lastCursorPerMetadataItemArray},
${limitPerMetadataItemArray}
) {
${objectMetadataItems
.map(
({ namePlural, nameSingular, fields }) =>
`${namePlural}(filter: $filter${capitalize(
nameSingular,
)}, orderBy: $orderBy${capitalize(
nameSingular,
)}, first: $limit${capitalize(
nameSingular,
)}, after: $lastCursor${capitalize(nameSingular)}){
edges {
node {
id
${fields
.map((field) => mapFieldMetadataToGraphQLQuery(field, depth))
.join('\n')}
}
cursor
}
pageInfo {
hasNextPage
startCursor
endCursor
}
}`,
)
.join('\n')}
}
`;
};

View File

@ -1,7 +1,6 @@
import { gql } from '@apollo/client';
import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery';
import { EMPTY_QUERY } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
@ -14,10 +13,6 @@ export const useGenerateFindManyRecordsQuery = ({
}) => {
const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery();
if (!objectMetadataItem) {
return EMPTY_QUERY;
}
return gql`
query FindMany${capitalize(
objectMetadataItem.namePlural,

View File

@ -67,13 +67,6 @@ export const useRecordTableContextMenuEntries = (
const { createFavorite, favorites, deleteFavorite } = useFavorites();
const objectMetadataType =
objectNameSingular === 'company'
? 'Company'
: objectNameSingular === 'person'
? 'Person'
: 'Custom';
const handleFavoriteButtonClick = useRecoilCallback(({ snapshot }) => () => {
const selectedRowIds = injectSelectorSnapshotValueWithRecordTableScopeId(
snapshot,
@ -212,14 +205,14 @@ export const useRecordTableContextMenuEntries = (
label: 'Task',
Icon: IconCheckbox,
onClick: () => {
openCreateActivityDrawer('Task', objectMetadataType);
openCreateActivityDrawer('Task', objectNameSingular);
},
},
{
label: 'Note',
Icon: IconNotes,
onClick: () => {
openCreateActivityDrawer('Note', objectMetadataType);
openCreateActivityDrawer('Note', objectNameSingular);
},
},
...(dataExecuteQuickActionOnmentEnabled

View File

@ -0,0 +1,212 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import debounce from 'lodash.debounce';
import { v4 } from 'uuid';
import {
ObjectRecordForSelect,
SelectedObjectRecordId,
useMultiObjectSearch,
} from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
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 { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
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';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
export type EntitiesForMultipleObjectRecordSelect = {
filteredSelectedObjectRecords: ObjectRecordForSelect[];
objectRecordsToSelect: ObjectRecordForSelect[];
loading: boolean;
};
export const MultipleObjectRecordSelect = ({
onChange,
onSubmit,
selectedObjectRecordIds,
}: {
onChange?: (
changedRecordForSelect: ObjectRecordForSelect,
newSelectedValue: boolean,
) => void;
onCancel?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void;
onSubmit?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void;
selectedObjectRecordIds: SelectedObjectRecordId[];
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [searchFilter, setSearchFilter] = useState<string>('');
const {
filteredSelectedObjectRecords,
loading,
objectRecordsToSelect,
selectedObjectRecords,
} = useMultiObjectSearch({
searchFilterValue: searchFilter,
selectedObjectRecordIds,
excludedObjectRecordIds: [],
limit: 10,
});
const selectedObjectRecordsForSelect = useMemo(
() =>
selectedObjectRecords.filter((selectedObjectRecord) =>
selectedObjectRecordIds.some(
(selectedObjectRecordId) =>
selectedObjectRecordId.id ===
selectedObjectRecord.recordIdentifier.id,
),
),
[selectedObjectRecords, selectedObjectRecordIds],
);
const [internalSelectedRecords, setInternalSelectedRecords] = useState<
ObjectRecordForSelect[]
>([]);
useEffect(() => {
if (!loading) {
setInternalSelectedRecords(selectedObjectRecordsForSelect);
}
}, [selectedObjectRecordsForSelect, loading]);
const debouncedSetSearchFilter = debounce(setSearchFilter, 100, {
leading: true,
});
const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSetSearchFilter(event.currentTarget.value);
};
const handleSelectChange = (
changedRecordForSelect: ObjectRecordForSelect,
newSelectedValue: boolean,
) => {
const newSelectedRecords = newSelectedValue
? [...internalSelectedRecords, changedRecordForSelect]
: internalSelectedRecords.filter(
(selectedRecord) =>
selectedRecord.record.id !== changedRecordForSelect.record.id,
);
setInternalSelectedRecords(newSelectedRecords);
onChange?.(changedRecordForSelect, newSelectedValue);
};
const entitiesInDropdown = useMemo(
() =>
[
...(filteredSelectedObjectRecords ?? []),
...(objectRecordsToSelect ?? []),
].filter((entity) => isNonEmptyString(entity.recordIdentifier.id)),
[filteredSelectedObjectRecords, objectRecordsToSelect],
);
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
event.stopPropagation();
event.preventDefault();
onSubmit?.(internalSelectedRecords);
},
});
const selectableItemIds = entitiesInDropdown.map(
(entity) => entity.record.id,
);
return (
<DropdownMenu ref={containerRef} data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={handleFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{loading ? (
<MenuItem text="Loading..." />
) : (
<>
<SelectableList
selectableListId="multiple-entity-select-list"
selectableItemIdArray={selectableItemIds}
hotkeyScope={RelationPickerHotkeyScope.RelationPicker}
onEnter={(recordId) => {
const recordIsSelected = internalSelectedRecords?.some(
(selectedRecord) => selectedRecord.record.id === recordId,
);
const correspondingRecordForSelect = entitiesInDropdown?.find(
(entity) => entity.record.id === recordId,
);
if (correspondingRecordForSelect) {
handleSelectChange(
correspondingRecordForSelect,
!recordIsSelected,
);
}
}}
>
{entitiesInDropdown?.map((objectRecordForSelect) => (
<StyledSelectableItem
itemId={objectRecordForSelect.record.id}
key={objectRecordForSelect.record.id + v4()}
>
<MenuItemMultiSelectAvatar
selected={internalSelectedRecords?.some(
(selectedRecord) => {
return (
selectedRecord.record.id ===
objectRecordForSelect.record.id
);
},
)}
onSelectChange={(newCheckedValue) =>
handleSelectChange(objectRecordForSelect, newCheckedValue)
}
avatar={
<Avatar
avatarUrl={
objectRecordForSelect.recordIdentifier.avatarUrl
}
colorId={objectRecordForSelect.record.id}
placeholder={
objectRecordForSelect.recordIdentifier.name
}
size="md"
type={
objectRecordForSelect.recordIdentifier.avatarType ??
'rounded'
}
/>
}
text={objectRecordForSelect.recordIdentifier.name}
/>
</StyledSelectableItem>
))}
</SelectableList>
{entitiesInDropdown?.length === 0 && <MenuItem text="No result" />}
</>
)}
</DropdownMenuItemsContainer>
</DropdownMenu>
);
};

View File

@ -0,0 +1,24 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/search/hooks/useFilteredSearchEntityQuery';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const useLimitPerMetadataItem = ({
objectMetadataItems,
limit = DEFAULT_SEARCH_REQUEST_LIMIT,
}: {
objectMetadataItems: ObjectMetadataItem[];
limit?: number;
}) => {
const limitPerMetadataItem = Object.fromEntries(
objectMetadataItems
.map(({ nameSingular }) => {
return [`limit${capitalize(nameSingular)}`, limit];
})
.filter(isDefined),
);
return {
limitPerMetadataItem,
};
};

View File

@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsByNamePluralMapSelector } from '@/object-metadata/states/objectMetadataItemsByNamePluralMapSelector';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { isDefined } from '~/utils/isDefined';
export type MultiObjectRecordQueryResult = {
[namePlural: string]: ObjectRecordConnection;
};
export const useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray =
({
multiObjectRecordsQueryResult,
}: {
multiObjectRecordsQueryResult:
| MultiObjectRecordQueryResult
| null
| undefined;
}) => {
const objectMetadataItemsByNamePluralMap = useRecoilValue(
objectMetadataItemsByNamePluralMapSelector,
);
const objectRecordForSelectArray = useMemo(() => {
return Object.entries(multiObjectRecordsQueryResult ?? {}).flatMap(
([namePlural, objectRecordConnection]) => {
const objectMetadataItem =
objectMetadataItemsByNamePluralMap.get(namePlural);
if (!isDefined(objectMetadataItem)) return [];
return objectRecordConnection.edges.map(({ node }) => ({
objectMetadataItem,
record: node,
recordIdentifier: getObjectRecordIdentifier({
objectMetadataItem,
record: node,
}),
})) as ObjectRecordForSelect[];
},
);
}, [multiObjectRecordsQueryResult, objectMetadataItemsByNamePluralMap]);
return {
objectRecordForSelectArray,
};
};

View File

@ -0,0 +1,72 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery } from '@/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery';
import { useMultiObjectSearchMatchesSearchFilterAndToSelectQuery } from '@/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery';
import { useMultiObjectSearchSelectedItemsQuery } from '@/object-record/relation-picker/hooks/useMultiObjectSearchSelectedItemsQuery';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
export const DEFAULT_SEARCH_REQUEST_LIMIT = 5;
export type ObjectRecordForSelect = {
objectMetadataItem: ObjectMetadataItem;
record: ObjectRecord;
recordIdentifier: ObjectRecordIdentifier;
};
export type SelectedObjectRecordId = {
objectNameSingular: string;
id: string;
};
export type MultiObjectSearch = {
selectedObjectRecords: ObjectRecordForSelect[];
filteredSelectedObjectRecords: ObjectRecordForSelect[];
objectRecordsToSelect: ObjectRecordForSelect[];
loading: boolean;
};
export const useMultiObjectSearch = ({
searchFilterValue,
selectedObjectRecordIds,
limit,
excludedObjectRecordIds = [],
}: {
searchFilterValue: string;
selectedObjectRecordIds: SelectedObjectRecordId[];
limit?: number;
excludedObjectRecordIds?: SelectedObjectRecordId[];
}): MultiObjectSearch => {
const { selectedObjectRecords, selectedObjectRecordsLoading } =
useMultiObjectSearchSelectedItemsQuery({
selectedObjectRecordIds,
});
const {
selectedAndMatchesSearchFilterObjectRecords,
selectedAndMatchesSearchFilterObjectRecordsLoading,
} = useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery({
searchFilterValue,
selectedObjectRecordIds,
limit,
});
const {
toSelectAndMatchesSearchFilterObjectRecords,
toSelectAndMatchesSearchFilterObjectRecordsLoading,
} = useMultiObjectSearchMatchesSearchFilterAndToSelectQuery({
excludedObjectRecordIds,
searchFilterValue,
selectedObjectRecordIds,
limit,
});
return {
selectedObjectRecords,
filteredSelectedObjectRecords: selectedAndMatchesSearchFilterObjectRecords,
objectRecordsToSelect: toSelectAndMatchesSearchFilterObjectRecords,
loading:
selectedAndMatchesSearchFilterObjectRecordsLoading ||
toSelectAndMatchesSearchFilterObjectRecordsLoading ||
selectedObjectRecordsLoading,
};
};

View File

@ -0,0 +1,113 @@
import { useQuery } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem';
import {
MultiObjectRecordQueryResult,
useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray,
} from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem';
import { useSearchFilterPerMetadataItem } from '@/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({
selectedObjectRecordIds,
searchFilterValue,
limit,
}: {
selectedObjectRecordIds: SelectedObjectRecordId[];
searchFilterValue: string;
limit?: number;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { searchFilterPerMetadataItemNameSingular } =
useSearchFilterPerMetadataItem({
objectMetadataItems,
searchFilterValue,
});
const objectMetadataItemsUsedInSelectedIdsQuery = objectMetadataItems.filter(
({ nameSingular }) => {
return selectedObjectRecordIds.some(({ objectNameSingular }) => {
return objectNameSingular === nameSingular;
});
},
);
const selectedAndMatchesSearchFilterTextFilterPerMetadataItem =
Object.fromEntries(
objectMetadataItems
.map(({ nameSingular }) => {
const selectedIds = selectedObjectRecordIds
.filter(
({ objectNameSingular }) => objectNameSingular === nameSingular,
)
.map(({ id }) => id);
if (!isNonEmptyArray(selectedIds)) return null;
const searchFilter =
searchFilterPerMetadataItemNameSingular[nameSingular] ?? {};
return [
`filter${capitalize(nameSingular)}`,
{
and: [
{
...searchFilter,
},
{
id: {
in: selectedIds,
},
},
],
},
];
})
.filter(isDefined),
);
const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
});
const { limitPerMetadataItem } = useLimitPerMetadataItem({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
limit,
});
const multiSelectQueryForSelectedIds =
useGenerateFindManyRecordsForMultipleMetadataItemsQuery({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
});
const {
loading: selectedAndMatchesSearchFilterObjectRecordsLoading,
data: selectedAndMatchesSearchFilterObjectRecordsQueryResult,
} = useQuery<MultiObjectRecordQueryResult>(multiSelectQueryForSelectedIds, {
variables: {
...selectedAndMatchesSearchFilterTextFilterPerMetadataItem,
...orderByFieldPerMetadataItem,
...limitPerMetadataItem,
},
});
const {
objectRecordForSelectArray: selectedAndMatchesSearchFilterObjectRecords,
} = useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({
multiObjectRecordsQueryResult:
selectedAndMatchesSearchFilterObjectRecordsQueryResult,
});
return {
selectedAndMatchesSearchFilterObjectRecordsLoading,
selectedAndMatchesSearchFilterObjectRecords,
};
};

View File

@ -0,0 +1,130 @@
import { useQuery } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem';
import {
MultiObjectRecordQueryResult,
useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray,
} from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem';
import { useSearchFilterPerMetadataItem } from '@/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({
selectedObjectRecordIds,
excludedObjectRecordIds,
searchFilterValue,
limit,
}: {
selectedObjectRecordIds: SelectedObjectRecordId[];
excludedObjectRecordIds: SelectedObjectRecordId[];
searchFilterValue: string;
limit?: number;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const nonSystemObjectMetadataItems = objectMetadataItems.filter(
({ nameSingular, isSystem }) =>
!isSystem && nameSingular !== CoreObjectNameSingular.Opportunity,
);
const { searchFilterPerMetadataItemNameSingular } =
useSearchFilterPerMetadataItem({
objectMetadataItems: nonSystemObjectMetadataItems,
searchFilterValue,
});
const objectRecordsToSelectAndMatchesSearchFilterTextFilterPerMetadataItem =
Object.fromEntries(
nonSystemObjectMetadataItems
.map(({ nameSingular }) => {
const selectedIds = selectedObjectRecordIds
.filter(
({ objectNameSingular }) => objectNameSingular === nameSingular,
)
.map(({ id }) => id);
const excludedIds = excludedObjectRecordIds
.filter(
({ objectNameSingular }) => objectNameSingular === nameSingular,
)
.map(({ id }) => id);
const searchFilter =
searchFilterPerMetadataItemNameSingular[nameSingular] ?? {};
const excludedIdsUnion = [...selectedIds, ...excludedIds];
const noFilter =
!isNonEmptyArray(excludedIdsUnion) &&
isDeeplyEqual(searchFilter, {});
return [
`filter${capitalize(nameSingular)}`,
!noFilter
? {
and: [
{
...searchFilter,
},
isNonEmptyArray(excludedIdsUnion)
? {
not: {
id: {
in: [...selectedIds, ...excludedIds],
},
},
}
: {},
],
}
: {},
];
})
.filter(isDefined),
);
const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({
objectMetadataItems: nonSystemObjectMetadataItems,
});
const { limitPerMetadataItem } = useLimitPerMetadataItem({
objectMetadataItems: nonSystemObjectMetadataItems,
limit,
});
const multiSelectQuery =
useGenerateFindManyRecordsForMultipleMetadataItemsQuery({
objectMetadataItems: nonSystemObjectMetadataItems,
});
const {
loading: toSelectAndMatchesSearchFilterObjectRecordsLoading,
data: toSelectAndMatchesSearchFilterObjectRecordsQueryResult,
} = useQuery<MultiObjectRecordQueryResult>(multiSelectQuery, {
variables: {
...objectRecordsToSelectAndMatchesSearchFilterTextFilterPerMetadataItem,
...orderByFieldPerMetadataItem,
...limitPerMetadataItem,
},
});
const {
objectRecordForSelectArray: toSelectAndMatchesSearchFilterObjectRecords,
} = useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({
multiObjectRecordsQueryResult:
toSelectAndMatchesSearchFilterObjectRecordsQueryResult,
});
return {
toSelectAndMatchesSearchFilterObjectRecordsLoading,
toSelectAndMatchesSearchFilterObjectRecords,
};
};

View File

@ -0,0 +1,88 @@
import { useQuery } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem';
import {
MultiObjectRecordQueryResult,
useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray,
} from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const useMultiObjectSearchSelectedItemsQuery = ({
selectedObjectRecordIds,
}: {
selectedObjectRecordIds: SelectedObjectRecordId[];
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const objectMetadataItemsUsedInSelectedIdsQuery = objectMetadataItems.filter(
({ nameSingular }) => {
return selectedObjectRecordIds.some(({ objectNameSingular }) => {
return objectNameSingular === nameSingular;
});
},
);
const selectedIdFilterPerMetadataItem = Object.fromEntries(
objectMetadataItemsUsedInSelectedIdsQuery
.map(({ nameSingular }) => {
const selectedIds = selectedObjectRecordIds
.filter(
({ objectNameSingular }) => objectNameSingular === nameSingular,
)
.map(({ id }) => id);
if (!isNonEmptyArray(selectedIds)) return null;
return [
`filter${capitalize(nameSingular)}`,
{
id: {
in: selectedIds,
},
},
];
})
.filter(isDefined),
);
const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
});
const { limitPerMetadataItem } = useLimitPerMetadataItem({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
});
const multiSelectQueryForSelectedIds =
useGenerateFindManyRecordsForMultipleMetadataItemsQuery({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
});
const {
loading: selectedObjectRecordsLoading,
data: selectedObjectRecordsQueryResult,
} = useQuery<MultiObjectRecordQueryResult>(multiSelectQueryForSelectedIds, {
variables: {
...selectedIdFilterPerMetadataItem,
...orderByFieldPerMetadataItem,
...limitPerMetadataItem,
},
});
const { objectRecordForSelectArray: selectedObjectRecords } =
useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({
multiObjectRecordsQueryResult: selectedObjectRecordsQueryResult,
});
return {
selectedObjectRecordsLoading,
selectedObjectRecords,
};
};

View File

@ -0,0 +1,29 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectOrderByField } from '@/object-metadata/utils/getObjectOrderByField';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const useOrderByFieldPerMetadataItem = ({
objectMetadataItems,
}: {
objectMetadataItems: ObjectMetadataItem[];
}) => {
const orderByFieldPerMetadataItem = Object.fromEntries(
objectMetadataItems
.map((objectMetadataItem) => {
const orderByField = getObjectOrderByField(objectMetadataItem);
return [
`orderBy${capitalize(objectMetadataItem.nameSingular)}`,
{
...orderByField,
},
];
})
.filter(isDefined),
);
return {
orderByFieldPerMetadataItem,
};
};

View File

@ -0,0 +1,67 @@
import { isNonEmptyString } from '@sniptt/guards';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
export const useSearchFilterPerMetadataItem = ({
objectMetadataItems,
searchFilterValue,
}: {
objectMetadataItems: ObjectMetadataItem[];
searchFilterValue: string;
}) => {
const searchFilterPerMetadataItemNameSingular =
Object.fromEntries<ObjectRecordQueryFilter>(
objectMetadataItems
.map((objectMetadataItem) => {
if (!isNonEmptyString(searchFilterValue)) return null;
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
let searchFilter: ObjectRecordQueryFilter = {};
if (labelIdentifierFieldMetadataItem) {
switch (labelIdentifierFieldMetadataItem.type) {
case FieldMetadataType.FullName: {
searchFilter = {
or: [
{
[labelIdentifierFieldMetadataItem.name]: {
firstName: {
ilike: `%${searchFilterValue}%`,
},
},
},
{
[labelIdentifierFieldMetadataItem.name]: {
lastName: {
ilike: `%${searchFilterValue}%`,
},
},
},
],
};
break;
}
default:
searchFilter = {
[labelIdentifierFieldMetadataItem.name]: {
ilike: `%${searchFilterValue}%`,
},
};
}
}
return [objectMetadataItem.nameSingular, searchFilter] as const;
})
.filter(isDefined),
);
return {
searchFilterPerMetadataItemNameSingular,
};
};

View File

@ -0,0 +1 @@
export type ObjectRecord = Record<string, any> & { id: string };

View File

@ -0,0 +1,11 @@
import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge';
export type ObjectRecordConnection = {
edges: ObjectRecordEdge[];
pageInfo: {
hasNextPage?: boolean;
hasPreviousPage?: boolean;
startCursor?: string;
endCursor?: string;
};
};

View File

@ -0,0 +1,6 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export type ObjectRecordEdge = {
node: ObjectRecord;
cursor: string;
};

View File

@ -5,5 +5,6 @@ export type ObjectRecordIdentifier = {
name: string;
avatarUrl?: string | null;
avatarType?: AvatarType | null;
linkToEntity?: string;
linkToShowPage?: string;
};