Refactor SingleRecordPicker to handle morphs (#14956)

## Scope

- Refactor SingleRecordPicker to handle morphItem and match Multiple one
- fix query generation to take morph into account


Left: 
- double check SingleRecordPicker in Workflows / Form
- Fix tests / stories
This commit is contained in:
Charles Bochet
2025-10-07 20:07:27 +02:00
committed by GitHub
parent 8829eb5a4a
commit 84f1559832
28 changed files with 460 additions and 585 deletions
@@ -6,7 +6,7 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { searchRecordStoreComponentFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
@@ -58,7 +58,6 @@ export const useUpdateActivityTargetFromCell = ({
async ({
morphItem,
activityTargetWithTargetRecords,
recordPickerInstanceId,
}: UpdateActivityTargetFromCellProps) => {
const targetObjectName =
activityObjectNameSingular === CoreObjectNameSingular.Task
@@ -105,12 +104,7 @@ export const useUpdateActivityTargetFromCell = ({
}
} else {
const searchRecord = snapshot
.getLoadable(
searchRecordStoreComponentFamilyState.atomFamily({
instanceId: recordPickerInstanceId,
familyKey: morphItem.recordId,
}),
)
.getLoadable(searchRecordStoreFamilyState(morphItem.recordId))
.getValue();
if (!isDefined(searchRecord) || !isDefined(searchRecord?.record)) {
@@ -1,23 +0,0 @@
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
import { type SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { type SearchRecord } from '~/generated/graphql';
export const formatSearchRecordAsSingleRecordPickerRecord = (
searchRecord: SearchRecord,
): SingleRecordPickerRecord => {
return {
id: searchRecord.recordId,
name: searchRecord.label,
avatarUrl: searchRecord.imageUrl ?? undefined,
avatarType: getAvatarType(searchRecord.objectNameSingular),
linkToShowPage:
getBasePathToShowPage({
objectNameSingular: searchRecord.objectNameSingular,
}) + searchRecord.recordId,
record: {
id: searchRecord.recordId,
__typename: searchRecord.objectNameSingular,
},
};
};
@@ -4,6 +4,7 @@ import gql from 'graphql-tag';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { type RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type ObjectPermissions } from 'twenty-shared/types';
@@ -21,7 +22,7 @@ export const updateRecordFromCache = <T extends ObjectRecord>({
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: ObjectMetadataItem;
cache: ApolloCache<object>;
recordGqlFields: Record<string, boolean>;
recordGqlFields: RecordGqlFields;
record: T;
objectPermissionsByObjectMetadataId: Record<
string,
@@ -1,3 +1,4 @@
import { type RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import {
type GenerateDepthOneRecordGqlFields,
generateDepthOneRecordGqlFields,
@@ -11,13 +12,13 @@ type ComputeDepthOneRecordGqlFieldsFromRecordArgs =
export const computeDepthOneRecordGqlFieldsFromRecord = ({
objectMetadataItem,
record,
}: ComputeDepthOneRecordGqlFieldsFromRecordArgs) => {
}: ComputeDepthOneRecordGqlFieldsFromRecordArgs): RecordGqlFields => {
const depthOneRecordGqlFields = generateDepthOneRecordGqlFields({
objectMetadataItem,
});
const recordKeys = Object.keys(record);
return Object.keys(depthOneRecordGqlFields).reduce<Record<string, boolean>>(
return Object.keys(depthOneRecordGqlFields).reduce<RecordGqlFields>(
(acc, key) => {
return {
...acc,
@@ -1,6 +1,8 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isDefined } from 'twenty-shared/utils';
import { type RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
export type GenerateDepthOneRecordGqlFields = {
objectMetadataItem: ObjectMetadataItem;
@@ -8,28 +10,64 @@ export type GenerateDepthOneRecordGqlFields = {
export const generateDepthOneRecordGqlFields = ({
objectMetadataItem,
}: GenerateDepthOneRecordGqlFields) =>
objectMetadataItem.readableFields.reduce<Record<string, true>>(
(acc, field) => {
return {
...acc,
...(isDefined(field.settings?.joinColumnName)
? {
[field.settings.joinColumnName]: true,
}
: {}),
[field.name]:
// TODO: Remove once we have made the workflows lighter
(objectMetadataItem.nameSingular ===
CoreObjectNameSingular.Workflow ||
objectMetadataItem.nameSingular ===
CoreObjectNameSingular.WorkflowVersion ||
objectMetadataItem.nameSingular ===
CoreObjectNameSingular.WorkflowRun) &&
(field.name === 'versions' || field.name === 'runs')
? { id: true, name: true }
: true,
};
}: GenerateDepthOneRecordGqlFields) => {
const gqlFields = objectMetadataItem.readableFields.flatMap(
(fieldMetadata) => {
const isManyToOneMorphRelation =
fieldMetadata.type === FieldMetadataType.MORPH_RELATION &&
fieldMetadata.settings.relationType === RelationType.MANY_TO_ONE;
const isManyToOneRelation =
fieldMetadata.type === FieldMetadataType.RELATION &&
fieldMetadata.settings.relationType === RelationType.MANY_TO_ONE;
if (isManyToOneRelation) {
return [`${fieldMetadata.name}Id`, `${fieldMetadata.name}`];
}
if (isManyToOneMorphRelation) {
if (!isDefined(fieldMetadata.morphRelations)) {
throw new Error(
`Field ${fieldMetadata.name} is missing, please refresh the page. If the problem persists, please contact support.`,
);
}
const morphGqlFields = fieldMetadata.morphRelations.map(
(morphRelation) => ({
gqlField: computeMorphRelationFieldName({
fieldName: fieldMetadata.name,
relationType: morphRelation.type,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
morphRelation.targetObjectMetadata.namePlural,
}),
fieldMetadata,
}),
);
return morphGqlFields.flatMap((morphGqlField) => [
morphGqlField.gqlField,
`${morphGqlField.gqlField}Id`,
]);
}
return [fieldMetadata.name];
},
{},
);
return gqlFields.reduce<RecordGqlFields>((acc, field) => {
return {
...acc,
[field]:
// TODO: Remove once we have made the workflows lighter
(objectMetadataItem.nameSingular === CoreObjectNameSingular.Workflow ||
objectMetadataItem.nameSingular ===
CoreObjectNameSingular.WorkflowVersion ||
objectMetadataItem.nameSingular ===
CoreObjectNameSingular.WorkflowRun) &&
(field === 'versions' || field === 'runs')
? { id: true, name: true }
: true,
};
}, {});
};
@@ -9,6 +9,7 @@ import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
type ObjectRecordFilterInput,
type SearchQuery,
useSearchQuery,
} from '~/generated/graphql';
import { logError } from '~/utils/logError';
@@ -18,6 +19,7 @@ export type UseSearchRecordsParams = {
objectNameSingulars: string[];
limit?: number;
onError?: (error?: Error) => void;
onCompleted?: (data: SearchQuery) => void;
skip?: boolean;
fetchPolicy?: WatchQueryFetchPolicy;
searchInput?: string;
@@ -28,6 +30,7 @@ export const useObjectRecordSearchRecords = ({
objectNameSingulars,
searchInput,
limit,
onCompleted,
skip,
filter,
fetchPolicy,
@@ -49,6 +52,7 @@ export const useObjectRecordSearchRecords = ({
},
fetchPolicy: fetchPolicy,
client: apolloCoreClient,
onCompleted: onCompleted,
onError: (error) => {
logError(
`useSearchRecords for "${objectNameSingulars.join(', ')}" error : ` +
@@ -7,6 +7,7 @@ import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { type RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { computeDepthOneRecordGqlFieldsFromRecord } from '@/object-record/graphql/utils/computeDepthOneRecordGqlFieldsFromRecord';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
@@ -24,7 +25,7 @@ type UpdateOneRecordArgs<UpdatedObjectRecord> = {
updateOneRecordInput: Partial<Omit<UpdatedObjectRecord, 'id'>>;
optimisticRecord?: Partial<ObjectRecord>;
objectNameSingular: string;
recordGqlFields?: Record<string, any>;
recordGqlFields?: RecordGqlFields;
};
export const useUpdateOneRecordV2 = () => {
const apolloCoreClient = useApolloCoreClient();
@@ -11,7 +11,7 @@ import { SingleRecordPicker } from '@/object-record/record-picker/single-record-
import { useSingleRecordPickerOpen } from '@/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerOpen';
import { singleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchFilterComponentState';
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { type SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { getRecordFieldCardRelationPickerDropdownId } from '@/object-record/record-show/utils/getRecordFieldCardRelationPickerDropdownId';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
@@ -77,13 +77,14 @@ export const RecordDetailRelationSectionDropdownToOne = () => {
const { onSubmit } = useContext(FieldInputEventContext);
const handleRelationPickerEntitySelected = (
selectedRelationEntity?: SingleRecordPickerRecord,
selectedMorphItem?: RecordPickerPickableMorphItem,
) => {
closeDropdown(dropdownId);
if (!selectedRelationEntity?.id || !relationFieldMetadataItem?.name) return;
if (!selectedMorphItem?.recordId || !relationFieldMetadataItem?.name)
return;
onSubmit?.({ newValue: selectedRelationEntity.record });
onSubmit?.({ newValue: selectedMorphItem.recordId });
};
const { createNewRecordAndOpenRightDrawer } =
@@ -133,7 +134,7 @@ export const RecordDetailRelationSectionDropdownToOne = () => {
focusId={dropdownId}
componentInstanceId={dropdownId}
EmptyIcon={IconForbid}
onRecordSelected={handleRelationPickerEntitySelected}
onMorphItemSelected={handleRelationPickerEntitySelected}
objectNameSingulars={[relationObjectMetadataNameSingular]}
recordPickerInstanceId={dropdownId}
onCancel={() => closeDropdown(dropdownId)}
@@ -7,7 +7,7 @@ import { type VariablePickerComponent } from '@/object-record/record-field/ui/fo
import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
import { singleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchFilterComponentState';
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { type SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
@@ -122,19 +122,16 @@ export const FormSingleRecordPicker = ({
setRecordPickerSearchFilter('');
}, [setRecordPickerSearchFilter]);
const handleRecordSelected = (
selectedEntity: SingleRecordPickerRecord | null | undefined,
const handleMorphItemSelected = (
selectedMorphItem: RecordPickerPickableMorphItem | null | undefined,
) => {
if (
!isDefined(selectedEntity?.record?.id) ||
!isNonEmptyString(selectedEntity.record?.id)
) {
if (!isNonEmptyString(selectedMorphItem?.recordId)) {
onChange(null);
return;
}
onChange(selectedEntity.record.id);
onChange(selectedMorphItem.recordId);
closeDropdown(dropdownId);
};
@@ -216,7 +213,7 @@ export const FormSingleRecordPicker = ({
EmptyIcon={IconForbid}
emptyLabel={'No ' + objectNameSingulars.join(' or ')}
onCancel={() => closeDropdown(dropdownId)}
onRecordSelected={handleRecordSelected}
onMorphItemSelected={handleMorphItemSelected}
objectNameSingulars={objectNameSingulars}
recordPickerInstanceId={dropdownId}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
@@ -1,33 +1,59 @@
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useMorphPersistManyToOne } from '@/object-record/record-field/ui/meta-types/input/hooks/useMorphPersistManyToOne';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/ui/states/recordFieldInputLayoutDirectionComponentState';
import { recordFieldInputLayoutDirectionLoadingComponentState } from '@/object-record/record-field/ui/states/recordFieldInputLayoutDirectionLoadingComponentState';
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
import { type SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { useRecordTableBodyContextOrThrow } from '@/object-record/record-table/contexts/RecordTableBodyContext';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconForbid } from 'twenty-ui/display';
export const MorphRelationManyToOneFieldInput = () => {
const { fieldDefinition } = useContext(FieldContext);
const { fieldDefinition, recordId } = useContext(FieldContext);
const { onCancel, onSubmit } = useContext(FieldInputEventContext);
const { onCloseTableCell } = useRecordTableBodyContextOrThrow();
const { onCancel } = useContext(FieldInputEventContext);
const { objectMetadataItems } = useObjectMetadataItems();
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordFieldComponentInstanceContext,
);
const handleRecordSelected = (
selectedRecord: SingleRecordPickerRecord | null | undefined,
const { persistMorphManyToOne } = useMorphPersistManyToOne({
objectMetadataNameSingular:
fieldDefinition.metadata.objectMetadataNameSingular ?? '',
});
const handleMorphItemSelected = async (
selectedMorphItem: RecordPickerPickableMorphItem | null | undefined,
) => {
onSubmit?.({ newValue: selectedRecord?.record ?? null });
onCloseTableCell();
if (!isDefined(selectedMorphItem)) {
// Handle detach
return;
}
const targetObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === selectedMorphItem.objectMetadataId,
);
if (!isDefined(targetObjectMetadataItem)) {
throw new Error('TargetObjectMetadataItem is required');
}
await persistMorphManyToOne({
recordId: recordId,
fieldDefinition,
valueToPersist: selectedMorphItem.recordId,
targetObjectMetadataNameSingular: targetObjectMetadataItem.nameSingular,
});
onCancel?.();
};
const layoutDirection = useRecoilComponentValue(
@@ -56,7 +82,7 @@ export const MorphRelationManyToOneFieldInput = () => {
EmptyIcon={IconForbid}
emptyLabel={'No ' + fieldDefinition.label}
onCancel={onCancel}
onRecordSelected={handleRecordSelected}
onMorphItemSelected={handleMorphItemSelected}
objectNameSingulars={objectNameSingulars}
recordPickerInstanceId={instanceId}
layoutDirection={
@@ -8,7 +8,7 @@ import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/r
import { recordFieldInputLayoutDirectionLoadingComponentState } from '@/object-record/record-field/ui/states/recordFieldInputLayoutDirectionLoadingComponentState';
import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { type SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
@@ -28,9 +28,14 @@ export const RelationManyToOneFieldInput = () => {
RecordFieldComponentInstanceContext,
);
const handleRecordSelected = (
selectedRecord: SingleRecordPickerRecord | null | undefined,
) => onSubmit?.({ newValue: selectedRecord?.record ?? null });
const handleMorphItemSelected = (
selectedMorphItem: RecordPickerPickableMorphItem | null | undefined,
) =>
onSubmit?.({
newValue: isDefined(selectedMorphItem)
? { id: selectedMorphItem.recordId }
: null,
});
const { objectMetadataItem: relationObjectMetadataItem } =
useObjectMetadataItem({
@@ -85,7 +90,7 @@ export const RelationManyToOneFieldInput = () => {
emptyLabel={t`No ${fieldLabel}`}
onCancel={onCancel}
onCreate={handleCreateNew}
onRecordSelected={handleRecordSelected}
onMorphItemSelected={handleMorphItemSelected}
objectNameSingulars={[
fieldDefinition.metadata.relationObjectMetadataNameSingular,
]}
@@ -0,0 +1,106 @@
import { useRecoilCallback } from 'recoil';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
export type MorphPersistManyToOneProps = {
objectMetadataNameSingular: string;
};
export const useMorphPersistManyToOne = ({
objectMetadataNameSingular,
}: MorphPersistManyToOneProps) => {
const { objectMetadataItems } = useObjectMetadataItems();
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: objectMetadataNameSingular,
});
const persistMorphManyToOne = useRecoilCallback(
({ set, snapshot }) =>
async ({
recordId,
fieldDefinition,
valueToPersist,
targetObjectMetadataNameSingular,
}: {
recordId: string;
fieldDefinition: FieldDefinition<FieldMetadata>;
valueToPersist: string | null | undefined;
targetObjectMetadataNameSingular: string;
}) => {
assertFieldMetadata(
FieldMetadataType.MORPH_RELATION,
isFieldMorphRelation,
fieldDefinition,
);
const targetObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular ===
targetObjectMetadataNameSingular,
);
if (!isDefined(targetObjectMetadataItem)) {
throw new Error('Object metadata item not found');
}
const fieldName = fieldDefinition.metadata.fieldName;
if (!isDefined(valueToPersist)) {
// Handle detach
return;
}
const computedFieldName = computeMorphRelationFieldName({
fieldName,
relationType: fieldDefinition.metadata.relationType,
targetObjectMetadataNameSingular:
targetObjectMetadataItem.nameSingular,
targetObjectMetadataNamePlural: targetObjectMetadataItem.namePlural,
});
const currentValue: unknown = snapshot
.getLoadable(
recordStoreFamilySelector({
recordId,
fieldName: computedFieldName,
}),
)
.getValue();
if (
isDefined(currentValue) &&
(currentValue as ObjectRecord).id === valueToPersist
) {
return;
}
updateOneRecord?.({
idToUpdate: recordId,
updateOneRecordInput: {
[`${computedFieldName}Id`]: valueToPersist,
},
});
set(
recordStoreFamilySelector({
recordId,
fieldName: computedFieldName,
}),
valueToPersist,
);
return;
},
[updateOneRecord, objectMetadataItems],
);
return { persistMorphManyToOne };
};
@@ -1,7 +1,8 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { searchRecordStoreComponentFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { multipleRecordPickerSinglePickableMorphItemComponentFamilySelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerSinglePickableMorphItemComponentFamilySelector';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
type UseRecordPickerGetRecordAndObjectMetadataItemFromRecordIdProps = {
@@ -19,10 +20,7 @@ export const useRecordPickerGetSearchRecordAndObjectMetadataItemFromRecordId =
recordId,
);
const searchRecord = useRecoilComponentFamilyValue(
searchRecordStoreComponentFamilyState,
recordId,
);
const searchRecord = useRecoilValue(searchRecordStoreFamilyState(recordId));
if (!isDefined(pickableMorphItem) || !isDefined(searchRecord)) {
return { searchRecord: null, objectMetadataItem: null };
@@ -8,7 +8,7 @@ import { multipleRecordPickerPaginationState } from '@/object-record/record-pick
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState';
import { searchRecordStoreComponentFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { sortMorphItems } from '@/object-record/record-picker/multiple-record-picker/utils/sortMorphItems';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
@@ -280,10 +280,7 @@ export const useMultipleRecordPickerPerformSearch = () => {
searchRecords.forEach((searchRecord) => {
set(
searchRecordStoreComponentFamilyState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
familyKey: searchRecord.recordId,
}),
searchRecordStoreFamilyState(searchRecord.recordId),
searchRecord,
);
});
@@ -346,16 +343,10 @@ export const useMultipleRecordPickerPerformSearch = () => {
return;
}
set(
searchRecordStoreComponentFamilyState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
familyKey: objectRecord.id,
}),
{
...searchRecord,
record: objectRecord,
},
);
set(searchRecordStoreFamilyState(objectRecord.id), {
...searchRecord,
record: objectRecord,
});
});
},
);
@@ -1,13 +1,11 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState';
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
import { type SearchRecord } from '~/generated-metadata/graphql';
export const searchRecordStoreComponentFamilyState = createComponentFamilyState<
export const searchRecordStoreFamilyState = createFamilyState<
(SearchRecord & { record?: ObjectRecord }) | undefined,
string
>({
key: 'searchRecordStoreComponentFamilyState',
key: 'searchRecordStoreFamilyState',
defaultValue: undefined,
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
});
@@ -1,10 +0,0 @@
import { type SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
export type MultipleRecordPickerRecords<
CustomRecordForRecordPicker extends SingleRecordPickerRecord,
> = {
selectedRecords: CustomRecordForRecordPicker[];
filteredSelectedRecords: CustomRecordForRecordPicker[];
recordsToSelect: CustomRecordForRecordPicker[];
loading: boolean;
};
@@ -6,7 +6,7 @@ import {
} from '@/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItemsWithSearch';
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
import { singleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchFilterComponentState';
import { type SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
@@ -24,7 +24,7 @@ export const SingleRecordPicker = ({
excludedRecordIds,
onCancel,
onCreate,
onRecordSelected,
onMorphItemSelected,
objectNameSingulars,
componentInstanceId,
layoutDirection,
@@ -44,12 +44,12 @@ export const SingleRecordPicker = ({
onCancel?.();
};
const handleRecordSelected = (
selectedRecord?: SingleRecordPickerRecord | undefined,
const handleMorphItemSelected = (
selectedMorphItem?: RecordPickerPickableMorphItem | undefined,
) => {
setRecordPickerSearchFilter('');
onRecordSelected?.(selectedRecord);
onMorphItemSelected?.(selectedMorphItem);
};
useListenClickOutside({
@@ -83,7 +83,7 @@ export const SingleRecordPicker = ({
excludedRecordIds,
onCancel: handleCancel,
onCreate,
onRecordSelected: handleRecordSelected,
onMorphItemSelected: handleMorphItemSelected,
objectNameSingulars,
layoutDirection,
}}
@@ -1,19 +1,23 @@
import styled from '@emotion/styled';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
import { type SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { getSingleRecordPickerSelectableListId } from '@/object-record/record-picker/single-record-picker/utils/getSingleRecordPickerSelectableListId';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
import { MenuItemSelectAvatar } from 'twenty-ui/navigation';
type SingleRecordPickerMenuItemProps = {
record: SingleRecordPickerRecord;
onRecordSelected: (recordSelected?: SingleRecordPickerRecord) => void;
selectedRecord?: SingleRecordPickerRecord;
morphItem: RecordPickerPickableMorphItem;
onMorphItemSelected: (morphItem?: RecordPickerPickableMorphItem) => void;
isRecordSelected: boolean;
};
const StyledSelectableItem = styled(SelectableListItem)`
@@ -21,9 +25,9 @@ const StyledSelectableItem = styled(SelectableListItem)`
`;
export const SingleRecordPickerMenuItem = ({
record,
onRecordSelected,
selectedRecord,
morphItem,
onMorphItemSelected,
isRecordSelected,
}: SingleRecordPickerMenuItemProps) => {
const recordPickerComponentInstanceId =
useAvailableComponentInstanceIdOrThrow(
@@ -33,33 +37,41 @@ export const SingleRecordPickerMenuItem = ({
const selectableListComponentInstanceId =
getSingleRecordPickerSelectableListId(recordPickerComponentInstanceId);
const isSelectedItemId = useRecoilComponentFamilyValue(
const isSelectedByKeyboard = useRecoilComponentFamilyValue(
isSelectedItemIdComponentFamilySelector,
record.id,
morphItem.recordId,
selectableListComponentInstanceId,
);
const searchRecord = useRecoilValue(
searchRecordStoreFamilyState(morphItem.recordId),
);
if (!isDefined(searchRecord)) {
return null;
}
return (
<StyledSelectableItem
itemId={record.id}
key={record.id}
itemId={morphItem.recordId}
key={morphItem.recordId}
onEnter={() => {
onRecordSelected(record);
onMorphItemSelected(morphItem);
}}
>
<MenuItemSelectAvatar
testId="menu-item"
onClick={() => onRecordSelected(record)}
text={record.name}
selected={selectedRecord?.id === record.id}
focused={isSelectedItemId}
onClick={() => onMorphItemSelected(morphItem)}
text={searchRecord.label}
selected={isRecordSelected}
focused={isSelectedByKeyboard}
avatar={
<Avatar
avatarUrl={record.avatarUrl}
placeholderColorSeed={record.id}
placeholder={record.name}
avatarUrl={searchRecord.imageUrl}
placeholderColorSeed={morphItem.recordId}
placeholder={searchRecord.label}
size="md"
type={record.avatarType ?? 'rounded'}
type={getAvatarType(searchRecord.objectNameSingular) ?? 'rounded'}
/>
}
/>
@@ -1,4 +1,4 @@
import { isNonEmptyString, isUndefined } from '@sniptt/guards';
import { isUndefined } from '@sniptt/guards';
import { Key } from 'ts-key-enum';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
@@ -12,8 +12,8 @@ import { SingleRecordPickerComponentInstanceContext } from '@/object-record/reco
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { singleRecordPickerShouldShowInitialLoadingComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerShouldShowInitialLoadingComponentState';
import { singleRecordPickerShouldShowSkeletonComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerShouldShowSkeletonComponentState';
import { type SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { getSingleRecordPickerSelectableListId } from '@/object-record/record-picker/single-record-picker/utils/getSingleRecordPickerSelectableListId';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
@@ -21,36 +21,26 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
import { type IconComponent } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
export type SingleRecordPickerMenuItemsProps = {
EmptyIcon?: IconComponent;
emptyLabel?: string;
recordsToSelect: SingleRecordPickerRecord[];
pickableMorphItems: RecordPickerPickableMorphItem[];
onCancel?: () => void;
onRecordSelected: (entity?: SingleRecordPickerRecord) => void;
selectedRecord?: SingleRecordPickerRecord;
onMorphItemSelected: (morphItem?: RecordPickerPickableMorphItem) => void;
focusId: string;
filteredSelectedRecords: SingleRecordPickerRecord[];
};
export const SingleRecordPickerMenuItems = ({
EmptyIcon,
emptyLabel,
recordsToSelect,
pickableMorphItems,
onCancel,
onRecordSelected,
filteredSelectedRecords,
selectedRecord,
onMorphItemSelected,
focusId,
}: SingleRecordPickerMenuItemsProps) => {
const recordsInDropdown = [selectedRecord, ...recordsToSelect].filter(
(entity): entity is SingleRecordPickerRecord =>
isDefined(entity) && isNonEmptyString(entity.name),
);
const recordPickerComponentInstanceId =
useAvailableComponentInstanceIdOrThrow(
SingleRecordPickerComponentInstanceContext,
@@ -79,7 +69,9 @@ export const SingleRecordPickerMenuItems = ({
dependencies: [onCancel, resetSelectedItem],
});
const selectableItemIds = recordsInDropdown.map((entity) => entity.id);
const selectableItemIds = pickableMorphItems.map(
(morphItem) => morphItem.recordId,
);
const [selectedRecordId, setSelectedRecordId] = useRecoilComponentState(
singleRecordPickerSelectedIdComponentState,
);
@@ -92,8 +84,11 @@ export const SingleRecordPickerMenuItems = ({
singleRecordPickerShouldShowInitialLoadingComponentState,
);
const searchHasNoResults =
recordsToSelect.length === 0 && filteredSelectedRecords?.length === 0;
const itemsMatchingSearchFilter = pickableMorphItems.filter(
(morphItem) => morphItem.isMatchingSearchFilter,
);
const searchHasNoResults = itemsMatchingSearchFilter.length === 0;
return (
<SelectableList
@@ -107,13 +102,13 @@ export const SingleRecordPickerMenuItems = ({
itemId="select-none"
onEnter={() => {
setSelectedRecordId(undefined);
onRecordSelected();
onMorphItemSelected();
}}
>
<MenuItemSelect
onClick={() => {
setSelectedRecordId(undefined);
onRecordSelected();
onMorphItemSelected();
}}
LeftIcon={EmptyIcon}
text={emptyLabel}
@@ -127,12 +122,12 @@ export const SingleRecordPickerMenuItems = ({
) : singleRecordPickerShouldShowSkeleton ? (
<RecordPickerLoadingSkeletonList />
) : (
recordsInDropdown?.map((record) => (
itemsMatchingSearchFilter.map((morphItem) => (
<SingleRecordPickerMenuItem
key={record.id}
record={record}
onRecordSelected={onRecordSelected}
selectedRecord={selectedRecord}
key={morphItem.recordId}
morphItem={morphItem}
onMorphItemSelected={onMorphItemSelected}
isRecordSelected={selectedRecordId === morphItem.recordId}
/>
))
)}
@@ -28,11 +28,7 @@ export type SingleRecordPickerMenuItemsWithSearchProps = {
focusId: string;
} & Pick<
SingleRecordPickerMenuItemsProps,
| 'EmptyIcon'
| 'emptyLabel'
| 'onCancel'
| 'onRecordSelected'
| 'selectedRecord'
'EmptyIcon' | 'emptyLabel' | 'onCancel' | 'onMorphItemSelected'
>;
export const SingleRecordPickerMenuItemsWithSearch = ({
@@ -41,7 +37,7 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
excludedRecordIds,
onCancel,
onCreate,
onRecordSelected,
onMorphItemSelected,
objectNameSingulars,
layoutDirection = 'search-bar-on-top',
focusId,
@@ -57,7 +53,7 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
recordPickerInstanceId,
);
const { records } = useSingleRecordPickerRecords({
const { pickableMorphItems, loading } = useSingleRecordPickerRecords({
objectNameSingulars,
excludedRecordIds,
});
@@ -83,7 +79,7 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
return (
<>
<SingleRecordPickerLoadingEffect loading={records.loading} />
<SingleRecordPickerLoadingEffect loading={loading} />
{layoutDirection === 'search-bar-on-bottom' && (
<>
{isDefined(onCreate) && hasUpdatePermissions && (
@@ -102,14 +98,12 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
<DropdownMenuItemsContainer hasMaxHeight>
<SingleRecordPickerMenuItems
focusId={focusId}
recordsToSelect={records.recordsToSelect}
selectedRecord={records.selectedRecords?.[0]}
filteredSelectedRecords={records.filteredSelectedRecords}
pickableMorphItems={pickableMorphItems}
onMorphItemSelected={onMorphItemSelected}
{...{
EmptyIcon,
emptyLabel,
onCancel,
onRecordSelected,
}}
/>
</DropdownMenuItemsContainer>
@@ -127,14 +121,12 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
<DropdownMenuItemsContainer hasMaxHeight>
<SingleRecordPickerMenuItems
focusId={focusId}
recordsToSelect={records.recordsToSelect}
selectedRecord={records.selectedRecords?.[0]}
filteredSelectedRecords={records.filteredSelectedRecords}
pickableMorphItems={pickableMorphItems}
onMorphItemSelected={onMorphItemSelected}
{...{
EmptyIcon,
emptyLabel,
onCancel,
onRecordSelected,
}}
/>
</DropdownMenuItemsContainer>
@@ -5,24 +5,31 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { allMockPersonRecords } from '~/testing/mock-data/people';
import { sleep } from '~/utils/sleep';
import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
import { type SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { IconUserCircle } from 'twenty-ui/display';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
const records = allMockPersonRecords.map<SingleRecordPickerRecord>(
(person) => ({
id: person.id,
name: person.name.firstName + ' ' + person.name.lastName,
avatarUrl: 'https://picsum.photos/200',
avatarType: 'rounded',
record: { ...person, __typename: 'Person' },
}),
);
// const records = allMockPersonRecords.map<SearchRecord>((person) => ({
// id: person.id,
// label: person.name.firstName + ' ' + person.name.lastName,
// imageUrl: 'https://picsum.photos/200',
// objectNameSingular: 'Person',
// recordId: person.id,
// tsRank: 0,
// tsRankCD: 0,
// }));
// const pickableMorphItems = records.map<RecordPickerPickableMorphItem>(
// (record) => ({
// recordId: record.recordId,
// objectMetadataId: record.objectNameSingular,
// isSelected: false,
// isMatchingSearchFilter: true,
// }),
// );
const meta: Meta<typeof SingleRecordPicker> = {
title: 'UI/RecordPicker/SingleRecordPicker',
@@ -37,15 +44,7 @@ const meta: Meta<typeof SingleRecordPicker> = {
objectNameSingulars: [CoreObjectNameSingular.WorkspaceMember],
componentInstanceId: 'single-record-picker',
},
argTypes: {
selectedRecord: {
options: records.map(({ name }) => name),
mapping: records.reduce(
(result, entity) => ({ ...result, [entity.name]: entity }),
{},
),
},
},
argTypes: {},
parameters: {
msw: graphqlMocks,
},
@@ -56,9 +55,7 @@ type Story = StoryObj<typeof SingleRecordPicker>;
export const Default: Story = {};
export const WithSelectedRecord: Story = {
args: { selectedRecord: records[2] },
};
export const WithSelectedRecord: Story = {};
export const WithEmptyOption: Story = {
args: {
@@ -0,0 +1,119 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit';
import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords';
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { useRecoilCallback } from 'recoil';
import { CustomError, isDefined } from 'twenty-shared/utils';
import { type SearchQuery } from '~/generated/graphql';
export const useSingleRecordPickerPerformSearch = ({
selectedIds,
limit,
excludedRecordIds = [],
objectNameSingulars,
searchFilter,
}: {
selectedIds: string[];
limit?: number;
excludedRecordIds?: string[];
objectNameSingulars: string[];
searchFilter?: string;
}): {
pickableMorphItems: RecordPickerPickableMorphItem[];
loading: boolean;
} => {
const onSearchRecordsCompleted = useRecoilCallback(
({ set }) =>
(data: SearchQuery) => {
const searchRecords = data.search.edges.map((edge) => edge.node);
searchRecords.forEach((searchRecord) => {
set(searchRecordStoreFamilyState(searchRecord.recordId), {
...searchRecord,
record: undefined,
});
});
},
[],
);
const { objectMetadataItems } = useObjectMetadataItems();
const selectedIdsFilter = { id: { in: selectedIds } };
const { loading: selectedRecordsLoading, searchRecords: selectedRecords } =
useObjectRecordSearchRecords({
objectNameSingulars,
filter: selectedIdsFilter,
skip: !selectedIds.length,
searchInput: '',
onCompleted: onSearchRecordsCompleted,
});
const {
loading: filteredSelectedRecordsLoading,
searchRecords: filteredSelectedRecords,
} = useObjectRecordSearchRecords({
objectNameSingulars,
filter: selectedIdsFilter,
skip: !selectedIds.length,
searchInput: searchFilter,
onCompleted: onSearchRecordsCompleted,
});
const notFilterIds = [...selectedIds, ...excludedRecordIds];
const notFilter = notFilterIds.length
? { not: { id: { in: notFilterIds } } }
: undefined;
const { loading: recordsToSelectLoading, searchRecords: recordsToSelect } =
useObjectRecordSearchRecords({
objectNameSingulars,
filter: notFilter,
limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT,
searchInput: searchFilter,
fetchPolicy: 'cache-and-network',
onCompleted: onSearchRecordsCompleted,
});
const pickableMorphItems = [...selectedRecords, ...recordsToSelect].map(
(record) => {
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === record.objectNameSingular,
);
if (!isDefined(objectMetadataItem)) {
throw new CustomError(
'Object metadata item not found for object name singular: ' +
record.objectNameSingular,
'OBJECT_METADATA_ITEM_NOT_FOUND',
);
}
return {
recordId: record.recordId,
objectMetadataId: objectMetadataItem.id,
isSelected: selectedRecords.some(
(selectedRecord) => selectedRecord.recordId === record.recordId,
),
isMatchingSearchFilter:
recordsToSelect.some(
(recordsToSelectRecord) =>
recordsToSelectRecord.recordId === record.recordId,
) ||
filteredSelectedRecords.some(
(filteredSelectedRecord) =>
filteredSelectedRecord.recordId === record.recordId,
),
};
},
);
return {
pickableMorphItems,
loading:
recordsToSelectLoading ||
filteredSelectedRecordsLoading ||
selectedRecordsLoading,
};
};
@@ -1,6 +1,6 @@
import { useSingleRecordPickerPerformSearch } from '@/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerPerformSearch';
import { singleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchFilterComponentState';
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { useFilteredSearchRecordQuery } from '@/search/hooks/useFilteredSearchRecordQuery';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
export const useSingleRecordPickerRecords = ({
@@ -17,12 +17,12 @@ export const useSingleRecordPickerRecords = ({
const selectedRecordId = useRecoilComponentValue(
singleRecordPickerSelectedIdComponentState,
);
const records = useFilteredSearchRecordQuery({
const { pickableMorphItems, loading } = useSingleRecordPickerPerformSearch({
searchFilter: recordPickerSearchFilter,
selectedIds: selectedRecordId ? [selectedRecordId] : [],
excludedRecordIds: excludedRecordIds,
objectNameSingulars,
});
return { records };
return { pickableMorphItems, loading };
};
@@ -1,6 +0,0 @@
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
export type SingleRecordPickerRecord = ObjectRecordIdentifier & {
record: ObjectRecord;
};
@@ -1,176 +0,0 @@
import { gql } from '@apollo/client';
export const query = gql`
query FindManyPeople(
$filter: PersonFilterInput
$orderBy: [PersonOrderByInput]
$lastCursor: String
$limit: Int = 60
) {
people(
filter: $filter
orderBy: $orderBy
first: $limit
after: $lastCursor
) {
edges {
node {
id
opportunities {
edges {
node {
id
personId
pointOfContactId
updatedAt
companyId
stage
closeDate
amount {
amountMicros
currencyCode
}
id
createdAt
}
}
}
xLink {
primaryLinkLabel
primaryLinkUrl
}
id
pointOfContactForOpportunities {
edges {
node {
id
personId
pointOfContactId
updatedAt
companyId
stage
closeDate
amount {
amountMicros
currencyCode
}
id
createdAt
}
}
}
createdAt
company {
id
xLink {
primaryLinkLabel
primaryLinkUrl
}
linkedinLink {
primaryLinkLabel
primaryLinkUrl
}
domainName
annualRecurringRevenue {
amountMicros
currencyCode
}
createdAt
address {
adressCity
}
updatedAt
name
accountOwnerId
employees
id
idealCustomerProfile
}
city
email
jobTitle
favorites {
edges {
node {
id
id
companyId
createdAt
forWorkspaceMemberId
personId
position
updatedAt
}
}
}
attachments {
edges {
node {
id
updatedAt
createdAt
name
personId
companyId
id
authorId
type
fullPath
}
}
}
name {
firstName
lastName
}
phone
linkedinLink {
primaryLinkLabel
primaryLinkUrl
}
updatedAt
avatarUrl
companyId
}
cursor
}
pageInfo {
hasNextPage
startCursor
endCursor
}
}
}
`;
export const variables = {
recordsToSelect: {
limit: 10,
filter: {
and: [
{ and: [{ or: [{ name: { ilike: '%Entity%' } }] }] },
{ not: { id: { in: ['1', '2'] } } },
],
},
orderBy: [{ name: 'AscNullsLast' }],
},
filteredSelectedRecords: {
limit: 60,
filter: {
and: [
{ and: [{ or: [{ name: { ilike: '%Entity%' } }] }] },
{ id: { in: ['1'] } },
],
},
orderBy: [{ name: 'AscNullsLast' }],
},
selectedEntities: {
limit: 60,
filter: { id: { in: ['1'] } },
orderBy: [{ name: 'AscNullsLast' }],
},
};
export const responseData = {
edges: [],
};
@@ -1,106 +0,0 @@
import { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type MultipleRecordPickerRecords } from '@/object-record/record-picker/multiple-record-picker/types/MultipleRecordPickerRecords';
import { SnackBarComponentInstanceContext } from '@/ui/feedback/snack-bar-manager/contexts/SnackBarComponentInstanceContext';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import {
query,
responseData,
variables,
} from '../__mocks__/useFilteredSearchRecordQuery';
import { useFilteredSearchRecordQuery } from '../useFilteredSearchRecordQuery';
const mocks = [
{
request: {
query,
variables: variables.recordsToSelect,
},
result: jest.fn(() => ({
data: {
people: responseData,
},
})),
},
{
request: {
query,
variables: variables.filteredSelectedRecords,
},
result: jest.fn(() => ({
data: {
people: responseData,
},
})),
},
{
request: {
query,
variables: variables.selectedEntities,
},
result: jest.fn(() => ({
data: {
people: responseData,
},
})),
},
];
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider mocks={mocks} addTypename={false}>
<SnackBarComponentInstanceContext.Provider
value={{ instanceId: 'snack-bar-manager' }}
>
{children}
</SnackBarComponentInstanceContext.Provider>
</MockedProvider>
</RecoilRoot>
);
describe('useFilteredSearchRecordQuery', () => {
it('returns the correct result when everything is provided', async () => {
const { result } = renderHook(
() => {
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
setCurrentWorkspaceMember({
id: '32219445-f587-4c40-b2b1-6d3205ed96da',
name: { firstName: 'John', lastName: 'Connor' },
locale: 'en',
colorScheme: 'Light',
userEmail: 'userEmail',
});
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
setMetadataItems(generatedMockObjectMetadataItems);
return useFilteredSearchRecordQuery({
selectedIds: ['1'],
limit: 10,
excludedRecordIds: ['2'],
objectNameSingulars: ['person'],
searchFilter: 'Entity',
});
},
{ wrapper: Wrapper },
);
const expectedResult: MultipleRecordPickerRecords<any> = {
selectedRecords: [],
filteredSelectedRecords: [],
recordsToSelect: [],
loading: true,
};
expect(result.current).toEqual(expectedResult);
});
});
@@ -1,73 +0,0 @@
import { formatSearchRecordAsSingleRecordPickerRecord } from '@/object-metadata/utils/formatSearchRecordAsSingleRecordPickerRecord';
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit';
import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords';
import { type SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { isDefined } from 'twenty-shared/utils';
export const useFilteredSearchRecordQuery = ({
selectedIds,
limit,
excludedRecordIds = [],
objectNameSingulars,
searchFilter,
}: {
selectedIds: string[];
limit?: number;
excludedRecordIds?: string[];
objectNameSingulars: string[];
searchFilter?: string;
}): {
selectedRecords: SingleRecordPickerRecord[];
filteredSelectedRecords: SingleRecordPickerRecord[];
recordsToSelect: SingleRecordPickerRecord[];
loading: boolean;
} => {
const selectedIdsFilter = { id: { in: selectedIds } };
const { loading: selectedRecordsLoading, searchRecords: selectedRecords } =
useObjectRecordSearchRecords({
objectNameSingulars,
filter: selectedIdsFilter,
skip: !selectedIds.length,
searchInput: '',
});
const {
loading: filteredSelectedRecordsLoading,
searchRecords: filteredSelectedRecords,
} = useObjectRecordSearchRecords({
objectNameSingulars,
filter: selectedIdsFilter,
skip: !selectedIds.length,
searchInput: searchFilter,
});
const notFilterIds = [...selectedIds, ...excludedRecordIds];
const notFilter = notFilterIds.length
? { not: { id: { in: notFilterIds } } }
: undefined;
const { loading: recordsToSelectLoading, searchRecords: recordsToSelect } =
useObjectRecordSearchRecords({
objectNameSingulars,
filter: notFilter,
limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT,
searchInput: searchFilter,
fetchPolicy: 'cache-and-network',
});
return {
selectedRecords: selectedRecords
.map(formatSearchRecordAsSingleRecordPickerRecord)
.filter(isDefined),
filteredSelectedRecords: filteredSelectedRecords
.map(formatSearchRecordAsSingleRecordPickerRecord)
.filter(isDefined),
recordsToSelect: recordsToSelect
.map(formatSearchRecordAsSingleRecordPickerRecord)
.filter(isDefined),
loading:
recordsToSelectLoading ||
filteredSelectedRecordsLoading ||
selectedRecordsLoading,
};
};
@@ -1,7 +0,0 @@
import { type DocumentNode } from 'graphql';
export type SearchConfigType = {
query: DocumentNode;
template: (searchInput: string, currentSelectedId?: string) => any;
resultMapper: (data: any) => any;
};