Refactor MultipleObjectsPicker component (#10552)

Refactor to only have MultipleRecordPicker and SingleRecordPicker

What's done:
- SingleRecordPicker, MultipleRecordPicker
- RelationToOneInput
- RelationFromManyInput
- usage in TableCell, InlineCell, RelationDetailSection, Workflow

What's left:
- Make a pass on the app, to make sure the hotkeyScopes, clickOutside
are properly set
- Fix flashing on ActivityTarget
- add more tests on the code
This commit is contained in:
Charles Bochet
2025-03-10 15:04:09 +01:00
committed by GitHub
parent 7eabcc8774
commit f0de6d31b7
126 changed files with 2465 additions and 2242 deletions

View File

@ -1,35 +0,0 @@
import { objectRecordsIdsMultiSelecComponentState } from '@/activities/states/objectRecordsIdsMultiSelectComponentState';
import { objectRecordMultiSelectCheckedRecordsIdsComponentState } from '@/object-record/record-field/states/objectRecordMultiSelectCheckedRecordsIdsComponentState';
import { objectRecordMultiSelectComponentFamilyState } from '@/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState';
import { recordMultiSelectIsLoadingComponentState } from '@/object-record/record-field/states/recordMultiSelectIsLoadingComponentState';
import { extractComponentFamilyState } from '@/ui/utilities/state/component-state/utils/extractComponentFamilyState';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
export const useObjectRecordMultiSelectScopedStates = (scopeId: string) => {
const objectRecordsIdsMultiSelectState = extractComponentState(
objectRecordsIdsMultiSelecComponentState,
scopeId,
);
const objectRecordMultiSelectCheckedRecordsIdsState = extractComponentState(
objectRecordMultiSelectCheckedRecordsIdsComponentState,
scopeId,
);
const objectRecordMultiSelectFamilyState = extractComponentFamilyState(
objectRecordMultiSelectComponentFamilyState,
scopeId,
);
const recordMultiSelectIsLoadingState = extractComponentState(
recordMultiSelectIsLoadingComponentState,
scopeId,
);
return {
objectRecordsIdsMultiSelectState,
objectRecordMultiSelectCheckedRecordsIdsState,
objectRecordMultiSelectFamilyState,
recordMultiSelectIsLoadingState,
};
};

View File

@ -1,288 +0,0 @@
import { isNull } from '@sniptt/guards';
import { useRecoilCallback, useRecoilState, useSetRecoilState } from 'recoil';
import { v4 } from 'uuid';
import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
import { ActivityTargetInlineCellEditModeMultiRecordsEffect } from '@/activities/inline-cell/components/ActivityTargetInlineCellEditModeMultiRecordsEffect';
import { ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect } from '@/activities/inline-cell/components/ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect';
import { ActivityTargetObjectRecordEffect } from '@/activities/inline-cell/components/ActivityTargetObjectRecordEffect';
import { MultipleObjectRecordOnClickOutsideEffect } from '@/activities/inline-cell/components/MultipleObjectRecordOnClickOutsideEffect';
import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState';
import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject';
import { Note } from '@/activities/types/Note';
import { NoteTarget } from '@/activities/types/NoteTarget';
import { Task } from '@/activities/types/Task';
import { TaskTarget } from '@/activities/types/TaskTarget';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
import { getJoinObjectNameSingular } from '@/activities/utils/getJoinObjectNameSingular';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateManyRecordsInCache } from '@/object-record/cache/hooks/useCreateManyRecordsInCache';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { activityTargetObjectRecordFamilyState } from '@/object-record/record-field/states/activityTargetObjectRecordFamilyState';
import { objectRecordMultiSelectCheckedRecordsIdsComponentState } from '@/object-record/record-field/states/objectRecordMultiSelectCheckedRecordsIdsComponentState';
import {
ObjectRecordAndSelected,
objectRecordMultiSelectComponentFamilyState,
} from '@/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState';
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
import { MultipleRecordPicker } from '@/object-record/record-picker/components/MultipleRecordPicker';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { prefillRecord } from '@/object-record/utils/prefillRecord';
import { useRef } from 'react';
type ActivityTargetInlineCellEditModeProps = {
activity: Task | Note;
activityTargetWithTargetRecords: ActivityTargetWithTargetRecord[];
activityObjectNameSingular:
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task;
};
export const ActivityTargetInlineCellEditMode = ({
activity,
activityTargetWithTargetRecords,
activityObjectNameSingular,
}: ActivityTargetInlineCellEditModeProps) => {
const [isActivityInCreateMode] = useRecoilState(isActivityInCreateModeState);
const recordPickerInstanceId = `record-picker-${activity.id}`;
const selectedTargetObjectIds = activityTargetWithTargetRecords.map(
(activityTarget) => ({
objectNameSingular: activityTarget.targetObjectMetadataItem.nameSingular,
id: activityTarget.targetObject.id,
}),
);
const { createOneRecord: createOneActivityTarget } = useCreateOneRecord<
NoteTarget | TaskTarget
>({
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
});
const { deleteOneRecord: deleteOneActivityTarget } = useDeleteOneRecord({
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
});
const { closeInlineCell: closeEditableField } = useInlineCell();
const { upsertActivity } = useUpsertActivity({
activityObjectNameSingular,
});
const { objectMetadataItem: objectMetadataItemActivityTarget } =
useObjectMetadataItem({
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
});
const setActivityFromStore = useSetRecoilState(
recordStoreFamilyState(activity.id),
);
const { createManyRecordsInCache: createManyActivityTargetsInCache } =
useCreateManyRecordsInCache<NoteTarget | TaskTarget>({
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
});
const handleSubmit = useRecoilCallback(
({ snapshot }) =>
async () => {
const activityTargetsAfterUpdate =
activityTargetWithTargetRecords.filter((activityTarget) => {
const recordSelectedInMultiSelect = snapshot
.getLoadable(
objectRecordMultiSelectComponentFamilyState({
scopeId: recordPickerInstanceId,
familyKey: activityTarget.targetObject.id,
}),
)
.getValue() as ObjectRecordAndSelected;
return recordSelectedInMultiSelect
? recordSelectedInMultiSelect.selected
: true;
});
setActivityFromStore((currentActivity) => {
if (isNull(currentActivity)) {
return null;
}
return {
...currentActivity,
activityTargets: activityTargetsAfterUpdate,
};
});
closeEditableField();
},
[
activityTargetWithTargetRecords,
closeEditableField,
recordPickerInstanceId,
setActivityFromStore,
],
);
const handleChange = useRecoilCallback(
({ snapshot, set }) =>
async (recordId: string) => {
const existingActivityTargets = activityTargetWithTargetRecords.map(
(activityTargetObjectRecord) =>
activityTargetObjectRecord.activityTarget,
);
let activityTargetsAfterUpdate = Array.from(existingActivityTargets);
const previouslyCheckedRecordsIds = snapshot
.getLoadable(
objectRecordMultiSelectCheckedRecordsIdsComponentState({
scopeId: recordPickerInstanceId,
}),
)
.getValue();
const isNewlySelected = !previouslyCheckedRecordsIds.includes(recordId);
if (isNewlySelected) {
const record = snapshot
.getLoadable(
objectRecordMultiSelectComponentFamilyState({
scopeId: recordPickerInstanceId,
familyKey: recordId,
}),
)
.getValue();
if (!record) {
throw new Error(
`Could not find selected record with id ${recordId}`,
);
}
set(
objectRecordMultiSelectCheckedRecordsIdsComponentState({
scopeId: recordPickerInstanceId,
}),
(prev) => [...prev, recordId],
);
const newActivityTargetId = v4();
const fieldNameWithIdSuffix = getActivityTargetObjectFieldIdName({
nameSingular: record.objectMetadataItem.nameSingular,
});
const newActivityTargetInput = {
id: newActivityTargetId,
...(activityObjectNameSingular === CoreObjectNameSingular.Task
? { taskId: activity.id }
: { noteId: activity.id }),
[fieldNameWithIdSuffix]: recordId,
};
const newActivityTarget = prefillRecord<NoteTarget | TaskTarget>({
objectMetadataItem: objectMetadataItemActivityTarget,
input: newActivityTargetInput,
});
activityTargetsAfterUpdate.push(newActivityTarget);
if (isActivityInCreateMode) {
createManyActivityTargetsInCache([newActivityTarget]);
upsertActivity({
activity,
input: {
[activityObjectNameSingular === CoreObjectNameSingular.Task
? 'taskTargets'
: activityObjectNameSingular === CoreObjectNameSingular.Note
? 'noteTargets'
: '']: activityTargetsAfterUpdate,
},
});
} else {
await createOneActivityTarget(newActivityTargetInput);
}
set(activityTargetObjectRecordFamilyState(recordId), {
activityTargetId: newActivityTargetId,
});
} else {
const activityTargetToDeleteId = snapshot
.getLoadable(activityTargetObjectRecordFamilyState(recordId))
.getValue().activityTargetId;
if (!activityTargetToDeleteId) {
throw new Error('Could not delete this activity target.');
}
set(
objectRecordMultiSelectCheckedRecordsIdsComponentState({
scopeId: recordPickerInstanceId,
}),
previouslyCheckedRecordsIds.filter((id) => id !== recordId),
);
activityTargetsAfterUpdate = activityTargetsAfterUpdate.filter(
(activityTarget) => activityTarget.id !== activityTargetToDeleteId,
);
if (isActivityInCreateMode) {
upsertActivity({
activity,
input: {
[activityObjectNameSingular === CoreObjectNameSingular.Task
? 'taskTargets'
: activityObjectNameSingular === CoreObjectNameSingular.Note
? 'noteTargets'
: '']: activityTargetsAfterUpdate,
},
});
} else {
await deleteOneActivityTarget(activityTargetToDeleteId);
}
set(activityTargetObjectRecordFamilyState(recordId), {
activityTargetId: null,
});
}
},
[
activity,
activityTargetWithTargetRecords,
createOneActivityTarget,
createManyActivityTargetsInCache,
deleteOneActivityTarget,
isActivityInCreateMode,
objectMetadataItemActivityTarget,
recordPickerInstanceId,
upsertActivity,
activityObjectNameSingular,
],
);
const containerRef = useRef<HTMLDivElement>(null);
return (
<>
<ActivityTargetObjectRecordEffect
activityTargetWithTargetRecords={activityTargetWithTargetRecords}
/>
<ActivityTargetInlineCellEditModeMultiRecordsEffect
recordPickerInstanceId={recordPickerInstanceId}
selectedObjectRecordIds={selectedTargetObjectIds}
/>
<ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect
recordPickerInstanceId={recordPickerInstanceId}
/>
<MultipleObjectRecordOnClickOutsideEffect
containerRef={containerRef}
onClickOutside={closeEditableField}
/>
<div ref={containerRef}>
<MultipleRecordPicker
onSubmit={handleSubmit}
onChange={handleChange}
componentInstanceId={recordPickerInstanceId}
/>
</div>
</>
);
};

View File

@ -1,111 +0,0 @@
import { useEffect } from 'react';
import {
useRecoilCallback,
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from 'recoil';
import { useObjectRecordMultiSelectScopedStates } from '@/activities/hooks/useObjectRecordMultiSelectScopedStates';
import { objectRecordMultiSelectComponentFamilyState } from '@/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState';
import { objectRecordMultiSelectMatchesFilterRecordsIdsComponentState } from '@/object-record/record-field/states/objectRecordMultiSelectMatchesFilterRecordsIdsComponentState';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { ObjectRecordForSelect } from '@/object-record/types/ObjectRecordForSelect';
import { SelectedObjectRecordId } from '@/object-record/types/SelectedObjectRecordId';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
// Todo: this effect should be deprecated to use sync hooks
export const ActivityTargetInlineCellEditModeMultiRecordsEffect = ({
recordPickerInstanceId,
selectedObjectRecordIds,
}: {
recordPickerInstanceId: string;
selectedObjectRecordIds: SelectedObjectRecordId[];
}) => {
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordPickerComponentInstanceContext,
recordPickerInstanceId,
);
const {
objectRecordsIdsMultiSelectState,
objectRecordMultiSelectCheckedRecordsIdsState,
} = useObjectRecordMultiSelectScopedStates(instanceId);
const [objectRecordsIdsMultiSelect, setObjectRecordsIdsMultiSelect] =
useRecoilState(objectRecordsIdsMultiSelectState);
const setObjectRecordMultiSelectCheckedRecordsIds = useSetRecoilState(
objectRecordMultiSelectCheckedRecordsIdsState,
);
const updateRecords = useRecoilCallback(
({ snapshot, set }) =>
(newRecords: ObjectRecordForSelect[]) => {
for (const newRecord of newRecords) {
const currentRecord = snapshot
.getLoadable(
objectRecordMultiSelectComponentFamilyState({
scopeId: instanceId,
familyKey: newRecord.record.id,
}),
)
.getValue();
const objectRecordMultiSelectCheckedRecordsIds = snapshot
.getLoadable(objectRecordMultiSelectCheckedRecordsIdsState)
.getValue();
const newRecordWithSelected = {
...newRecord,
selected: objectRecordMultiSelectCheckedRecordsIds.some(
(checkedRecordId) => checkedRecordId === newRecord.record.id,
),
};
if (
!isDeeplyEqual(
newRecordWithSelected.selected,
currentRecord?.selected,
)
) {
set(
objectRecordMultiSelectComponentFamilyState({
scopeId: instanceId,
familyKey: newRecordWithSelected.record.id,
}),
newRecordWithSelected,
);
}
}
},
[objectRecordMultiSelectCheckedRecordsIdsState, instanceId],
);
const matchesSearchFilterObjectRecords = useRecoilValue(
objectRecordMultiSelectMatchesFilterRecordsIdsComponentState({
scopeId: instanceId,
}),
);
useEffect(() => {
const allRecords = matchesSearchFilterObjectRecords ?? [];
updateRecords(allRecords);
const allRecordsIds = allRecords.map((record) => record.record.id);
if (!isDeeplyEqual(allRecordsIds, objectRecordsIdsMultiSelect)) {
setObjectRecordsIdsMultiSelect(allRecordsIds);
}
}, [
matchesSearchFilterObjectRecords,
objectRecordsIdsMultiSelect,
setObjectRecordsIdsMultiSelect,
updateRecords,
]);
useEffect(() => {
setObjectRecordMultiSelectCheckedRecordsIds(
selectedObjectRecordIds.map((rec) => rec.id),
);
}, [selectedObjectRecordIds, setObjectRecordMultiSelectCheckedRecordsIds]);
return <></>;
};

View File

@ -1,51 +0,0 @@
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray } from '@/activities/inline-cell/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
import { useMultiObjectSearch } from '@/activities/inline-cell/hooks/useMultiObjectSearch';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { objectRecordMultiSelectMatchesFilterRecordsIdsComponentState } from '@/object-record/record-field/states/objectRecordMultiSelectMatchesFilterRecordsIdsComponentState';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { recordPickerSearchFilterComponentState } from '@/object-record/record-picker/states/recordPickerSearchFilterComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
// Todo: this effect should be deprecated to use sync hooks
export const ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect = ({
recordPickerInstanceId,
}: {
recordPickerInstanceId: string;
}) => {
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordPickerComponentInstanceContext,
recordPickerInstanceId,
);
const setRecordMultiSelectMatchesFilterRecords = useSetRecoilState(
objectRecordMultiSelectMatchesFilterRecordsIdsComponentState({
scopeId: instanceId,
}),
);
const recordPickerSearchFilter = useRecoilComponentValueV2(
recordPickerSearchFilterComponentState,
instanceId,
);
const { matchesSearchFilterObjectRecordsQueryResult } = useMultiObjectSearch({
excludedObjects: [CoreObjectNameSingular.Task, CoreObjectNameSingular.Note],
searchFilterValue: recordPickerSearchFilter,
limit: 10,
});
const { objectRecordForSelectArray } =
useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({
multiObjectRecordsQueryResult:
matchesSearchFilterObjectRecordsQueryResult,
});
useEffect(() => {
setRecordMultiSelectMatchesFilterRecords(objectRecordForSelectArray);
}, [setRecordMultiSelectMatchesFilterRecords, objectRecordForSelectArray]);
return <></>;
};

View File

@ -1,42 +0,0 @@
import { useEffect } from 'react';
import { useRecoilCallback } from 'recoil';
import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject';
import { activityTargetObjectRecordFamilyState } from '@/object-record/record-field/states/activityTargetObjectRecordFamilyState';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const ActivityTargetObjectRecordEffect = ({
activityTargetWithTargetRecords,
}: {
activityTargetWithTargetRecords: ActivityTargetWithTargetRecord[];
}) => {
const updateActivityTargets = useRecoilCallback(
({ snapshot, set }) =>
(newActivityTargets: ActivityTargetWithTargetRecord[]) => {
for (const newActivityTarget of newActivityTargets) {
const objectRecordId = newActivityTarget.targetObject.id;
const record = snapshot
.getLoadable(activityTargetObjectRecordFamilyState(objectRecordId))
.getValue();
if (
!isDeeplyEqual(
record.activityTargetId,
newActivityTarget.activityTarget.id,
)
) {
set(activityTargetObjectRecordFamilyState(objectRecordId), {
activityTargetId: newActivityTarget.activityTarget.id,
});
}
}
},
[],
);
useEffect(() => {
updateActivityTargets(activityTargetWithTargetRecords);
}, [activityTargetWithTargetRecords, updateActivityTargets]);
return <></>;
};

View File

@ -4,8 +4,8 @@ import { IconArrowUpRight, IconPencil } from 'twenty-ui';
import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips';
import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords';
import { ActivityTargetInlineCellEditMode } from '@/activities/inline-cell/components/ActivityTargetInlineCellEditMode';
import { useOpenActivityTargetInlineCellEditMode } from '@/activities/inline-cell/hooks/useOpenActivityTargetInlineCellEditMode';
import { useUpdateActivityTargetFromInlineCell } from '@/activities/inline-cell/hooks/useUpdateActivityTargetFromInlineCell';
import { ActivityEditorHotkeyScope } from '@/activities/types/ActivityEditorHotkeyScope';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
@ -18,6 +18,7 @@ import { RecordFieldInputScope } from '@/object-record/record-field/scopes/Recor
import { RecordInlineCellContainer } from '@/object-record/record-inline-cell/components/RecordInlineCellContainer';
import { RecordInlineCellContext } from '@/object-record/record-inline-cell/components/RecordInlineCellContext';
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
type ActivityTargetsInlineCellProps = {
@ -38,6 +39,8 @@ export const ActivityTargetsInlineCell = ({
const { activityTargetObjectRecords } =
useActivityTargetObjectRecords(activity);
const multipleRecordPickerInstanceId = `multiple-record-picker-target-${activity.id}`;
const { closeInlineCell } = useInlineCell();
const { fieldDefinition } = useContext(FieldContext);
@ -64,6 +67,12 @@ export const ActivityTargetsInlineCell = ({
const { openActivityTargetInlineCellEditMode } =
useOpenActivityTargetInlineCellEditMode();
const { updateActivityTargetFromInlineCell } =
useUpdateActivityTargetFromInlineCell({
activityObjectNameSingular,
activityId: activity.id,
});
return (
<RecordFieldInputScope recordFieldInputScopeId={activity?.id ?? ''}>
<FieldFocusContextProvider>
@ -72,20 +81,27 @@ export const ActivityTargetsInlineCell = ({
<RecordInlineCellContext.Provider
value={{
buttonIcon: IconPencil,
customEditHotkeyScope: {
scope: ActivityEditorHotkeyScope.ActivityTargets,
},
customEditHotkeyScope:
ActivityEditorHotkeyScope.ActivityTargets,
IconLabel: showLabel ? IconArrowUpRight : undefined,
showLabel: showLabel,
readonly: isFieldReadOnly,
labelWidth: fieldDefinition?.labelWidth,
editModeContent: (
<ActivityTargetInlineCellEditMode
activity={activity}
activityTargetWithTargetRecords={
activityTargetObjectRecords
}
activityObjectNameSingular={activityObjectNameSingular}
<MultipleRecordPicker
componentInstanceId={multipleRecordPickerInstanceId}
onClickOutside={() => {}}
onChange={(morphItem) => {
updateActivityTargetFromInlineCell({
recordPickerInstanceId: multipleRecordPickerInstanceId,
morphItem,
activityTargetWithTargetRecords:
activityTargetObjectRecords,
});
}}
onSubmit={() => {
closeInlineCell();
}}
/>
),
label: 'Relations',
@ -97,7 +113,8 @@ export const ActivityTargetsInlineCell = ({
),
onOpenEditMode: () => {
openActivityTargetInlineCellEditMode({
recordPickerInstanceId: `record-picker-${activity.id}`,
recordPickerInstanceId: multipleRecordPickerInstanceId,
activityTargetObjectRecords,
});
},
}}

View File

@ -1,40 +0,0 @@
import { useEffect } from 'react';
import { RECORD_PICKER_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-picker/constants/RecordPickerClickOutsideListenerId';
import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
// Todo: this effect should be deprecated to use sync hooks
export const MultipleObjectRecordOnClickOutsideEffect = ({
containerRef,
onClickOutside,
}: {
containerRef: React.RefObject<HTMLDivElement>;
onClickOutside: () => void;
}) => {
const { toggleClickOutsideListener: toggleRightDrawerClickOustideListener } =
useClickOutsideListener(RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID);
useEffect(() => {
toggleRightDrawerClickOustideListener(false);
return () => {
toggleRightDrawerClickOustideListener(true);
};
}, [toggleRightDrawerClickOustideListener]);
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
event.stopPropagation();
event.preventDefault();
onClickOutside();
},
listenerId: RECORD_PICKER_CLICK_OUTSIDE_LISTENER_ID,
});
return <></>;
};

View File

@ -1,95 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import { useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray } from '@/activities/inline-cell/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
const instanceId = 'instanceId';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<RecordPickerComponentInstanceContext.Provider value={{ instanceId }}>
<RecoilRoot>{children}</RecoilRoot>
</RecordPickerComponentInstanceContext.Provider>
);
const opportunityId = 'cb702502-4b1d-488e-9461-df3fb096ebf6';
const personId = 'ab091fd9-1b81-4dfd-bfdb-564ffee032a2';
describe('useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray', () => {
it('should return object formatted from objectMetadataItemsState', async () => {
const { result } = renderHook(
() => {
return {
formattedRecord:
useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray(
{
multiObjectRecordsQueryResult: {
opportunities: {
edges: [
{
node: {
id: opportunityId,
pointOfContactId:
'e992bda7-d797-4e12-af04-9b427f42244c',
updatedAt: '2023-11-30T11:13:15.308Z',
createdAt: '2023-11-30T11:13:15.308Z',
__typename: 'Opportunity',
},
cursor: 'cursor',
__typename: 'OpportunityEdge',
},
],
pageInfo: {},
},
people: {
edges: [
{
node: {
id: personId,
updatedAt: '2023-11-30T11:13:15.308Z',
createdAt: '2023-11-30T11:13:15.308Z',
__typename: 'Person',
},
cursor: 'cursor',
__typename: 'PersonEdge',
},
],
pageInfo: {},
},
},
},
),
setObjectMetadata: useSetRecoilState(objectMetadataItemsState),
};
},
{
wrapper: Wrapper,
},
);
act(() => {
result.current.setObjectMetadata(generatedMockObjectMetadataItems);
});
expect(
result.current.formattedRecord.objectRecordForSelectArray.length,
).toBe(2);
const [opportunityRecordForSelect, personRecordForSelect] =
result.current.formattedRecord.objectRecordForSelectArray;
expect(opportunityRecordForSelect.objectMetadataItem.namePlural).toBe(
'opportunities',
);
expect(opportunityRecordForSelect.record.id).toBe(opportunityId);
expect(opportunityRecordForSelect.recordIdentifier.linkToShowPage).toBe(
`/object/opportunity/${opportunityId}`,
);
expect(personRecordForSelect.objectMetadataItem.namePlural).toBe('people');
expect(personRecordForSelect.record.id).toBe(personId);
expect(personRecordForSelect.recordIdentifier.linkToShowPage).toBe(
`/object/person/${personId}`,
);
});
});

View File

@ -1,97 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import { useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap } from '@/activities/inline-cell/hooks/useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
const instanceId = 'instanceId';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<RecordPickerComponentInstanceContext.Provider value={{ instanceId }}>
<RecoilRoot>{children}</RecoilRoot>
</RecordPickerComponentInstanceContext.Provider>
);
const opportunityId = 'cb702502-4b1d-488e-9461-df3fb096ebf6';
const personId = 'ab091fd9-1b81-4dfd-bfdb-564ffee032a2';
describe('useMultiObjectRecordsQueryResultFormattedAsObjectRecordsMap', () => {
it('should return object formatted from objectMetadataItemsState', async () => {
const { result } = renderHook(
() => {
return {
formattedRecord:
useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap({
multiObjectRecordsQueryResult: {
opportunities: {
edges: [
{
node: {
id: opportunityId,
pointOfContactId:
'e992bda7-d797-4e12-af04-9b427f42244c',
updatedAt: '2023-11-30T11:13:15.308Z',
createdAt: '2023-11-30T11:13:15.308Z',
__typename: 'Opportunity',
},
cursor: 'cursor',
__typename: 'OpportunityEdge',
},
],
pageInfo: {},
},
people: {
edges: [
{
node: {
id: personId,
updatedAt: '2023-11-30T11:13:15.308Z',
createdAt: '2023-11-30T11:13:15.308Z',
__typename: 'Person',
},
cursor: 'cursor',
__typename: 'PersonEdge',
},
],
pageInfo: {},
},
},
}),
setObjectMetadata: useSetRecoilState(objectMetadataItemsState),
};
},
{
wrapper: Wrapper,
},
);
act(() => {
result.current.setObjectMetadata(generatedMockObjectMetadataItems);
});
expect(
Object.values(result.current.formattedRecord.objectRecordsMap).flat()
.length,
).toBe(2);
const opportunityObjectRecords =
result.current.formattedRecord.objectRecordsMap.opportunities;
const personObjectRecords =
result.current.formattedRecord.objectRecordsMap.people;
expect(opportunityObjectRecords[0].objectMetadataItem.namePlural).toBe(
'opportunities',
);
expect(opportunityObjectRecords[0].record.id).toBe(opportunityId);
expect(opportunityObjectRecords[0].recordIdentifier.linkToShowPage).toBe(
`/object/opportunity/${opportunityId}`,
);
expect(personObjectRecords[0].objectMetadataItem.namePlural).toBe('people');
expect(personObjectRecords[0].record.id).toBe(personId);
expect(personObjectRecords[0].recordIdentifier.linkToShowPage).toBe(
`/object/person/${personId}`,
);
});
});

View File

@ -1,55 +0,0 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsByNamePluralMapSelector } from '@/object-metadata/states/objectMetadataItemsByNamePluralMapSelector';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
import { MultiObjectRecordQueryResult } from '@/object-record/multiple-objects/types/MultiObjectRecordQueryResult';
import { formatMultiObjectRecordSearchResults } from '@/object-record/multiple-objects/utils/formatMultiObjectRecordSearchResults';
import { ObjectRecordForSelect } from '@/object-record/types/ObjectRecordForSelect';
import { isDefined } from 'twenty-shared';
export const useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray =
({
multiObjectRecordsQueryResult,
}: {
multiObjectRecordsQueryResult:
| MultiObjectRecordQueryResult
| null
| undefined;
}) => {
const objectMetadataItemsByNamePluralMap = useRecoilValue(
objectMetadataItemsByNamePluralMapSelector,
);
const formattedMultiObjectRecordsQueryResult = useMemo(() => {
return formatMultiObjectRecordSearchResults(
multiObjectRecordsQueryResult,
);
}, [multiObjectRecordsQueryResult]);
const objectRecordForSelectArray = useMemo(() => {
return Object.entries(
formattedMultiObjectRecordsQueryResult ?? {},
).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[];
});
}, [
formattedMultiObjectRecordsQueryResult,
objectMetadataItemsByNamePluralMap,
]);
return {
objectRecordForSelectArray,
};
};

View File

@ -1,62 +0,0 @@
import { useQuery } from '@apollo/client';
import { useRecoilValue } from 'recoil';
import { useLimitPerMetadataItem } from '@/object-metadata/hooks/useLimitPerMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
import { useGenerateCombinedSearchRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedSearchRecordsQuery';
import { MultiObjectRecordQueryResult } from '@/object-record/multiple-objects/types/MultiObjectRecordQueryResult';
import { isDefined } from 'twenty-shared';
export const useMultiObjectSearch = ({
searchFilterValue,
limit,
excludedObjects,
}: {
searchFilterValue: string;
limit?: number;
excludedObjects?: CoreObjectNameSingular[];
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const selectableObjectMetadataItems = objectMetadataItems.filter(
({ nameSingular, isSearchable }) =>
!excludedObjects?.includes(nameSingular as CoreObjectNameSingular) &&
isSearchable,
);
const { limitPerMetadataItem } = useLimitPerMetadataItem({
objectMetadataItems,
limit,
});
const multiSelectSearchQueryForSelectedIds =
useGenerateCombinedSearchRecordsQuery({
operationSignatures: selectableObjectMetadataItems.map(
(objectMetadataItem) => ({
objectNameSingular: objectMetadataItem.nameSingular,
variables: {},
}),
),
});
const {
loading: matchesSearchFilterObjectRecordsLoading,
data: matchesSearchFilterObjectRecordsQueryResult,
} = useQuery<MultiObjectRecordQueryResult>(
multiSelectSearchQueryForSelectedIds ?? EMPTY_QUERY,
{
variables: {
search: searchFilterValue,
...limitPerMetadataItem,
},
skip: !isDefined(multiSelectSearchQueryForSelectedIds),
},
);
return {
matchesSearchFilterObjectRecordsLoading,
matchesSearchFilterObjectRecordsQueryResult,
};
};

View File

@ -1,61 +0,0 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsByNamePluralMapSelector } from '@/object-metadata/states/objectMetadataItemsByNamePluralMapSelector';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
import { MultiObjectRecordQueryResult } from '@/object-record/multiple-objects/types/MultiObjectRecordQueryResult';
import { formatMultiObjectRecordSearchResults } from '@/object-record/multiple-objects/utils/formatMultiObjectRecordSearchResults';
import { ObjectRecordForSelect } from '@/object-record/types/ObjectRecordForSelect';
import { isDefined } from 'twenty-shared';
export const useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap = ({
multiObjectRecordsQueryResult,
}: {
multiObjectRecordsQueryResult:
| MultiObjectRecordQueryResult
| null
| undefined;
}) => {
const objectMetadataItemsByNamePluralMap = useRecoilValue(
objectMetadataItemsByNamePluralMapSelector,
);
const formattedMultiObjectRecordsQueryResult = useMemo(() => {
return formatMultiObjectRecordSearchResults(multiObjectRecordsQueryResult);
}, [multiObjectRecordsQueryResult]);
const objectRecordsMap = useMemo(() => {
const recordsByNamePlural: { [key: string]: ObjectRecordForSelect[] } = {};
Object.entries(formattedMultiObjectRecordsQueryResult ?? {}).forEach(
([namePlural, objectRecordConnection]) => {
const objectMetadataItem =
objectMetadataItemsByNamePluralMap.get(namePlural);
if (!isDefined(objectMetadataItem)) return [];
if (!isDefined(recordsByNamePlural[namePlural])) {
recordsByNamePlural[namePlural] = [];
}
objectRecordConnection.edges.forEach(({ node }) => {
const record = {
objectMetadataItem,
record: node,
recordIdentifier: getObjectRecordIdentifier({
objectMetadataItem,
record: node,
}),
} as ObjectRecordForSelect;
recordsByNamePlural[namePlural].push(record);
});
},
);
return recordsByNamePlural;
}, [
formattedMultiObjectRecordsQueryResult,
objectMetadataItemsByNamePluralMap,
]);
return {
objectRecordsMap,
};
};

View File

@ -1,100 +0,0 @@
import { gql, useQuery } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray } from '@/activities/inline-cell/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
import { useLimitPerMetadataItem } from '@/object-metadata/hooks/useLimitPerMetadataItem';
import { useOrderByFieldPerMetadataItem } from '@/object-metadata/hooks/useOrderByFieldPerMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery';
import { MultiObjectRecordQueryResult } from '@/object-record/multiple-objects/types/MultiObjectRecordQueryResult';
import { SelectedObjectRecordId } from '@/object-record/types/SelectedObjectRecordId';
import { capitalize, isDefined } from 'twenty-shared';
export const EMPTY_QUERY = gql`
query Empty {
__typename
}
`;
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 =
useGenerateCombinedFindManyRecordsQuery({
operationSignatures: objectMetadataItemsUsedInSelectedIdsQuery.map(
(objectMetadataItem) => ({
objectNameSingular: objectMetadataItem.nameSingular,
variables: {},
}),
),
});
const {
loading: selectedObjectRecordsLoading,
data: selectedObjectRecordsQueryResult,
} = useQuery<MultiObjectRecordQueryResult>(
multiSelectQueryForSelectedIds ?? EMPTY_QUERY,
{
variables: {
...selectedIdFilterPerMetadataItem,
...orderByFieldPerMetadataItem,
...limitPerMetadataItem,
},
skip: !isDefined(multiSelectQueryForSelectedIds),
},
);
const { objectRecordForSelectArray: selectedObjectRecords } =
useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({
multiObjectRecordsQueryResult: selectedObjectRecordsQueryResult,
});
return {
selectedObjectRecordsLoading,
selectedObjectRecords,
};
};

View File

@ -1,14 +1,90 @@
import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
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 { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { useRecoilCallback } from 'recoil';
type OpenActivityTargetInlineCellEditModeProps = {
recordPickerInstanceId: string;
activityTargetObjectRecords: ActivityTargetWithTargetRecord[];
};
export const useOpenActivityTargetInlineCellEditMode = () => {
const openActivityTargetInlineCellEditMode = ({
recordPickerInstanceId,
}: OpenActivityTargetInlineCellEditModeProps) => {
// eslint-disable-next-line no-console
console.log('openActivityTargetInlineCellEditMode', recordPickerInstanceId);
};
const { toggleClickOutsideListener: toggleRightDrawerClickOustideListener } =
useClickOutsideListener(RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID);
const { performSearch: multipleRecordPickerPerformSearch } =
useMultipleRecordPickerPerformSearch();
const openActivityTargetInlineCellEditMode = useRecoilCallback(
({ set, snapshot }) =>
({
recordPickerInstanceId,
activityTargetObjectRecords,
}: OpenActivityTargetInlineCellEditModeProps) => {
const objectMetadataItems = snapshot
.getLoadable(objectMetadataItemsState)
.getValue()
.filter(
(objectMetadataItem) =>
objectMetadataItem.isSearchable &&
objectMetadataItem.nameSingular !== CoreObjectNameSingular.Task &&
objectMetadataItem.nameSingular !== CoreObjectNameSingular.Note,
);
set(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId: recordPickerInstanceId,
}),
activityTargetObjectRecords.map((activityTargetObjectRecord) => ({
recordId: activityTargetObjectRecord.targetObject.id,
objectMetadataId:
activityTargetObjectRecord.targetObjectMetadataItem.id,
isSelected: true,
isMatchingSearchFilter: true,
})),
);
set(
multipleRecordPickerSearchableObjectMetadataItemsComponentState.atomFamily(
{
instanceId: recordPickerInstanceId,
},
),
objectMetadataItems,
);
set(
multipleRecordPickerSearchFilterComponentState.atomFamily({
instanceId: recordPickerInstanceId,
}),
'',
);
toggleRightDrawerClickOustideListener(false);
multipleRecordPickerPerformSearch({
multipleRecordPickerInstanceId: recordPickerInstanceId,
forceSearchFilter: '',
forceSearchableObjectMetadataItems: objectMetadataItems,
forcePickableMorphItems: activityTargetObjectRecords.map(
(activityTargetObjectRecord) => ({
recordId: activityTargetObjectRecord.targetObject.id,
objectMetadataId:
activityTargetObjectRecord.targetObjectMetadataItem.id,
isSelected: true,
isMatchingSearchFilter: true,
}),
),
});
},
[multipleRecordPickerPerformSearch, toggleRightDrawerClickOustideListener],
);
return { openActivityTargetInlineCellEditMode };
};

View File

@ -0,0 +1,173 @@
import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject';
import { NoteTarget } from '@/activities/types/NoteTarget';
import { TaskTarget } from '@/activities/types/TaskTarget';
import { getJoinObjectNameSingular } from '@/activities/utils/getJoinObjectNameSingular';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isNull } from '@sniptt/guards';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { v4 } from 'uuid';
type UpdateActivityTargetFromInlineCellProps = {
recordPickerInstanceId: string;
morphItem: RecordPickerPickableMorphItem;
activityTargetWithTargetRecords: ActivityTargetWithTargetRecord[];
};
export const useUpdateActivityTargetFromInlineCell = ({
activityObjectNameSingular,
activityId,
}: {
activityObjectNameSingular:
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task;
activityId: string;
}) => {
const { createOneRecord: createOneActivityTarget } = useCreateOneRecord<
NoteTarget | TaskTarget
>({
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
});
const { deleteOneRecord: deleteOneActivityTarget } = useDeleteOneRecord({
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
});
const setActivityFromStore = useSetRecoilState(
recordStoreFamilyState(activityId),
);
const updateActivityTargetFromInlineCell = useRecoilCallback(
({ snapshot }) =>
async ({
morphItem,
activityTargetWithTargetRecords,
}: UpdateActivityTargetFromInlineCellProps) => {
const targetObjectName =
activityObjectNameSingular === CoreObjectNameSingular.Task
? 'task'
: 'note';
const pickedObjectMetadataItem = snapshot
.getLoadable(objectMetadataItemsState)
.getValue()
.find(
(objectMetadataItem) =>
objectMetadataItem.id === morphItem.objectMetadataId,
);
if (!isDefined(pickedObjectMetadataItem)) {
throw new Error('Could not find object metadata item');
}
let activityTargetsAfterUpdate: (TaskTarget | NoteTarget)[] = [];
const existingActivityTarget = activityTargetWithTargetRecords.find(
(activityTarget) =>
activityTarget.targetObject.id === morphItem.recordId,
);
if (isDefined(existingActivityTarget)) {
activityTargetsAfterUpdate = activityTargetWithTargetRecords
.map((activityTarget) => {
if (
activityTarget.targetObject.id === morphItem.recordId &&
!morphItem.isSelected
) {
return undefined;
}
return activityTarget.activityTarget;
})
.filter(isDefined);
if (!morphItem.isSelected) {
await deleteOneActivityTarget(
existingActivityTarget.targetObject.id,
);
}
} else {
const targetRecord = snapshot
.getLoadable(recordStoreFamilyState(morphItem.recordId))
.getValue();
if (!isDefined(targetRecord)) {
return;
}
if (!morphItem.isSelected) {
return;
}
const activityTarget =
activityObjectNameSingular === CoreObjectNameSingular.Task
? {
id: v4(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
__typename: 'TaskTarget',
taskId: activityId,
task: {
id: activityId,
__typename: 'Task',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
[pickedObjectMetadataItem.nameSingular]: targetRecord,
}
: {
id: v4(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
__typename: 'NoteTarget',
noteId: activityId,
note: {
id: activityId,
__typename: 'Note',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
[pickedObjectMetadataItem.nameSingular]: targetRecord,
};
activityTargetsAfterUpdate = [
...activityTargetWithTargetRecords.map((activityTarget) => {
return activityTarget.activityTarget;
}),
activityTarget as NoteTarget | TaskTarget,
];
await createOneActivityTarget({
...activityTarget,
[targetObjectName]: undefined,
[pickedObjectMetadataItem.nameSingular]: undefined,
} as Partial<NoteTarget | TaskTarget>);
}
setActivityFromStore((currentActivity) => {
if (isNull(currentActivity)) {
return null;
}
return {
...currentActivity,
[`${targetObjectName}Targets`]: activityTargetsAfterUpdate,
};
});
},
[
activityId,
activityObjectNameSingular,
createOneActivityTarget,
deleteOneActivityTarget,
setActivityFromStore,
],
);
return { updateActivityTargetFromInlineCell };
};

View File

@ -3,13 +3,13 @@ import { RecoilRoot } from 'recoil';
import { useLimitPerMetadataItem } from '@/object-metadata/hooks/useLimitPerMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
const instanceId = 'instanceId';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<RecordPickerComponentInstanceContext.Provider value={{ instanceId }}>
<SingleRecordPickerComponentInstanceContext.Provider value={{ instanceId }}>
<RecoilRoot>{children}</RecoilRoot>
</RecordPickerComponentInstanceContext.Provider>
</SingleRecordPickerComponentInstanceContext.Provider>
);
describe('useLimitPerMetadataItem', () => {

View File

@ -17,7 +17,7 @@ export const getLabelIdentifierFieldValue = (
}
if (isDefined(labelIdentifierFieldMetadataItem?.name)) {
return String(record[labelIdentifierFieldMetadataItem.name]);
return record[labelIdentifierFieldMetadataItem.name];
}
return '';

View File

@ -12,7 +12,13 @@ export const getObjectRecordIdentifier = ({
objectMetadataItem,
record,
}: {
objectMetadataItem: ObjectMetadataItem;
objectMetadataItem: Pick<
ObjectMetadataItem,
| 'fields'
| 'labelIdentifierFieldMetadataId'
| 'nameSingular'
| 'imageIdentifierFieldMetadataId'
>;
record: ObjectRecord;
}): ObjectRecordIdentifier => {
const labelIdentifierFieldMetadataItem =

View File

@ -5,7 +5,7 @@ import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
import { useCombinedFindManyRecordsQueryVariables } from '@/object-record/multiple-objects/hooks/useCombinedFindManyRecordsQueryVariables';
import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery';
import { MultiObjectRecordQueryResult } from '@/object-record/multiple-objects/types/MultiObjectRecordQueryResult';
import { CombinedFindManyRecordsQueryResult } from '@/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult';
export const useCombinedFindManyRecords = ({
operationSignatures,
@ -22,7 +22,7 @@ export const useCombinedFindManyRecords = ({
operationSignatures,
});
const { data, loading } = useQuery<MultiObjectRecordQueryResult>(
const { data, loading } = useQuery<CombinedFindManyRecordsQueryResult>(
findManyQuery ?? EMPTY_QUERY,
{
skip,

View File

@ -4,7 +4,7 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery';
import { MultiObjectRecordQueryResult } from '@/object-record/multiple-objects/types/MultiObjectRecordQueryResult';
import { CombinedFindManyRecordsQueryResult } from '@/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult';
export const useCombinedGetTotalCount = ({
objectMetadataItems,
@ -28,7 +28,7 @@ export const useCombinedGetTotalCount = ({
operationSignatures,
});
const { data } = useQuery<MultiObjectRecordQueryResult>(
const { data } = useQuery<CombinedFindManyRecordsQueryResult>(
findManyQuery ?? EMPTY_QUERY,
{
skip,

View File

@ -1,12 +1,8 @@
import { gql } from '@apollo/client';
import { isUndefined } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
import { getSearchRecordsQueryResponseField } from '@/object-record/utils/getSearchRecordsQueryResponseField';
import { capitalize } from 'twenty-shared';
import { generateCombinedSearchRecordsQuery } from '@/object-record/multiple-objects/utils/generateCombinedSearchRecordsQuery';
import { isNonEmptyArray } from '~/utils/isNonEmptyArray';
export const useGenerateCombinedSearchRecordsQuery = ({
@ -20,70 +16,8 @@ export const useGenerateCombinedSearchRecordsQuery = ({
return null;
}
const filterPerMetadataItemArray = operationSignatures
.map(
({ objectNameSingular }) =>
`$filter${capitalize(objectNameSingular)}: ${capitalize(
objectNameSingular,
)}FilterInput`,
)
.join(', ');
const limitPerMetadataItemArray = operationSignatures
.map(
({ objectNameSingular }) =>
`$limit${capitalize(objectNameSingular)}: Int`,
)
.join(', ');
const queryKeyWithObjectMetadataItemArray = operationSignatures.map(
(queryKey) => {
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === queryKey.objectNameSingular,
);
if (isUndefined(objectMetadataItem)) {
throw new Error(
`Object metadata item not found for object name singular: ${queryKey.objectNameSingular}`,
);
}
return { ...queryKey, objectMetadataItem };
},
);
const filteredQueryKeyWithObjectMetadataItemArray =
queryKeyWithObjectMetadataItemArray.filter(
({ objectMetadataItem }) => objectMetadataItem.isSearchable,
);
return gql`
query CombinedSearchRecords(
${filterPerMetadataItemArray},
${limitPerMetadataItemArray},
$search: String,
) {
${filteredQueryKeyWithObjectMetadataItemArray
.map(
({ objectMetadataItem }) =>
`${getSearchRecordsQueryResponseField(objectMetadataItem.namePlural)}(filter: $filter${capitalize(
objectMetadataItem.nameSingular,
)},
limit: $limit${capitalize(objectMetadataItem.nameSingular)},
searchInput: $search
){
edges {
node ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems: objectMetadataItems,
objectMetadataItem,
})}
cursor
}
totalCount
}`,
)
.join('\n')}
}
`;
return generateCombinedSearchRecordsQuery({
objectMetadataItems,
operationSignatures,
});
};

View File

@ -1,5 +1,5 @@
import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
export type MultiObjectRecordQueryResult = {
export type CombinedFindManyRecordsQueryResult = {
[namePlural: string]: RecordGqlConnection;
};

View File

@ -1,16 +0,0 @@
import { MultiObjectRecordQueryResult } from '@/object-record/multiple-objects/types/MultiObjectRecordQueryResult';
export const formatMultiObjectRecordSearchResults = (
searchResults: MultiObjectRecordQueryResult | undefined | null,
): MultiObjectRecordQueryResult => {
if (!searchResults) {
return {};
}
return Object.entries(searchResults).reduce((acc, [key, value]) => {
let newKey = key.replace(/^search/, '');
newKey = newKey.charAt(0).toLowerCase() + newKey.slice(1);
acc[newKey] = value;
return acc;
}, {} as MultiObjectRecordQueryResult);
};

View File

@ -0,0 +1,82 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
import { getSearchRecordsQueryResponseField } from '@/object-record/utils/getSearchRecordsQueryResponseField';
import { isUndefined } from '@sniptt/guards';
import gql from 'graphql-tag';
import { capitalize } from 'twenty-shared';
export const generateCombinedSearchRecordsQuery = ({
objectMetadataItems,
operationSignatures,
}: {
objectMetadataItems: ObjectMetadataItem[];
operationSignatures: RecordGqlOperationSignature[];
}) => {
const filterPerMetadataItemArray = operationSignatures
.map(
({ objectNameSingular }) =>
`$filter${capitalize(objectNameSingular)}: ${capitalize(
objectNameSingular,
)}FilterInput`,
)
.join(', ');
const limitPerMetadataItemArray = operationSignatures
.map(
({ objectNameSingular }) =>
`$limit${capitalize(objectNameSingular)}: Int`,
)
.join(', ');
const queryKeyWithObjectMetadataItemArray = operationSignatures.map(
(queryKey) => {
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === queryKey.objectNameSingular,
);
if (isUndefined(objectMetadataItem)) {
throw new Error(
`Object metadata item not found for object name singular: ${queryKey.objectNameSingular}`,
);
}
return { ...queryKey, objectMetadataItem };
},
);
const filteredQueryKeyWithObjectMetadataItemArray =
queryKeyWithObjectMetadataItemArray.filter(
({ objectMetadataItem }) => objectMetadataItem.isSearchable,
);
return gql`
query CombinedSearchRecords(
${filterPerMetadataItemArray},
${limitPerMetadataItemArray},
$search: String,
) {
${filteredQueryKeyWithObjectMetadataItemArray
.map(
({ objectMetadataItem }) =>
`${getSearchRecordsQueryResponseField(objectMetadataItem.namePlural)}(filter: $filter${capitalize(
objectMetadataItem.nameSingular,
)},
limit: $limit${capitalize(objectMetadataItem.nameSingular)},
searchInput: $search
){
edges {
node ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems: objectMetadataItems,
objectMetadataItem,
})}
cursor
}
totalCount
}`,
)
.join('\n')}
}
`;
};

View File

@ -7,8 +7,8 @@ import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldM
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { selectedFilterComponentState } from '@/object-record/object-filter-dropdown/states/selectedFilterComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { RelationPickerHotkeyScope } from '@/object-record/record-field/meta-types/input/types/RelationPickerHotkeyScope';
import { useApplyRecordFilter } from '@/object-record/record-filter/hooks/useApplyRecordFilter';
import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope';
import { BooleanDisplay } from '@/ui/field/display/components/BooleanDisplay';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
@ -91,7 +91,7 @@ export const ObjectFilterDropdownBooleanSelect = () => {
<SelectableList
selectableListId="boolean-select"
selectableItemIdArray={options.map((option) => option.toString())}
hotkeyScope={RelationPickerHotkeyScope.RelationPicker}
hotkeyScope={SingleRecordPickerHotkeyScope.SingleRecordPicker}
onEnter={(itemId) => {
handleOptionSelect(itemId === 'true');
}}

View File

@ -15,7 +15,7 @@ import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/i
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { findDuplicateRecordFilterInNonAdvancedRecordFilters } from '@/object-record/record-filter/utils/findDuplicateRecordFilterInNonAdvancedRecordFilters';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { RecordPickerHotkeyScope } from '@/object-record/record-picker/types/RecordPickerHotkeyScope';
import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
@ -87,7 +87,7 @@ export const ObjectFilterDropdownFilterSelectMenuItem = ({
const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type);
if (filterType === 'RELATION' || filterType === 'SELECT') {
setHotkeyScope(RecordPickerHotkeyScope.RecordPicker);
setHotkeyScope(SingleRecordPickerHotkeyScope.SingleRecordPicker);
}
const defaultOperand = getRecordFilterOperands({

View File

@ -18,9 +18,8 @@ import { objectFilterDropdownSearchInputComponentState } from '@/object-record/o
import { objectFilterDropdownSelectedOptionValuesComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedOptionValuesComponentState';
import { selectedFilterComponentState } from '@/object-record/object-filter-dropdown/states/selectedFilterComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { RelationPickerHotkeyScope } from '@/object-record/record-field/meta-types/input/types/RelationPickerHotkeyScope';
import { useApplyRecordFilter } from '@/object-record/record-filter/hooks/useApplyRecordFilter';
import { RecordPickerHotkeyScope } from '@/object-record/record-picker/types/RecordPickerHotkeyScope';
import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -102,7 +101,7 @@ export const ObjectFilterDropdownOptionSelect = () => {
closeDropdown();
resetSelectedItem();
},
RelationPickerHotkeyScope.RelationPicker,
SingleRecordPickerHotkeyScope.SingleRecordPicker,
[closeDropdown, resetSelectedItem],
);
@ -165,7 +164,7 @@ export const ObjectFilterDropdownOptionSelect = () => {
<SelectableList
selectableListId={componentInstanceId}
selectableItemIdArray={objectRecordsIds}
hotkeyScope={RecordPickerHotkeyScope.RecordPicker}
hotkeyScope={SingleRecordPickerHotkeyScope.SingleRecordPicker}
onEnter={(itemId) => {
const option = optionsInDropdown.find((option) => option.id === itemId);
if (isDefined(option)) {

View File

@ -12,7 +12,7 @@ import { selectedFilterComponentState } from '@/object-record/object-filter-drop
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { useApplyRecordFilter } from '@/object-record/record-filter/hooks/useApplyRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { RecordPickerHotkeyScope } from '@/object-record/record-picker/types/RecordPickerHotkeyScope';
import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope';
import { MultipleSelectDropdown } from '@/object-record/select/components/MultipleSelectDropdown';
import { useRecordsForSelect } from '@/object-record/select/hooks/useRecordsForSelect';
import { SelectableItem } from '@/object-record/select/types/SelectableItem';
@ -232,7 +232,7 @@ export const ObjectFilterDropdownRecordSelect = ({
)}
<MultipleSelectDropdown
selectableListId="object-filter-record-select-id"
hotkeyScope={RecordPickerHotkeyScope.RecordPicker}
hotkeyScope={SingleRecordPickerHotkeyScope.SingleRecordPicker}
itemsToSelect={recordsToSelect}
filteredSelectedItems={filteredSelectedRecords}
selectedItems={selectedRecords}

View File

@ -12,7 +12,7 @@ import { getActorSourceMultiSelectOptions } from '@/object-record/object-filter-
import { useApplyRecordFilter } from '@/object-record/record-filter/hooks/useApplyRecordFilter';
import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { RecordPickerHotkeyScope } from '@/object-record/record-picker/types/RecordPickerHotkeyScope';
import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope';
import { MultipleSelectDropdown } from '@/object-record/select/components/MultipleSelectDropdown';
import { SelectableItem } from '@/object-record/select/types/SelectableItem';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -144,7 +144,7 @@ export const ObjectFilterDropdownSourceSelect = ({
return (
<MultipleSelectDropdown
selectableListId="object-filter-source-select-id"
hotkeyScope={RecordPickerHotkeyScope.RecordPicker}
hotkeyScope={SingleRecordPickerHotkeyScope.SingleRecordPicker}
itemsToSelect={sourceTypes.filter(
(item) =>
!filteredSelectedItems.some((selected) => selected.id === item.id),

View File

@ -8,7 +8,7 @@ import { selectedOperandInDropdownComponentState } from '@/object-record/object-
import { getInitialFilterValue } from '@/object-record/object-filter-dropdown/utils/getInitialFilterValue';
import { useApplyRecordFilter } from '@/object-record/record-filter/hooks/useApplyRecordFilter';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { RecordPickerHotkeyScope } from '@/object-record/record-picker/types/RecordPickerHotkeyScope';
import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -67,7 +67,7 @@ export const useSelectFilterUsedInDropdown = (componentInstanceId?: string) => {
fieldMetadataItem.type === 'RELATION' ||
fieldMetadataItem.type === 'SELECT'
) {
setHotkeyScope(RecordPickerHotkeyScope.RecordPicker);
setHotkeyScope(SingleRecordPickerHotkeyScope.SingleRecordPicker);
}
const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type);

View File

@ -2,8 +2,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useAddNewCard } from '@/object-record/record-board/record-board-column/hooks/useAddNewCard';
import { recordBoardNewRecordByColumnIdSelector } from '@/object-record/record-board/states/selectors/recordBoardNewRecordByColumnIdSelector';
import { SingleRecordPicker } from '@/object-record/record-picker/components/SingleRecordPicker';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
@ -68,22 +67,15 @@ export const RecordBoardColumnNewOpportunity = ({
<>
{newRecord.isCreating && newRecord.position === position && (
<OverlayContainer>
<RecordPickerComponentInstanceContext.Provider
value={{
instanceId: `add-new-card-record-picker-column-${columnId}`,
}}
>
<SingleRecordPicker
componentInstanceId={`add-new-card-record-picker-column-${columnId}`}
onCancel={() => handleCreateSuccess(position, columnId, false)}
onRecordSelected={(company) =>
company ? handleEntitySelect(position, company) : null
}
objectNameSingular={CoreObjectNameSingular.Company}
selectedRecordIds={[]}
onCreate={createCompanyOpportunityAndOpenRightDrawer}
/>
</RecordPickerComponentInstanceContext.Provider>
<SingleRecordPicker
componentInstanceId={`add-new-card-record-picker-column-${columnId}`}
onCancel={() => handleCreateSuccess(position, columnId, false)}
onRecordSelected={(company) =>
company ? handleEntitySelect(position, company) : null
}
objectNameSingular={CoreObjectNameSingular.Company}
onCreate={createCompanyOpportunityAndOpenRightDrawer}
/>
</OverlayContainer>
)}
</>

View File

@ -1,9 +1,9 @@
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { recordBoardNewRecordByColumnIdSelector } from '@/object-record/record-board/states/selectors/recordBoardNewRecordByColumnIdSelector';
import { RelationPickerHotkeyScope } from '@/object-record/record-field/meta-types/input/types/RelationPickerHotkeyScope';
import { useRecordSelectSearch } from '@/object-record/record-picker/hooks/useRecordSelectSearch';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/types/SingleRecordPickerRecord';
import { useSingleRecordPickerSearch } from '@/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerSearch';
import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useCallback, useContext } from 'react';
import { RecoilState, useRecoilCallback } from 'recoil';
@ -26,7 +26,7 @@ export const useAddNewCard = ({
const columnContext = useContext(RecordBoardColumnContext);
const { createOneRecord, selectFieldMetadataItem, objectMetadataItem } =
useContext(RecordBoardContext);
const { resetSearchFilter } = useRecordSelectSearch(
const { resetSearchFilter } = useSingleRecordPickerSearch(
recordPickerComponentInstanceId,
);
@ -139,7 +139,7 @@ export const useAddNewCard = ({
addNewItem(set, columnDefinitionId, position, isOpportunity);
if (isOpportunity) {
setHotkeyScopeAndMemorizePreviousScope(
RelationPickerHotkeyScope.RelationPicker,
SingleRecordPickerHotkeyScope.SingleRecordPicker,
);
} else {
createRecord(labelIdentifier, labelValue, position, isOpportunity);

View File

@ -1,4 +1,4 @@
import { SingleRecordPickerRecord } from '@/object-record/record-picker/types/SingleRecordPickerRecord';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState';
export type NewCard = {

View File

@ -17,6 +17,7 @@ import { isFieldRelationToOneObject } from '@/object-record/record-field/types/g
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { ArrayFieldInput } from '@/object-record/record-field/meta-types/input/components/ArrayFieldInput';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray';
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
@ -71,108 +72,114 @@ export const FieldInput = ({
const { fieldDefinition } = useContext(FieldContext);
return (
<RecordFieldInputScope
recordFieldInputScopeId={getScopeIdFromComponentId(recordFieldInputdId)}
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: recordFieldInputdId,
}}
>
{isFieldRelationToOneObject(fieldDefinition) ? (
<RelationToOneFieldInput onSubmit={onSubmit} onCancel={onCancel} />
) : isFieldRelationFromManyObjects(fieldDefinition) ? (
<RelationFromManyFieldInput onSubmit={onSubmit} />
) : isFieldPhones(fieldDefinition) ? (
<PhonesFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldText(fieldDefinition) ? (
<TextFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldFullName(fieldDefinition) ? (
<FullNameFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldDateTime(fieldDefinition) ? (
<DateTimeFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onClear={onSubmit}
onSubmit={onSubmit}
/>
) : isFieldDate(fieldDefinition) ? (
<DateFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onClear={onSubmit}
onSubmit={onSubmit}
/>
) : isFieldNumber(fieldDefinition) ? (
<NumberFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldLinks(fieldDefinition) ? (
<LinksFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldBoolean(fieldDefinition) ? (
<BooleanFieldInput onSubmit={onSubmit} readonly={isReadOnly} />
) : isFieldRating(fieldDefinition) ? (
<RatingFieldInput onSubmit={onSubmit} />
) : isFieldSelect(fieldDefinition) ? (
<SelectFieldInput onSubmit={onSubmit} onCancel={onCancel} />
) : isFieldMultiSelect(fieldDefinition) ? (
<MultiSelectFieldInput onCancel={onCancel} />
) : isFieldAddress(fieldDefinition) ? (
<AddressFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldRawJson(fieldDefinition) ? (
<RawJsonFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldArray(fieldDefinition) ? (
<ArrayFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : (
<></>
)}
</RecordFieldInputScope>
<RecordFieldInputScope
recordFieldInputScopeId={getScopeIdFromComponentId(recordFieldInputdId)}
>
{isFieldRelationToOneObject(fieldDefinition) ? (
<RelationToOneFieldInput onSubmit={onSubmit} onCancel={onCancel} />
) : isFieldRelationFromManyObjects(fieldDefinition) ? (
<RelationFromManyFieldInput onSubmit={onSubmit} />
) : isFieldPhones(fieldDefinition) ? (
<PhonesFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldText(fieldDefinition) ? (
<TextFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldFullName(fieldDefinition) ? (
<FullNameFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldDateTime(fieldDefinition) ? (
<DateTimeFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onClear={onSubmit}
onSubmit={onSubmit}
/>
) : isFieldDate(fieldDefinition) ? (
<DateFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onClear={onSubmit}
onSubmit={onSubmit}
/>
) : isFieldNumber(fieldDefinition) ? (
<NumberFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldLinks(fieldDefinition) ? (
<LinksFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldBoolean(fieldDefinition) ? (
<BooleanFieldInput onSubmit={onSubmit} readonly={isReadOnly} />
) : isFieldRating(fieldDefinition) ? (
<RatingFieldInput onSubmit={onSubmit} />
) : isFieldSelect(fieldDefinition) ? (
<SelectFieldInput onSubmit={onSubmit} onCancel={onCancel} />
) : isFieldMultiSelect(fieldDefinition) ? (
<MultiSelectFieldInput onCancel={onCancel} />
) : isFieldAddress(fieldDefinition) ? (
<AddressFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldRawJson(fieldDefinition) ? (
<RawJsonFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldArray(fieldDefinition) ? (
<ArrayFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : (
<></>
)}
</RecordFieldInputScope>
</RecordFieldComponentInstanceContext.Provider>
);
};

View File

@ -0,0 +1,46 @@
import { useOpenRelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/hooks/useOpenRelationFromManyFieldInput';
import { useOpenRelationToOneFieldInput } from '@/object-record/record-field/meta-types/input/hooks/useOpenRelationToOneFieldInput';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject';
import { isDefined } from 'twenty-shared';
export const useOpenFieldInputEditMode = () => {
const { openRelationToOneFieldInput } = useOpenRelationToOneFieldInput();
const { openRelationFromManyFieldInput } =
useOpenRelationFromManyFieldInput();
const openFieldInput = ({
fieldDefinition,
recordId,
}: {
fieldDefinition: FieldDefinition<FieldMetadata>;
recordId: string;
}) => {
if (isFieldRelationToOneObject(fieldDefinition)) {
openRelationToOneFieldInput({
fieldName: fieldDefinition.metadata.fieldName,
recordId: recordId,
});
}
if (isFieldRelationFromManyObjects(fieldDefinition)) {
if (
isDefined(fieldDefinition.metadata.relationObjectMetadataNameSingular)
) {
openRelationFromManyFieldInput({
fieldName: fieldDefinition.metadata.fieldName,
objectNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
recordId: recordId,
});
}
}
};
return {
openFieldInput: openFieldInput,
closeFieldInput: () => {},
};
};

View File

@ -31,7 +31,6 @@ import { isFieldRichText } from '@/object-record/record-field/types/guards/isFie
import { isFieldRichTextV2 } from '@/object-record/record-field/types/guards/isFieldRichTextV2';
import { isFieldRichTextValue } from '@/object-record/record-field/types/guards/isFieldRichTextValue';
import { isFieldRichTextV2Value } from '@/object-record/record-field/types/guards/isFieldRichTextValueV2';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/types/SingleRecordPickerRecord';
import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName';
import { FieldContext } from '../contexts/FieldContext';
import { isFieldBoolean } from '../types/guards/isFieldBoolean';
@ -156,13 +155,12 @@ export const usePersistField = () => {
);
if (fieldIsRelationToOneObject) {
const value = valueToPersist as SingleRecordPickerRecord;
updateRecord?.({
variables: {
where: { id: recordId },
updateOneRecordInput: {
[getForeignKeyNameFromRelationFieldName(fieldName)]:
value?.id ?? null,
valueToPersist?.id ?? null,
},
},
});

View File

@ -7,14 +7,12 @@ import { FieldRelationValue } from '@/object-record/record-field/types/FieldMeta
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/types/SingleRecordPickerRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldContext } from '../../contexts/FieldContext';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldRelation } from '../../types/guards/isFieldRelation';
export const useRelationField = <
T extends SingleRecordPickerRecord | SingleRecordPickerRecord[],
>() => {
export const useRelationField = <T extends ObjectRecord | ObjectRecord[]>() => {
const { recordId, fieldDefinition, maxWidth } = useContext(FieldContext);
const button = useGetButtonIcon();

View File

@ -2,14 +2,14 @@ import { useContext } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { RelationFromManyFieldInputMultiRecordsEffect } from '@/object-record/record-field/meta-types/input/components/RelationFromManyFieldInputMultiRecordsEffect';
import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer';
import { useUpdateRelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/hooks/useUpdateRelationFromManyFieldInput';
import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/states/recordFieldInputLayoutDirectionComponentState';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { MultipleRecordPicker } from '@/object-record/record-picker/components/MultipleRecordPicker';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
type RelationFromManyFieldInputProps = {
onSubmit?: FieldInputEvent;
@ -19,10 +19,9 @@ export const RelationFromManyFieldInput = ({
onSubmit,
}: RelationFromManyFieldInputProps) => {
const { fieldDefinition, recordId } = useContext(FieldContext);
const recordPickerInstanceId = `record-picker-${fieldDefinition.fieldMetadataId}`;
const { updateRelation } = useUpdateRelationFromManyFieldInput({
scopeId: recordPickerInstanceId,
});
const recordPickerInstanceId = `relation-from-many-field-input-${recordId}`;
const { updateRelation } = useUpdateRelationFromManyFieldInput();
const handleSubmit = () => {
onSubmit?.(() => {});
@ -50,19 +49,22 @@ export const RelationFromManyFieldInput = ({
recordId,
});
const layoutDirection = useRecoilComponentValueV2(
recordFieldInputLayoutDirectionComponentState,
);
return (
<>
<RecordPickerComponentInstanceContext.Provider
value={{ instanceId: recordPickerInstanceId }}
>
<RelationFromManyFieldInputMultiRecordsEffect />
<MultipleRecordPicker
componentInstanceId={recordPickerInstanceId}
onSubmit={handleSubmit}
onChange={updateRelation}
onCreate={createNewRecordAndOpenRightDrawer}
/>
</RecordPickerComponentInstanceContext.Provider>
</>
<MultipleRecordPicker
componentInstanceId={recordPickerInstanceId}
onSubmit={handleSubmit}
onChange={updateRelation}
onCreate={createNewRecordAndOpenRightDrawer}
onClickOutside={handleSubmit}
layoutDirection={
layoutDirection === 'downward'
? 'search-bar-on-top'
: 'search-bar-on-bottom'
}
/>
);
};

View File

@ -1,129 +0,0 @@
import { useEffect, useMemo } from 'react';
import { useRecoilCallback, useRecoilState, useSetRecoilState } from 'recoil';
import { useObjectRecordMultiSelectScopedStates } from '@/activities/hooks/useObjectRecordMultiSelectScopedStates';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useRelationField } from '@/object-record/record-field/meta-types/hooks/useRelationField';
import { objectRecordMultiSelectComponentFamilyState } from '@/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState';
import { useRecordPickerRecordsOptions } from '@/object-record/record-picker/hooks/useRecordPickerRecordsOptions';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/types/SingleRecordPickerRecord';
import { ObjectRecordForSelect } from '@/object-record/types/ObjectRecordForSelect';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const RelationFromManyFieldInputMultiRecordsEffect = () => {
const { fieldValue, fieldDefinition } =
useRelationField<SingleRecordPickerRecord[]>();
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordPickerComponentInstanceContext,
);
const {
objectRecordsIdsMultiSelectState,
objectRecordMultiSelectCheckedRecordsIdsState,
recordMultiSelectIsLoadingState,
} = useObjectRecordMultiSelectScopedStates(instanceId);
const [objectRecordsIdsMultiSelect, setObjectRecordsIdsMultiSelect] =
useRecoilState(objectRecordsIdsMultiSelectState);
const { records } = useRecordPickerRecordsOptions({
objectNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
});
const setRecordMultiSelectIsLoading = useSetRecoilState(
recordMultiSelectIsLoadingState,
);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
});
const allRecords = useMemo(
() => [
...records.recordsToSelect.map((entity) => {
const { record, ...recordIdentifier } = entity;
return {
objectMetadataItem: objectMetadataItem,
record: record,
recordIdentifier: recordIdentifier,
};
}),
],
[records.recordsToSelect, objectMetadataItem],
);
const [
objectRecordMultiSelectCheckedRecordsIds,
setObjectRecordMultiSelectCheckedRecordsIds,
] = useRecoilState(objectRecordMultiSelectCheckedRecordsIdsState);
const updateRecords = useRecoilCallback(
({ snapshot, set }) =>
(newRecords: ObjectRecordForSelect[]) => {
for (const newRecord of newRecords) {
const currentRecord = snapshot
.getLoadable(
objectRecordMultiSelectComponentFamilyState({
scopeId: instanceId,
familyKey: newRecord.record.id,
}),
)
.getValue();
const newRecordWithSelected = {
...newRecord,
selected: objectRecordMultiSelectCheckedRecordsIds.includes(
newRecord.record.id,
),
};
if (
!isDeeplyEqual(
newRecordWithSelected.selected,
currentRecord?.selected,
)
) {
set(
objectRecordMultiSelectComponentFamilyState({
scopeId: instanceId,
familyKey: newRecordWithSelected.record.id,
}),
newRecordWithSelected,
);
}
}
},
[objectRecordMultiSelectCheckedRecordsIds, instanceId],
);
useEffect(() => {
updateRecords(allRecords);
const allRecordsIds = allRecords.map((record) => record.record.id);
if (!isDeeplyEqual(allRecordsIds, objectRecordsIdsMultiSelect)) {
setObjectRecordsIdsMultiSelect(allRecordsIds);
}
}, [
allRecords,
objectRecordsIdsMultiSelect,
setObjectRecordsIdsMultiSelect,
updateRecords,
]);
useEffect(() => {
setObjectRecordMultiSelectCheckedRecordsIds(
fieldValue
? fieldValue.map(
(fieldValueItem: SingleRecordPickerRecord) => fieldValueItem.id,
)
: [],
);
}, [fieldValue, setObjectRecordMultiSelectCheckedRecordsIds]);
useEffect(() => {
setRecordMultiSelectIsLoading(records.loading);
}, [records.loading, setRecordMultiSelectIsLoading]);
return <></>;
};

View File

@ -1,83 +0,0 @@
import { useContext } from 'react';
import { IconForbid } from 'twenty-ui';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { RelationPickerInitialValueEffect } from '@/object-record/record-field/meta-types/input/components/RelationPickerInitialValueEffect';
import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer';
import { RelationPickerHotkeyScope } from '@/object-record/record-field/meta-types/input/types/RelationPickerHotkeyScope';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { SingleRecordPicker } from '@/object-record/record-picker/components/SingleRecordPicker';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/types/SingleRecordPickerRecord';
export type RelationPickerProps = {
selectedRecordId?: string;
onSubmit: (selectedRecord: SingleRecordPickerRecord | null) => void;
onCancel?: () => void;
width?: number;
excludedRecordIds?: string[];
initialSearchFilter?: string | null;
fieldDefinition: FieldDefinition<FieldRelationMetadata>;
};
export const RelationPicker = ({
selectedRecordId,
onSubmit,
onCancel,
excludedRecordIds,
width,
initialSearchFilter,
fieldDefinition,
}: RelationPickerProps) => {
const recordPickerInstanceId = RelationPickerHotkeyScope.RelationPicker;
const handleRecordSelected = (
selectedRecord: SingleRecordPickerRecord | null | undefined,
) => onSubmit(selectedRecord ?? null);
const { objectMetadataItem: relationObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
});
const relationFieldMetadataItem = relationObjectMetadataItem.fields.find(
({ id }) => id === fieldDefinition.metadata.relationFieldMetadataId,
);
const { recordId } = useContext(FieldContext);
const { createNewRecordAndOpenRightDrawer } =
useAddNewRecordAndOpenRightDrawer({
relationObjectMetadataNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
relationObjectMetadataItem,
relationFieldMetadataItem,
recordId,
});
return (
<>
<RelationPickerInitialValueEffect
initialValueForSearchFilter={initialSearchFilter}
recordPickerInstanceId={recordPickerInstanceId}
/>
<SingleRecordPicker
componentInstanceId={recordPickerInstanceId}
EmptyIcon={IconForbid}
emptyLabel={'No ' + fieldDefinition.label}
onCancel={onCancel}
onCreate={createNewRecordAndOpenRightDrawer}
onRecordSelected={handleRecordSelected}
width={width}
objectNameSingular={
fieldDefinition.metadata.relationObjectMetadataNameSingular
}
recordPickerInstanceId={recordPickerInstanceId}
selectedRecordIds={selectedRecordId ? [selectedRecordId] : []}
excludedRecordIds={excludedRecordIds}
/>
</>
);
};

View File

@ -1,23 +0,0 @@
import { recordPickerSearchFilterComponentState } from '@/object-record/record-picker/states/recordPickerSearchFilterComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useEffect } from 'react';
// Todo: this effect should be deprecated to use sync hooks
export const RelationPickerInitialValueEffect = ({
initialValueForSearchFilter,
recordPickerInstanceId,
}: {
initialValueForSearchFilter?: string | null;
recordPickerInstanceId: string;
}) => {
const setRecordPickerSearchFilter = useSetRecoilComponentStateV2(
recordPickerSearchFilterComponentState,
recordPickerInstanceId,
);
useEffect(() => {
setRecordPickerSearchFilter(initialValueForSearchFilter ?? '');
}, [initialValueForSearchFilter, setRecordPickerSearchFilter]);
return <></>;
};

View File

@ -1,8 +1,15 @@
import { RelationPicker } from '@/object-record/record-field/meta-types/input/components/RelationPicker';
import { usePersistField } from '../../../hooks/usePersistField';
import { useRelationField } from '../../hooks/useRelationField';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/types/SingleRecordPickerRecord';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer';
import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/states/recordFieldInputLayoutDirectionComponentState';
import { recordFieldInputLayoutDirectionLoadingComponentState } from '@/object-record/record-field/states/recordFieldInputLayoutDirectionLoadingComponentState';
import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { IconForbid } from 'twenty-ui';
import { FieldInputEvent } from './DateTimeFieldInput';
export type RelationToOneFieldInputProps = {
@ -14,22 +21,64 @@ export const RelationToOneFieldInput = ({
onSubmit,
onCancel,
}: RelationToOneFieldInputProps) => {
const { fieldDefinition, initialSearchValue, fieldValue } =
useRelationField<SingleRecordPickerRecord>();
const { fieldDefinition, recordId } = useRelationField<ObjectRecord>();
const persistField = usePersistField();
const handleSubmit = (newEntity: SingleRecordPickerRecord | null) => {
onSubmit?.(() => persistField(newEntity?.record ?? null));
};
const recordPickerInstanceId = `relation-to-one-field-input-${recordId}-${fieldDefinition.metadata.fieldName}`;
const handleRecordSelected = (
selectedRecord: SingleRecordPickerRecord | null | undefined,
) => onSubmit?.(() => persistField(selectedRecord?.record ?? null));
const { objectMetadataItem: relationObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
});
const relationFieldMetadataItem = relationObjectMetadataItem.fields.find(
({ id }) => id === fieldDefinition.metadata.relationFieldMetadataId,
);
const { createNewRecordAndOpenRightDrawer } =
useAddNewRecordAndOpenRightDrawer({
relationObjectMetadataNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
relationObjectMetadataItem,
relationFieldMetadataItem,
recordId,
});
const layoutDirection = useRecoilComponentValueV2(
recordFieldInputLayoutDirectionComponentState,
);
const isLoading = useRecoilComponentValueV2(
recordFieldInputLayoutDirectionLoadingComponentState,
);
if (isLoading) {
return <></>;
}
return (
<RelationPicker
fieldDefinition={fieldDefinition}
selectedRecordId={fieldValue?.id}
onSubmit={handleSubmit}
<SingleRecordPicker
componentInstanceId={recordPickerInstanceId}
EmptyIcon={IconForbid}
emptyLabel={'No ' + fieldDefinition.label}
onCancel={onCancel}
initialSearchFilter={initialSearchValue}
onCreate={createNewRecordAndOpenRightDrawer}
onRecordSelected={handleRecordSelected}
objectNameSingular={
fieldDefinition.metadata.relationObjectMetadataNameSingular
}
recordPickerInstanceId={recordPickerInstanceId}
layoutDirection={
layoutDirection === 'downward'
? 'search-bar-on-top'
: 'search-bar-on-bottom'
}
/>
);
};

View File

@ -18,7 +18,10 @@ import {
} from '~/testing/mock-data/users';
import { FieldContextProvider } from '@/object-record/record-field/meta-types/components/FieldContextProvider';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { recordFieldInputLayoutDirectionLoadingComponentState } from '@/object-record/record-field/states/recordFieldInputLayoutDirectionLoadingComponentState';
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { getCanvasElementForDropdownTesting } from 'twenty-ui';
import {
RelationToOneFieldInput,
@ -30,11 +33,21 @@ const RelationWorkspaceSetterEffect = () => {
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
const setRecordFieldInputLayoutDirectionLoading =
useSetRecoilComponentStateV2(
recordFieldInputLayoutDirectionLoadingComponentState,
'relation-to-one-field-input-123-Relation',
);
useEffect(() => {
setCurrentWorkspace(mockCurrentWorkspace);
setCurrentWorkspaceMember(mockedWorkspaceMemberData);
}, [setCurrentWorkspace, setCurrentWorkspaceMember]);
setRecordFieldInputLayoutDirectionLoading(false);
}, [
setCurrentWorkspace,
setCurrentWorkspaceMember,
setRecordFieldInputLayoutDirectionLoading,
]);
return <></>;
};
@ -74,12 +87,18 @@ const RelationToOneFieldInputWithContext = ({
}}
recordId={recordId}
>
<RecordPickerComponentInstanceContext.Provider
value={{ instanceId: 'relation-to-one-field-input' }}
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: 'relation-to-one-field-input-123-Relation',
}}
>
<RelationWorkspaceSetterEffect />
<RelationToOneFieldInput onSubmit={onSubmit} onCancel={onCancel} />
</RecordPickerComponentInstanceContext.Provider>
<SingleRecordPickerComponentInstanceContext.Provider
value={{ instanceId: 'relation-to-one-field-input-123-Relation' }}
>
<RelationWorkspaceSetterEffect />
<RelationToOneFieldInput onSubmit={onSubmit} onCancel={onCancel} />
</SingleRecordPickerComponentInstanceContext.Provider>
</RecordFieldComponentInstanceContext.Provider>
</FieldContextProvider>
<div data-testid="data-field-input-click-outside-div" />
</div>

View File

@ -1,6 +1,7 @@
import { useSetRecoilState } from 'recoil';
import { v4 } from 'uuid';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
@ -10,9 +11,11 @@ import { viewableRecordIdState } from '@/object-record/record-right-drawer/state
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared';
import { IconEye } from 'twenty-ui';
import {
FeatureFlagKey,
FieldMetadataType,
RelationDefinitionType,
} from '~/generated-metadata/graphql';
@ -45,6 +48,10 @@ export const useAddNewRecordAndOpenRightDrawer = ({
});
const { openRightDrawer } = useRightDrawer();
const { openRecordInCommandMenu } = useCommandMenu();
const isCommandMenuEnabled = useIsFeatureEnabled(
FeatureFlagKey.IsCommandMenuV2Enabled,
);
if (
relationObjectMetadataNameSingular === 'workspaceMember' ||
@ -110,10 +117,18 @@ export const useAddNewRecordAndOpenRightDrawer = ({
setViewableRecordId(newRecordId);
setViewableRecordNameSingular(relationObjectMetadataNameSingular);
openRightDrawer(RightDrawerPages.ViewRecord, {
title: 'View Record',
Icon: IconEye,
});
if (isCommandMenuEnabled) {
openRecordInCommandMenu({
recordId: newRecordId,
objectNameSingular: relationObjectMetadataNameSingular,
});
} else {
openRightDrawer(RightDrawerPages.ViewRecord, {
title: 'View Record',
Icon: IconEye,
});
}
},
};
};

View File

@ -0,0 +1,91 @@
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import {
FieldRelationFromManyValue,
FieldRelationValue,
} from '@/object-record/record-field/types/FieldMetadata';
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { useRecoilCallback } from 'recoil';
export const useOpenRelationFromManyFieldInput = () => {
const { performSearch } = useMultipleRecordPickerPerformSearch();
const openRelationFromManyFieldInput = useRecoilCallback(
({ set, snapshot }) =>
({
fieldName,
objectNameSingular,
recordId,
}: {
fieldName: string;
objectNameSingular: string;
recordId: string;
}) => {
const recordPickerInstanceId = `relation-from-many-field-input-${recordId}`;
const fieldValue = snapshot
.getLoadable<FieldRelationValue<FieldRelationFromManyValue>>(
recordStoreFamilySelector({
recordId,
fieldName,
}),
)
.getValue();
const objectMetadataItems = snapshot
.getLoadable(objectMetadataItemsState)
.getValue();
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectNameSingular,
);
if (!objectMetadataItem) {
return;
}
const pickableMorphItems: RecordPickerPickableMorphItem[] =
fieldValue.map((record) => {
return {
objectMetadataId: objectMetadataItem.id,
recordId: record.id,
isSelected: true,
isMatchingSearchFilter: true,
};
});
for (const record of fieldValue) {
set(recordStoreFamilyState(record.id), record);
}
set(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId: recordPickerInstanceId,
}),
pickableMorphItems,
);
set(
multipleRecordPickerSearchableObjectMetadataItemsComponentState.atomFamily(
{ instanceId: recordPickerInstanceId },
),
[objectMetadataItem],
);
performSearch({
multipleRecordPickerInstanceId: recordPickerInstanceId,
forceSearchFilter: '',
forceSearchableObjectMetadataItems: [objectMetadataItem],
forcePickableMorphItems: pickableMorphItems,
});
},
[performSearch],
);
return { openRelationFromManyFieldInput };
};

View File

@ -0,0 +1,37 @@
import {
FieldRelationToOneValue,
FieldRelationValue,
} from '@/object-record/record-field/types/FieldMetadata';
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared';
export const useOpenRelationToOneFieldInput = () => {
const openRelationToOneFieldInput = useRecoilCallback(
({ set, snapshot }) =>
({ fieldName, recordId }: { fieldName: string; recordId: string }) => {
const recordPickerInstanceId = `relation-to-one-field-input-${recordId}-${fieldName}`;
const fieldValue = snapshot
.getLoadable<FieldRelationValue<FieldRelationToOneValue>>(
recordStoreFamilySelector({
recordId,
fieldName,
}),
)
.getValue();
if (isDefined(fieldValue)) {
set(
singleRecordPickerSelectedIdComponentState.atomFamily({
instanceId: recordPickerInstanceId,
}),
fieldValue.id,
);
}
},
[],
);
return { openRelationToOneFieldInput };
};

View File

@ -4,16 +4,12 @@ import { useRecoilCallback } from 'recoil';
import { useAttachRelatedRecordFromRecord } from '@/object-record/hooks/useAttachRelatedRecordFromRecord';
import { useDetachRelatedRecordFromRecord } from '@/object-record/hooks/useDetachRelatedRecordFromRecord';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { objectRecordMultiSelectCheckedRecordsIdsComponentState } from '@/object-record/record-field/states/objectRecordMultiSelectCheckedRecordsIdsComponentState';
import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const useUpdateRelationFromManyFieldInput = ({
scopeId,
}: {
scopeId: string;
}) => {
export const useUpdateRelationFromManyFieldInput = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
assertFieldMetadata(
@ -41,49 +37,21 @@ export const useUpdateRelationFromManyFieldInput = ({
});
const updateRelation = useRecoilCallback(
({ snapshot, set }) =>
async (objectRecordId: string) => {
const previouslyCheckedRecordsIds = snapshot
.getLoadable(
objectRecordMultiSelectCheckedRecordsIdsComponentState({
scopeId,
}),
)
.getValue();
const isNewlySelected =
!previouslyCheckedRecordsIds.includes(objectRecordId);
if (isNewlySelected) {
set(
objectRecordMultiSelectCheckedRecordsIdsComponentState({
scopeId,
}),
(prev) => [...prev, objectRecordId],
);
} else {
set(
objectRecordMultiSelectCheckedRecordsIdsComponentState({
scopeId,
}),
(prev) => prev.filter((id) => id !== objectRecordId),
);
}
if (isNewlySelected) {
await updateOneRecordAndAttachRelations({
recordId,
relatedRecordId: objectRecordId,
});
} else {
await updateOneRecordAndDetachRelations({
recordId,
relatedRecordId: objectRecordId,
});
}
},
() => async (morphItem: RecordPickerPickableMorphItem) => {
if (morphItem.isSelected) {
await updateOneRecordAndAttachRelations({
recordId,
relatedRecordId: morphItem.recordId,
});
} else {
await updateOneRecordAndDetachRelations({
recordId,
relatedRecordId: morphItem.recordId,
});
}
},
[
recordId,
scopeId,
updateOneRecordAndAttachRelations,
updateOneRecordAndDetachRelations,
],

View File

@ -1,4 +1,3 @@
export enum RelationPickerHotkeyScope {
RelationPicker = 'relation-picker',
AddNew = 'add-new',
}

View File

@ -1,13 +0,0 @@
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
export type ActivityTargetObjectRecord = {
activityTargetId: string | null;
};
export const activityTargetObjectRecordFamilyState = createFamilyState<
ActivityTargetObjectRecord,
string
>({
key: 'activityTargetObjectRecordFamilyState',
defaultValue: { activityTargetId: null },
});

View File

@ -0,0 +1,7 @@
import { ComponentStateKeyV2 } from '@/ui/utilities/state/component-state/types/ComponentStateKeyV2';
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
type RecordFieldComponentInstanceContextProps = ComponentStateKeyV2;
export const RecordFieldComponentInstanceContext =
createComponentInstanceContext<RecordFieldComponentInstanceContextProps>();

View File

@ -1,7 +0,0 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const objectRecordMultiSelectCheckedRecordsIdsComponentState =
createComponentState<string[]>({
key: 'objectRecordMultiSelectCheckedRecordsIdsComponentState',
defaultValue: [],
});

View File

@ -1,12 +0,0 @@
import { ObjectRecordForSelect } from '@/object-record/types/ObjectRecordForSelect';
import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState';
export type ObjectRecordAndSelected = ObjectRecordForSelect & {
selected: boolean;
};
export const objectRecordMultiSelectComponentFamilyState =
createComponentFamilyState<ObjectRecordAndSelected | undefined, string>({
key: 'objectRecordMultiSelectComponentFamilyState',
defaultValue: undefined,
});

View File

@ -1,8 +0,0 @@
import { ObjectRecordForSelect } from '@/object-record/types/ObjectRecordForSelect';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const objectRecordMultiSelectMatchesFilterRecordsIdsComponentState =
createComponentState<ObjectRecordForSelect[]>({
key: 'objectRecordMultiSelectMatchesFilterRecordsIdsComponentState',
defaultValue: [],
});

View File

@ -0,0 +1,10 @@
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { FieldInputLayoutDirection } from '@/object-record/record-field/types/FieldInputLayoutDirection';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const recordFieldInputLayoutDirectionComponentState =
createComponentStateV2<FieldInputLayoutDirection>({
key: 'recordFieldInputLayoutDirectionComponentState',
defaultValue: 'upward',
componentInstanceContext: RecordFieldComponentInstanceContext,
});

View File

@ -0,0 +1,9 @@
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const recordFieldInputLayoutDirectionLoadingComponentState =
createComponentStateV2<boolean>({
key: 'recordFieldInputLayoutDirectionLoadingComponentState',
defaultValue: true,
componentInstanceContext: RecordFieldComponentInstanceContext,
});

View File

@ -1,7 +0,0 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const recordMultiSelectIsLoadingComponentState =
createComponentState<boolean>({
key: 'recordMultiSelectIsLoadingComponentState',
defaultValue: false,
});

View File

@ -0,0 +1 @@
export type FieldInputLayoutDirection = 'upward' | 'downward';

View File

@ -2,7 +2,7 @@ import { ThemeColor } from 'twenty-ui';
import { RATING_VALUES } from '@/object-record/record-field/meta-types/constants/RatingValues';
import { ZodHelperLiteral } from '@/object-record/record-field/types/ZodHelperLiteral';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/types/SingleRecordPickerRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ConnectedAccountProvider } from 'twenty-shared';
import * as z from 'zod';
import { RelationDefinitionType } from '~/generated-metadata/graphql';
@ -260,9 +260,9 @@ export type FieldRatingValue = (typeof RATING_VALUES)[number] | null;
export type FieldSelectValue = string | null;
export type FieldMultiSelectValue = string[] | null;
export type FieldRelationToOneValue = SingleRecordPickerRecord | null;
export type FieldRelationToOneValue = ObjectRecord | null;
export type FieldRelationFromManyValue = SingleRecordPickerRecord[] | [];
export type FieldRelationFromManyValue = ObjectRecord[];
export type FieldRelationValue<
T extends FieldRelationToOneValue | FieldRelationFromManyValue,

View File

@ -2,10 +2,10 @@ import { isFieldRelation } from '@/object-record/record-field/types/guards/isFie
import { RelationDefinitionType } from '~/generated-metadata/graphql';
import { FieldDefinition } from '../FieldDefinition';
import { FieldMetadata } from '../FieldMetadata';
import { FieldMetadata, FieldRelationMetadata } from '../FieldMetadata';
export const isFieldRelationFromManyObjects = (
field: Pick<FieldDefinition<FieldMetadata>, 'type' | 'metadata'>,
): field is FieldDefinition<FieldMetadata> =>
): field is FieldDefinition<FieldRelationMetadata> =>
isFieldRelation(field) &&
field.metadata.relationType === RelationDefinitionType.ONE_TO_MANY;

View File

@ -8,17 +8,24 @@ import { FieldFocusContextProvider } from '@/object-record/record-field/contexts
import { useGetButtonIcon } from '@/object-record/record-field/hooks/useGetButtonIcon';
import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { useInlineCell } from '../hooks/useInlineCell';
import { useIsFieldValueReadOnly } from '@/object-record/record-field/hooks/useIsFieldValueReadOnly';
import { useOpenFieldInputEditMode } from '@/object-record/record-field/hooks/useOpenFieldInputEditMode';
import { FieldInputClickOutsideEvent } from '@/object-record/record-field/meta-types/input/components/DateTimeFieldInput';
import { RelationPickerHotkeyScope } from '@/object-record/record-field/meta-types/input/types/RelationPickerHotkeyScope';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { MultipleRecordPickerHotkeyScope } from '@/object-record/record-picker/multiple-record-picker/types/MultipleRecordPickerHotkeyScope';
import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope';
import { SelectFieldHotkeyScope } from '@/object-record/select/types/SelectFieldHotkeyScope';
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { useRecoilCallback } from 'recoil';
import { RelationDefinitionType } from '~/generated-metadata/graphql';
import { RecordInlineCellContainer } from './RecordInlineCellContainer';
import {
RecordInlineCellContext,
@ -39,6 +46,7 @@ export const RecordInlineCell = ({ loading }: RecordInlineCellProps) => {
onOpenEditMode,
onCloseEditMode,
} = useContext(FieldContext);
const buttonIcon = useGetButtonIcon();
const isFieldInputOnly = useIsFieldInputOnly();
@ -101,13 +109,40 @@ export const RecordInlineCell = ({ loading }: RecordInlineCellProps) => {
);
const { getIcon } = useIcons();
const { openFieldInput, closeFieldInput } = useOpenFieldInputEditMode();
// TODO: deprecate this and use useOpenFieldInput hooks to set the hotkey scope
const computedHotkeyScope = (
columnDefinition: FieldDefinition<FieldMetadata>,
) => {
if (isFieldRelation(columnDefinition)) {
if (
columnDefinition.metadata.relationType ===
RelationDefinitionType.MANY_TO_ONE
) {
return SingleRecordPickerHotkeyScope.SingleRecordPicker;
}
if (
columnDefinition.metadata.relationType ===
RelationDefinitionType.ONE_TO_MANY
) {
return MultipleRecordPickerHotkeyScope.MultipleRecordPicker;
}
return SingleRecordPickerHotkeyScope.SingleRecordPicker;
}
if (isFieldSelect(columnDefinition)) {
return SelectFieldHotkeyScope.SelectField;
}
return undefined;
};
const RecordInlineCellContextValue: RecordInlineCellContextProps = {
readonly: isFieldReadOnly,
buttonIcon: buttonIcon,
customEditHotkeyScope: isFieldRelation(fieldDefinition)
? { scope: RelationPickerHotkeyScope.RelationPicker }
: undefined,
IconLabel: fieldDefinition.iconName
? getIcon(fieldDefinition.iconName)
: undefined,
@ -135,8 +170,10 @@ export const RecordInlineCell = ({ loading }: RecordInlineCellProps) => {
isDisplayModeFixHeight: isDisplayModeFixHeight,
editModeContentOnly: isFieldInputOnly,
loading: loading,
onOpenEditMode,
onCloseEditMode,
customEditHotkeyScope: computedHotkeyScope(fieldDefinition),
onOpenEditMode:
onOpenEditMode ?? (() => openFieldInput({ fieldDefinition, recordId })),
onCloseEditMode: onCloseEditMode ?? (() => closeFieldInput()),
};
return (

View File

@ -1,4 +1,3 @@
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { createContext, ReactElement, useContext } from 'react';
import { IconComponent } from 'twenty-ui';
@ -12,7 +11,7 @@ export type RecordInlineCellContextProps = {
editModeContent?: ReactElement;
editModeContentOnly?: boolean;
displayModeContent?: ReactElement;
customEditHotkeyScope?: HotkeyScope;
customEditHotkeyScope?: string;
isDisplayModeFixHeight?: boolean;
disableHoverEffect?: boolean;
loading?: boolean;

View File

@ -1,7 +1,18 @@
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/states/recordFieldInputLayoutDirectionComponentState';
import { recordFieldInputLayoutDirectionLoadingComponentState } from '@/object-record/record-field/states/recordFieldInputLayoutDirectionLoadingComponentState';
import { RecordInlineCellContext } from '@/object-record/record-inline-cell/components/RecordInlineCellContext';
import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import styled from '@emotion/styled';
import { autoUpdate, flip, offset, useFloating } from '@floating-ui/react';
import {
MiddlewareState,
autoUpdate,
flip,
offset,
useFloating,
} from '@floating-ui/react';
import { useContext } from 'react';
import { createPortal } from 'react-dom';
@ -24,6 +35,33 @@ export const RecordInlineCellEditMode = ({
children,
}: RecordInlineCellEditModeProps) => {
const { isCentered } = useContext(RecordInlineCellContext);
const { recordId, fieldDefinition } = useContext(FieldContext);
const instanceId = getRecordFieldInputId(
recordId,
fieldDefinition?.metadata?.fieldName,
);
const setFieldInputLayoutDirection = useSetRecoilComponentStateV2(
recordFieldInputLayoutDirectionComponentState,
instanceId,
);
const setFieldInputLayoutDirectionLoading = useSetRecoilComponentStateV2(
recordFieldInputLayoutDirectionLoadingComponentState,
instanceId,
);
const setFieldInputLayoutDirectionMiddleware = {
name: 'middleware',
fn: async (state: MiddlewareState) => {
setFieldInputLayoutDirection(
state.placement.startsWith('bottom') ? 'downward' : 'upward',
);
setFieldInputLayoutDirectionLoading(false);
return {};
},
};
const { refs, floatingStyles } = useFloating({
placement: isCentered ? 'bottom' : 'bottom-start',
@ -40,6 +78,7 @@ export const RecordInlineCellEditMode = ({
crossAxis: -5,
},
),
setFieldInputLayoutDirectionMiddleware,
],
whileElementsMounted: autoUpdate,
});

View File

@ -3,7 +3,6 @@ import { useRecoilState } from 'recoil';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { isDefined } from 'twenty-shared';
import { useInitDraftValueV2 } from '@/object-record/record-field/hooks/useInitDraftValueV2';
@ -48,16 +47,13 @@ export const useInlineCell = () => {
goBackToPreviousDropdownFocusId();
};
const openInlineCell = (customEditHotkeyScopeForField?: HotkeyScope) => {
const openInlineCell = (customEditHotkeyScopeForField?: string) => {
onOpenEditMode?.();
setIsInlineCellInEditMode(true);
initFieldInputDraftValue({ recordId, fieldDefinition });
if (isDefined(customEditHotkeyScopeForField)) {
setHotkeyScopeAndMemorizePreviousScope(
customEditHotkeyScopeForField.scope,
customEditHotkeyScopeForField.customScopes,
);
setHotkeyScopeAndMemorizePreviousScope(customEditHotkeyScopeForField);
} else {
setHotkeyScopeAndMemorizePreviousScope(InlineCellHotkeyScope.InlineCell);
}

View File

@ -1,204 +0,0 @@
import { useObjectRecordMultiSelectScopedStates } from '@/activities/hooks/useObjectRecordMultiSelectScopedStates';
import { MultipleRecordPickerMenuItem } from '@/object-record/record-picker/components/MultipleRecordPickerMenuItem';
import { RECORD_PICKER_SELECTABLE_LIST_COMPONENT_INSTANCE_ID } from '@/object-record/record-picker/constants/RecordPickerSelectableListComponentInstanceId';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { recordPickerSearchFilterComponentState } from '@/object-record/record-picker/states/recordPickerSearchFilterComponentState';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNewButton';
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
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 { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import styled from '@emotion/styled';
import { Placement } from '@floating-ui/react';
import { useCallback, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared';
import { IconPlus } from 'twenty-ui';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
type MultipleRecordPickerProps = {
onChange?: (changedRecordForSelectId: string) => void;
onSubmit?: () => void;
onCreate?: ((searchInput?: string) => void) | (() => void);
dropdownPlacement?: Placement | null;
componentInstanceId: string;
};
export const MultipleRecordPicker = ({
onChange,
onSubmit,
onCreate,
dropdownPlacement,
componentInstanceId,
}: MultipleRecordPickerProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordPickerComponentInstanceContext,
componentInstanceId,
);
const { objectRecordsIdsMultiSelectState, recordMultiSelectIsLoadingState } =
useObjectRecordMultiSelectScopedStates(instanceId);
const { resetSelectedItem } = useSelectableList(
RECORD_PICKER_SELECTABLE_LIST_COMPONENT_INSTANCE_ID,
);
const recordMultiSelectIsLoading = useRecoilValue(
recordMultiSelectIsLoadingState,
);
const objectRecordsIdsMultiSelect = useRecoilValue(
objectRecordsIdsMultiSelectState,
);
const setSearchFilter = useSetRecoilComponentStateV2(
recordPickerSearchFilterComponentState,
instanceId,
);
const recordPickerSearchFilter = useRecoilComponentValueV2(
recordPickerSearchFilterComponentState,
instanceId,
);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const handleSubmit = () => {
onSubmit?.();
goBackToPreviousHotkeyScope();
resetSelectedItem();
};
useScopedHotkeys(
Key.Escape,
() => {
handleSubmit();
},
instanceId,
[handleSubmit],
);
useListenClickOutside({
refs: [containerRef],
callback: handleSubmit,
listenerId: 'MULTI_RECORD_SELECT_LISTENER_ID',
hotkeyScope: instanceId,
});
const handleFilterChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSearchFilter(event.currentTarget.value);
},
[setSearchFilter],
);
// TODO: refactor this in a separate component
const results = (
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableList
selectableListId={RECORD_PICKER_SELECTABLE_LIST_COMPONENT_INSTANCE_ID}
selectableItemIdArray={objectRecordsIdsMultiSelect}
hotkeyScope={instanceId}
onEnter={(selectedId) => {
onChange?.(selectedId);
resetSelectedItem();
}}
>
{objectRecordsIdsMultiSelect?.map((recordId) => {
return (
<MultipleRecordPickerMenuItem
key={recordId}
objectRecordId={recordId}
onChange={(recordId) => {
onChange?.(recordId);
resetSelectedItem();
}}
/>
);
})}
</SelectableList>
</DropdownMenuItemsContainer>
);
const createNewButton = isDefined(onCreate) && (
<CreateNewButton
onClick={() => onCreate?.(recordPickerSearchFilter)}
LeftIcon={IconPlus}
text="Add New"
/>
);
return (
<RecordPickerComponentInstanceContext.Provider
value={{ instanceId: componentInstanceId }}
>
<DropdownMenu ref={containerRef} data-select-disable width={200}>
{dropdownPlacement?.includes('end') && (
<>
{isDefined(onCreate) && !hasObjectReadOnlyPermission && (
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
)}
<DropdownMenuSeparator />
{objectRecordsIdsMultiSelect?.length > 0 && results}
{recordMultiSelectIsLoading && !recordPickerSearchFilter && (
<>
<DropdownMenuSkeletonItem />
<DropdownMenuSeparator />
</>
)}
{objectRecordsIdsMultiSelect?.length > 0 && (
<DropdownMenuSeparator />
)}
</>
)}
<DropdownMenuSearchInput
value={recordPickerSearchFilter}
onChange={handleFilterChange}
autoFocus
/>
{(dropdownPlacement?.includes('start') ||
isUndefinedOrNull(dropdownPlacement)) && (
<>
<DropdownMenuSeparator />
{recordMultiSelectIsLoading && !recordPickerSearchFilter && (
<>
<DropdownMenuSkeletonItem />
<DropdownMenuSeparator />
</>
)}
{objectRecordsIdsMultiSelect?.length > 0 && results}
{objectRecordsIdsMultiSelect?.length > 0 && (
<DropdownMenuSeparator />
)}
{isDefined(onCreate) && (
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
)}
</>
)}
</DropdownMenu>
</RecordPickerComponentInstanceContext.Provider>
);
};

View File

@ -1,88 +0,0 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { Avatar, MenuItemMultiSelectAvatar } from 'twenty-ui';
import { useObjectRecordMultiSelectScopedStates } from '@/activities/hooks/useObjectRecordMultiSelectScopedStates';
import { RECORD_PICKER_SELECTABLE_LIST_COMPONENT_INSTANCE_ID } from '@/object-record/record-picker/constants/RecordPickerSelectableListComponentInstanceId';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { isDefined } from 'twenty-shared';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
export const MultipleRecordPickerMenuItem = ({
objectRecordId,
onChange,
}: {
objectRecordId: string;
onChange?: (changedRecordForSelectId: string) => void;
}) => {
const { isSelectedItemIdSelector } = useSelectableList(
RECORD_PICKER_SELECTABLE_LIST_COMPONENT_INSTANCE_ID,
);
const isSelectedByKeyboard = useRecoilValue(
isSelectedItemIdSelector(objectRecordId),
);
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordPickerComponentInstanceContext,
);
const {
objectRecordMultiSelectFamilyState,
objectRecordMultiSelectCheckedRecordsIdsState,
} = useObjectRecordMultiSelectScopedStates(instanceId);
const record = useRecoilValue(
objectRecordMultiSelectFamilyState(objectRecordId),
);
const objectRecordMultiSelectCheckedRecordsIds = useRecoilValue(
objectRecordMultiSelectCheckedRecordsIdsState,
);
if (!record) {
return null;
}
const handleSelectChange = () => {
onChange?.(objectRecordId);
};
const { recordIdentifier } = record;
if (!isDefined(recordIdentifier)) {
return null;
}
const selected = objectRecordMultiSelectCheckedRecordsIds.find(
(checkedObjectRecord) => checkedObjectRecord === objectRecordId,
)
? true
: false;
return (
<StyledSelectableItem itemId={objectRecordId} key={objectRecordId}>
<MenuItemMultiSelectAvatar
onSelectChange={(_isNewlySelectedValue) => handleSelectChange()}
isKeySelected={isSelectedByKeyboard}
selected={selected}
avatar={
<Avatar
avatarUrl={recordIdentifier.avatarUrl}
placeholderColorSeed={objectRecordId}
placeholder={recordIdentifier.name}
size="md"
type={recordIdentifier.avatarType ?? 'rounded'}
/>
}
text={recordIdentifier.name}
/>
</StyledSelectableItem>
);
};

View File

@ -1,2 +0,0 @@
export const RECORD_PICKER_CLICK_OUTSIDE_LISTENER_ID =
'record-picker-click-outside-listener';

View File

@ -1,2 +0,0 @@
export const RECORD_PICKER_SELECTABLE_LIST_COMPONENT_INSTANCE_ID =
'record-picker-selectable-list-component-instance-id';

View File

@ -0,0 +1,38 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { multipleRecordPickerSinglePickableMorphItemComponentFamilySelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerSinglePickableMorphItemComponentFamilySelector';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
type UseRecordPickerGetRecordAndObjectMetadataItemFromRecordIdProps = {
recordId: string;
};
export const useRecordPickerGetRecordAndObjectMetadataItemFromRecordId = ({
recordId,
}: UseRecordPickerGetRecordAndObjectMetadataItemFromRecordIdProps) => {
const { objectMetadataItems } = useObjectMetadataItems();
const pickableMorphItem = useRecoilComponentFamilyValueV2(
multipleRecordPickerSinglePickableMorphItemComponentFamilySelector,
recordId,
);
const record = useRecoilValue(recordStoreFamilyState(recordId));
if (!isDefined(pickableMorphItem)) {
return { record: null, objectMetadataItem: null };
}
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === pickableMorphItem.objectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
return { record: null, objectMetadataItem: null };
}
return { record, objectMetadataItem };
};

View File

@ -1,26 +0,0 @@
import { recordPickerSearchFilterComponentState } from '@/object-record/record-picker/states/recordPickerSearchFilterComponentState';
import { useFilteredSearchRecordQuery } from '@/search/hooks/useFilteredSearchRecordQuery';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const useRecordPickerRecordsOptions = ({
objectNameSingular,
selectedRecordIds = [],
excludedRecordIds = [],
}: {
objectNameSingular: string;
selectedRecordIds?: string[];
excludedRecordIds?: string[];
}) => {
const recordPickerSearchFilter = useRecoilComponentValueV2(
recordPickerSearchFilterComponentState,
);
const records = useFilteredSearchRecordQuery({
searchFilter: recordPickerSearchFilter,
selectedIds: selectedRecordIds,
excludedRecordIds: excludedRecordIds,
objectNameSingular,
});
return { records };
};

View File

@ -0,0 +1,170 @@
import { MultipleRecordPickerMenuItems } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems';
import { MultipleRecordPickerOnClickOutsideEffect } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerOnClickOutsideEffect';
import { MultipleRecordPickerSearchInput } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerSearchInput';
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerIsLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsLoadingComponentState';
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
import { multipleRecordPickerPickableMorphItemsLengthComponentSelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPickableMorphItemsLengthComponentSelector';
import { MultipleRecordPickerHotkeyScope } from '@/object-record/record-picker/multiple-record-picker/types/MultipleRecordPickerHotkeyScope';
import { getMultipleRecordPickerSelectableListId } from '@/object-record/record-picker/multiple-record-picker/utils/getMultipleRecordPickerSelectableListId';
import { RecordPickerLayoutDirection } from '@/object-record/record-picker/types/RecordPickerLayoutDirection';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNewButton';
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import styled from '@emotion/styled';
import { useRef } from 'react';
import { useRecoilCallback } from 'recoil';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared';
import { IconPlus } from 'twenty-ui';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
type MultipleRecordPickerProps = {
onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
onSubmit?: () => void;
onCreate?: ((searchInput?: string) => void) | (() => void);
layoutDirection?: RecordPickerLayoutDirection;
componentInstanceId: string;
onClickOutside: () => void;
};
export const MultipleRecordPicker = ({
onChange,
onSubmit,
onCreate,
onClickOutside,
layoutDirection = 'search-bar-on-bottom',
componentInstanceId,
}: MultipleRecordPickerProps) => {
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
const selectableListComponentInstanceId =
getMultipleRecordPickerSelectableListId(componentInstanceId);
const { resetSelectedItem } = useSelectableList(
selectableListComponentInstanceId,
);
const multipleRecordPickerIsLoading = useRecoilComponentValueV2(
multipleRecordPickerIsLoadingComponentState,
componentInstanceId,
);
const itemsLength = useRecoilComponentValueV2(
multipleRecordPickerPickableMorphItemsLengthComponentSelector,
componentInstanceId,
);
const multipleRecordPickerSearchFilterState =
useRecoilComponentCallbackStateV2(
multipleRecordPickerSearchFilterComponentState,
componentInstanceId,
);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const handleSubmit = () => {
onSubmit?.();
goBackToPreviousHotkeyScope();
resetSelectedItem();
};
useScopedHotkeys(
Key.Escape,
() => {
handleSubmit();
},
MultipleRecordPickerHotkeyScope.MultipleRecordPicker,
[handleSubmit],
);
const containerRef = useRef<HTMLDivElement>(null);
const handleCreateNewButtonClick = useRecoilCallback(
({ snapshot }) => {
return () => {
const recordPickerSearchFilter = snapshot
.getLoadable(multipleRecordPickerSearchFilterState)
.getValue();
onCreate?.(recordPickerSearchFilter);
};
},
[multipleRecordPickerSearchFilterState, onCreate],
);
const createNewButton = isDefined(onCreate) && (
<CreateNewButton
onClick={handleCreateNewButtonClick}
LeftIcon={IconPlus}
text="Add New"
/>
);
return (
<MultipleRecordPickerComponentInstanceContext.Provider
value={{ instanceId: componentInstanceId }}
>
<MultipleRecordPickerOnClickOutsideEffect
containerRef={containerRef}
onClickOutside={onClickOutside}
/>
<DropdownMenu ref={containerRef} data-select-disable width={200}>
{layoutDirection === 'search-bar-on-bottom' && (
<>
{isDefined(onCreate) && !hasObjectReadOnlyPermission && (
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
)}
<DropdownMenuSeparator />
{itemsLength > 0 && (
<MultipleRecordPickerMenuItems onChange={onChange} />
)}
{multipleRecordPickerIsLoading && (
<>
<DropdownMenuSkeletonItem />
<DropdownMenuSeparator />
</>
)}
{itemsLength > 0 && <DropdownMenuSeparator />}
</>
)}
<MultipleRecordPickerSearchInput />
{layoutDirection === 'search-bar-on-top' && (
<>
<DropdownMenuSeparator />
{multipleRecordPickerIsLoading && (
<>
<DropdownMenuSkeletonItem />
<DropdownMenuSeparator />
</>
)}
{itemsLength > 0 && (
<MultipleRecordPickerMenuItems onChange={onChange} />
)}
{itemsLength > 0 && <DropdownMenuSeparator />}
{isDefined(onCreate) && (
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
)}
</>
)}
</DropdownMenu>
</MultipleRecordPickerComponentInstanceContext.Provider>
);
};

View File

@ -0,0 +1,39 @@
import styled from '@emotion/styled';
import { useRecordPickerGetRecordAndObjectMetadataItemFromRecordId } from '@/object-record/record-picker/hooks/useRecordPickerGetRecordAndObjectMetadataItemFromRecordId';
import { MultipleRecordPickerMenuItemContent } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItemContent';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { isDefined } from 'twenty-shared';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
type MultipleRecordPickerMenuItemProps = {
recordId: string;
onChange: (morphItem: RecordPickerPickableMorphItem) => void;
};
export const MultipleRecordPickerMenuItem = ({
recordId,
onChange,
}: MultipleRecordPickerMenuItemProps) => {
const { record, objectMetadataItem } =
useRecordPickerGetRecordAndObjectMetadataItemFromRecordId({
recordId,
});
if (!isDefined(record) || !isDefined(objectMetadataItem)) {
return null;
}
return (
<MultipleRecordPickerMenuItemContent
record={record}
objectMetadataItem={objectMetadataItem}
onChange={onChange}
/>
);
};

View File

@ -0,0 +1,92 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { Avatar, MenuItemMultiSelectAvatar } from 'twenty-ui';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerIsSelectedComponentFamilySelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerIsSelectedComponentFamilySelector';
import { getMultipleRecordPickerSelectableListId } from '@/object-record/record-picker/multiple-record-picker/utils/getMultipleRecordPickerSelectableListId';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { isDefined } from 'twenty-shared';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
type MultipleRecordPickerMenuItemContentProps = {
record: ObjectRecord;
objectMetadataItem: ObjectMetadataItem;
onChange: (morphItem: RecordPickerPickableMorphItem) => void;
};
export const MultipleRecordPickerMenuItemContent = ({
record,
objectMetadataItem,
onChange,
}: MultipleRecordPickerMenuItemContentProps) => {
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
MultipleRecordPickerComponentInstanceContext,
);
const selectableListComponentInstanceId =
getMultipleRecordPickerSelectableListId(componentInstanceId);
const { isSelectedItemIdSelector } = useSelectableList(
selectableListComponentInstanceId,
);
const isSelectedByKeyboard = useRecoilValue(
isSelectedItemIdSelector(record.id),
);
const isRecordSelectedWithObjectItem = useRecoilComponentFamilyValueV2(
multipleRecordPickerIsSelectedComponentFamilySelector,
record.id,
componentInstanceId,
);
const handleSelectChange = (isSelected: boolean) => {
onChange({
recordId: record.id,
objectMetadataId: objectMetadataItem.id,
isSelected,
isMatchingSearchFilter: true,
});
};
const recordIdentifier = getObjectRecordIdentifier({
objectMetadataItem,
record,
});
if (!isDefined(recordIdentifier)) {
return null;
}
return (
<StyledSelectableItem itemId={record.id} key={record.id}>
<MenuItemMultiSelectAvatar
onSelectChange={(isSelected) => handleSelectChange(isSelected)}
isKeySelected={isSelectedByKeyboard}
selected={isRecordSelectedWithObjectItem}
avatar={
<Avatar
avatarUrl={recordIdentifier.avatarUrl}
placeholderColorSeed={record.id}
placeholder={recordIdentifier.name}
size="md"
type={recordIdentifier.avatarType ?? 'rounded'}
/>
}
text={recordIdentifier.name}
/>
</StyledSelectableItem>
);
};

View File

@ -0,0 +1,138 @@
import styled from '@emotion/styled';
import { MultipleRecordPickerMenuItem } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItem';
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { multipleRecordPickerPickableRecordIdsMatchingSearchComponentSelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPickableRecordIdsMatchingSearchComponentSelector';
import { multipleRecordPickerSinglePickableMorphItemComponentFamilySelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerSinglePickableMorphItemComponentFamilySelector';
import { MultipleRecordPickerHotkeyScope } from '@/object-record/record-picker/multiple-record-picker/types/MultipleRecordPickerHotkeyScope';
import { getMultipleRecordPickerSelectableListId } from '@/object-record/record-picker/multiple-record-picker/utils/getMultipleRecordPickerSelectableListId';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
type MultipleRecordPickerMenuItemsProps = {
onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
};
export const MultipleRecordPickerMenuItems = ({
onChange,
}: MultipleRecordPickerMenuItemsProps) => {
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
MultipleRecordPickerComponentInstanceContext,
);
const selectableListComponentInstanceId =
getMultipleRecordPickerSelectableListId(componentInstanceId);
const pickableRecordIds = useRecoilComponentValueV2(
multipleRecordPickerPickableRecordIdsMatchingSearchComponentSelector,
componentInstanceId,
);
const { resetSelectedItem } = useSelectableList(
selectableListComponentInstanceId,
);
const singlePickableMorphItemFamilySelector =
useRecoilComponentCallbackStateV2(
multipleRecordPickerSinglePickableMorphItemComponentFamilySelector,
componentInstanceId,
);
const multipleRecordPickerPickableMorphItemsState =
useRecoilComponentCallbackStateV2(
multipleRecordPickerPickableMorphItemsComponentState,
componentInstanceId,
);
const handleChange = useRecoilCallback(
({ snapshot, set }) => {
return (morphItem: RecordPickerPickableMorphItem) => {
const previousMorphItems = snapshot
.getLoadable(multipleRecordPickerPickableMorphItemsState)
.getValue();
const existingMorphItemIndex = previousMorphItems.findIndex(
(item) => item.recordId === morphItem.recordId,
);
const newMorphItems = [...previousMorphItems];
if (existingMorphItemIndex === -1) {
newMorphItems.push(morphItem);
} else {
newMorphItems[existingMorphItemIndex] = morphItem;
}
set(multipleRecordPickerPickableMorphItemsState, newMorphItems);
};
},
[multipleRecordPickerPickableMorphItemsState],
);
const handleEnter = useRecoilCallback(
({ snapshot }) => {
return (selectedId: string) => {
const pickableMorphItem = snapshot
.getLoadable(singlePickableMorphItemFamilySelector(selectedId))
.getValue();
if (!isDefined(pickableMorphItem)) {
return;
}
const selectedMorphItem = {
...pickableMorphItem,
isSelected: !pickableMorphItem.isSelected,
};
handleChange(selectedMorphItem);
onChange?.(selectedMorphItem);
resetSelectedItem();
};
},
[
handleChange,
onChange,
resetSelectedItem,
singlePickableMorphItemFamilySelector,
],
);
return (
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableList
selectableListId={selectableListComponentInstanceId}
selectableItemIdArray={pickableRecordIds}
hotkeyScope={MultipleRecordPickerHotkeyScope.MultipleRecordPicker}
onEnter={handleEnter}
>
{pickableRecordIds.map((recordId) => {
return (
<MultipleRecordPickerMenuItem
key={recordId}
recordId={recordId}
onChange={(morphItem) => {
handleChange(morphItem);
onChange?.(morphItem);
resetSelectedItem();
}}
/>
);
})}
</SelectableList>
</DropdownMenuItemsContainer>
);
};

View File

@ -0,0 +1,24 @@
import { MULTIPLE_RECORD_PICKER_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-picker/multiple-record-picker/constants/MultipleRecordPickerClickOutsideListenerId';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
export const MultipleRecordPickerOnClickOutsideEffect = ({
containerRef,
onClickOutside,
}: {
containerRef: React.RefObject<HTMLDivElement>;
onClickOutside: () => void;
}) => {
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
event.stopPropagation();
event.preventDefault();
onClickOutside();
},
listenerId: MULTIPLE_RECORD_PICKER_CLICK_OUTSIDE_LISTENER_ID,
});
return <></>;
};

View File

@ -0,0 +1,45 @@
import styled from '@emotion/styled';
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useCallback } from 'react';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
export const MultipleRecordPickerSearchInput = () => {
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
MultipleRecordPickerComponentInstanceContext,
);
const [recordPickerSearchFilter, setRecordPickerSearchFilter] =
useRecoilComponentStateV2(multipleRecordPickerSearchFilterComponentState);
const { performSearch } = useMultipleRecordPickerPerformSearch();
const handleFilterChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setRecordPickerSearchFilter(event.currentTarget.value);
performSearch({
multipleRecordPickerInstanceId: componentInstanceId,
forceSearchFilter: event.currentTarget.value,
});
},
[componentInstanceId, performSearch, setRecordPickerSearchFilter],
);
return (
<DropdownMenuSearchInput
value={recordPickerSearchFilter}
onChange={handleFilterChange}
autoFocus
/>
);
};

View File

@ -0,0 +1,2 @@
export const MULTIPLE_RECORD_PICKER_CLICK_OUTSIDE_LISTENER_ID =
'multiple-record-picker-click-outside-listener';

View File

@ -0,0 +1,339 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { CombinedFindManyRecordsQueryResult } from '@/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult';
import { generateCombinedSearchRecordsQuery } from '@/object-record/multiple-objects/utils/generateCombinedSearchRecordsQuery';
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 { multipleRecordPickerformatQueryResultAsRecordsWithObjectMetadataId } from '@/object-record/record-picker/multiple-record-picker/utils/multipleRecordPickerformatQueryResultAsRecordWithObjectMetadataId';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ApolloClient, useApolloClient } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared';
export const useMultipleRecordPickerPerformSearch = () => {
const client = useApolloClient();
const performSearch = useRecoilCallback(
({ snapshot, set }) =>
async ({
multipleRecordPickerInstanceId,
forceSearchFilter = '',
forceSearchableObjectMetadataItems = [],
forcePickableMorphItems = [],
}: {
multipleRecordPickerInstanceId: string;
forceSearchFilter?: string;
forceSearchableObjectMetadataItems?: ObjectMetadataItem[];
forcePickableMorphItems?: RecordPickerPickableMorphItem[];
}) => {
const recordPickerSearchFilter = snapshot
.getLoadable(
multipleRecordPickerSearchFilterComponentState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
}),
)
.getValue();
const searchFilter = forceSearchFilter ?? recordPickerSearchFilter;
const recordPickerSearchableObjectMetadataItems = snapshot
.getLoadable(
multipleRecordPickerSearchableObjectMetadataItemsComponentState.atomFamily(
{ instanceId: multipleRecordPickerInstanceId },
),
)
.getValue();
const searchableObjectMetadataItems =
forceSearchableObjectMetadataItems.length > 0
? forceSearchableObjectMetadataItems
: recordPickerSearchableObjectMetadataItems;
const recordPickerPickableMorphItems = snapshot
.getLoadable(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
}),
)
.getValue();
const pickableMorphItems =
forcePickableMorphItems.length > 0
? forcePickableMorphItems
: recordPickerPickableMorphItems;
const recordsWithObjectMetadataIdFilteredOnPickedRecords =
await performSearchForPickedRecords({
client,
searchFilter,
searchableObjectMetadataItems,
pickableMorphItems,
});
const recordsWithObjectMetadataIdExcludingPickedRecords =
await performSearchExcludingPickedRecords({
client,
searchFilter,
searchableObjectMetadataItems,
pickableMorphItems,
});
const pickedMorphItems = pickableMorphItems.filter(
({ isSelected }) => isSelected,
);
// We update the existing pickedMorphItems to be matching the search filter
const updatedPickedMorphItems = pickedMorphItems.map((morphItem) => {
const record =
recordsWithObjectMetadataIdFilteredOnPickedRecords.find(
({ record }) => record.id === morphItem.recordId,
);
return {
...morphItem,
isMatchingSearchFilter: isDefined(record),
};
});
const recordsWithObjectMetadataIdFilteredOnPickedRecordsWithoutDuplicates =
recordsWithObjectMetadataIdFilteredOnPickedRecords.filter(
({ record }) =>
!updatedPickedMorphItems.some(
({ recordId }) => recordId === record.id,
),
);
const recordsWithObjectMetadataIdExcludingPickedRecordsWithoutDuplicates =
recordsWithObjectMetadataIdExcludingPickedRecords.filter(
({ record }) =>
!recordsWithObjectMetadataIdFilteredOnPickedRecords.some(
({ record: recordFilteredOnPickedRecords }) =>
recordFilteredOnPickedRecords.id === record.id,
) &&
!pickedMorphItems.some(({ recordId }) => recordId === record.id),
);
const morphItems = [
...updatedPickedMorphItems,
...recordsWithObjectMetadataIdFilteredOnPickedRecordsWithoutDuplicates.map(
({ record, objectMetadataItem }) => ({
isMatchingSearchFilter: true,
isSelected: true,
objectMetadataId: objectMetadataItem.id,
recordId: record.id,
}),
),
...recordsWithObjectMetadataIdExcludingPickedRecordsWithoutDuplicates.map(
({ record, objectMetadataItem }) => ({
isMatchingSearchFilter: true,
isSelected: false,
objectMetadataId: objectMetadataItem.id,
recordId: record.id,
}),
),
];
set(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
}),
morphItems,
);
[
...recordsWithObjectMetadataIdFilteredOnPickedRecords,
...recordsWithObjectMetadataIdExcludingPickedRecordsWithoutDuplicates,
].forEach(({ record }) => {
set(recordStoreFamilyState(record.id), record);
});
},
[client],
);
return { performSearch };
};
const performSearchForPickedRecords = async ({
client,
searchFilter,
searchableObjectMetadataItems,
pickableMorphItems,
}: {
client: ApolloClient<object>;
searchFilter: string;
searchableObjectMetadataItems: ObjectMetadataItem[];
pickableMorphItems: RecordPickerPickableMorphItem[];
}) => {
const pickedMorphItems = pickableMorphItems.filter(
({ isSelected }) => isSelected,
);
const filterPerMetadataItemFilteredOnPickedRecordId = Object.fromEntries(
searchableObjectMetadataItems
.map(({ id, nameSingular }) => {
const pickedRecordIdsForMetadataItem = pickedMorphItems
.filter(
({ objectMetadataId, isSelected }) =>
objectMetadataId === id && isSelected,
)
.map(({ recordId }) => recordId);
if (!isNonEmptyArray(pickedRecordIdsForMetadataItem)) {
return null;
}
return [
`filter${capitalize(nameSingular)}`,
{
id: {
in: pickedRecordIdsForMetadataItem,
},
},
];
})
.filter(isDefined),
);
const searchableObjectMetadataItemsFilteredOnPickedRecordId =
searchableObjectMetadataItems.filter(({ nameSingular }) =>
isDefined(
filterPerMetadataItemFilteredOnPickedRecordId[
`filter${capitalize(nameSingular)}`
],
),
);
if (!isNonEmptyArray(searchableObjectMetadataItemsFilteredOnPickedRecordId)) {
return [];
}
const combinedSearchRecordsQueryFilteredOnPickedRecords =
generateCombinedSearchRecordsQuery({
objectMetadataItems:
searchableObjectMetadataItemsFilteredOnPickedRecordId,
operationSignatures:
searchableObjectMetadataItemsFilteredOnPickedRecordId.map(
(objectMetadataItem) => ({
objectNameSingular: objectMetadataItem.nameSingular,
variables: {},
}),
),
});
const limitPerMetadataItem = Object.fromEntries(
searchableObjectMetadataItems
.map(({ nameSingular }) => {
return [`limit${capitalize(nameSingular)}`, 10];
})
.filter(isDefined),
);
const { data: combinedSearchRecordFilteredOnPickedRecordsQueryResult } =
await client.query<CombinedFindManyRecordsQueryResult>({
query: combinedSearchRecordsQueryFilteredOnPickedRecords,
variables: {
search: searchFilter,
...limitPerMetadataItem,
...filterPerMetadataItemFilteredOnPickedRecordId,
},
});
const {
recordsWithObjectMetadataId:
recordsWithObjectMetadataIdFilteredOnPickedRecords,
} = multipleRecordPickerformatQueryResultAsRecordsWithObjectMetadataId({
objectMetadataItems: searchableObjectMetadataItems,
searchQueryResult: combinedSearchRecordFilteredOnPickedRecordsQueryResult,
});
return recordsWithObjectMetadataIdFilteredOnPickedRecords;
};
const performSearchExcludingPickedRecords = async ({
client,
searchFilter,
searchableObjectMetadataItems,
pickableMorphItems,
}: {
client: ApolloClient<object>;
searchFilter: string;
searchableObjectMetadataItems: ObjectMetadataItem[];
pickableMorphItems: RecordPickerPickableMorphItem[];
}) => {
if (searchableObjectMetadataItems.length === 0) {
return [];
}
const pickedMorphItems = pickableMorphItems.filter(
({ isSelected }) => isSelected,
);
const filterPerMetadataItemExcludingPickedRecordId = Object.fromEntries(
searchableObjectMetadataItems
.map(({ id, nameSingular }) => {
const pickedRecordIdsForMetadataItem = pickedMorphItems
.filter(
({ objectMetadataId, isSelected }) =>
objectMetadataId === id && isSelected,
)
.map(({ recordId }) => recordId);
if (!isNonEmptyArray(pickedRecordIdsForMetadataItem)) {
return null;
}
return [
`filter${capitalize(nameSingular)}`,
{
not: {
id: {
in: pickedRecordIdsForMetadataItem,
},
},
},
];
})
.filter(isDefined),
);
const combinedSearchRecordsQueryExcludingPickedRecords =
generateCombinedSearchRecordsQuery({
objectMetadataItems: searchableObjectMetadataItems,
operationSignatures: searchableObjectMetadataItems.map(
(objectMetadataItem) => ({
objectNameSingular: objectMetadataItem.nameSingular,
variables: {},
}),
),
});
const limitPerMetadataItem = Object.fromEntries(
searchableObjectMetadataItems
.map(({ nameSingular }) => {
return [`limit${capitalize(nameSingular)}`, 10];
})
.filter(isDefined),
);
const { data: combinedSearchRecordExcludingPickedRecordsQueryResult } =
await client.query<CombinedFindManyRecordsQueryResult>({
query: combinedSearchRecordsQueryExcludingPickedRecords,
variables: {
search: searchFilter,
...limitPerMetadataItem,
...filterPerMetadataItemExcludingPickedRecordId,
},
});
const {
recordsWithObjectMetadataId:
recordsWithObjectMetadataIdExcludingPickedRecords,
} = multipleRecordPickerformatQueryResultAsRecordsWithObjectMetadataId({
objectMetadataItems: searchableObjectMetadataItems,
searchQueryResult: combinedSearchRecordExcludingPickedRecordsQueryResult,
});
return recordsWithObjectMetadataIdExcludingPickedRecords;
};

View File

@ -0,0 +1,7 @@
import { ComponentStateKeyV2 } from '@/ui/utilities/state/component-state/types/ComponentStateKeyV2';
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
type MultipleRecordPickerComponentInstanceContextProps = ComponentStateKeyV2;
export const MultipleRecordPickerComponentInstanceContext =
createComponentInstanceContext<MultipleRecordPickerComponentInstanceContextProps>();

View File

@ -0,0 +1,9 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const multipleRecordPickerIsLoadingComponentState =
createComponentStateV2<boolean>({
key: 'multipleRecordPickerIsLoadingComponentState',
defaultValue: false,
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
});

View File

@ -0,0 +1,10 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const multipleRecordPickerPickableMorphItemsComponentState =
createComponentStateV2<RecordPickerPickableMorphItem[]>({
key: 'multipleRecordPickerPickableMorphItemsComponentState',
defaultValue: [],
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
});

View File

@ -0,0 +1,9 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const multipleRecordPickerSearchFilterComponentState =
createComponentStateV2<string>({
key: 'multipleRecordPickerSearchFilterComponentState',
defaultValue: '',
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
});

View File

@ -0,0 +1,10 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const multipleRecordPickerSearchableObjectMetadataItemsComponentState =
createComponentStateV2<ObjectMetadataItem[]>({
key: 'multipleRecordPickerSearchableObjectMetadataItemsComponentState',
defaultValue: [],
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
});

View File

@ -0,0 +1,24 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { createComponentFamilySelectorV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilySelectorV2';
export const multipleRecordPickerIsSelectedComponentFamilySelector =
createComponentFamilySelectorV2<boolean, string>({
key: 'visibleRecordGroupIdsComponentFamilySelector',
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
get:
({ instanceId, familyKey: recordId }) =>
({ get }) => {
const pickableMorphItems = get(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId,
}),
);
const pickableMorphItem = pickableMorphItems.find(
({ recordId: itemRecordId }) => itemRecordId === recordId,
);
return pickableMorphItem?.isSelected ?? false;
},
});

View File

@ -0,0 +1,20 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { createComponentSelectorV2 } from '@/ui/utilities/state/component-state/utils/createComponentSelectorV2';
export const multipleRecordPickerPickableMorphItemsLengthComponentSelector =
createComponentSelectorV2({
key: 'multipleRecordPickerPickableMorphItemsLengthComponentSelector',
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
get:
({ instanceId }) =>
({ get }) => {
const pickableMorphItems = get(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId,
}),
);
return pickableMorphItems.length;
},
});

View File

@ -0,0 +1,22 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { createComponentSelectorV2 } from '@/ui/utilities/state/component-state/utils/createComponentSelectorV2';
export const multipleRecordPickerPickableRecordIdsMatchingSearchComponentSelector =
createComponentSelectorV2({
key: 'multipleRecordPickerPickableRecordIdsMatchingSearchComponentSelector',
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
get:
({ instanceId }) =>
({ get }) => {
const pickableMorphItems = get(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId,
}),
);
return pickableMorphItems
.filter(({ isMatchingSearchFilter }) => isMatchingSearchFilter)
.map(({ recordId }) => recordId);
},
});

View File

@ -0,0 +1,28 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { createComponentFamilySelectorV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilySelectorV2';
export const multipleRecordPickerSinglePickableMorphItemComponentFamilySelector =
createComponentFamilySelectorV2<
RecordPickerPickableMorphItem | undefined,
string
>({
key: 'multipleRecordPickerSinglePickableMorphItemComponentFamilySelector',
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
get:
({ instanceId, familyKey: recordId }) =>
({ get }) => {
const pickableMorphItems = get(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId,
}),
);
const pickableMorphItem = pickableMorphItems.find(
({ recordId: itemRecordId }) => itemRecordId === recordId,
);
return pickableMorphItem;
},
});

View File

@ -0,0 +1,3 @@
export enum MultipleRecordPickerHotkeyScope {
MultipleRecordPicker = 'multiple-record-picker',
}

View File

@ -1,4 +1,4 @@
import { SingleRecordPickerRecord } from '@/object-record/record-picker/types/SingleRecordPickerRecord';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
export type MultipleRecordPickerRecords<
CustomRecordForRecordPicker extends SingleRecordPickerRecord,

View File

@ -0,0 +1,5 @@
export const getMultipleRecordPickerSelectableListId = (
multipleRecordPickerComponentInstanceId: string,
) => {
return `${multipleRecordPickerComponentInstanceId}-selectable-list`;
};

View File

@ -0,0 +1,16 @@
import { CombinedFindManyRecordsQueryResult } from '@/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult';
export const multiRecordPickerFormatSearchResults = (
searchResults: CombinedFindManyRecordsQueryResult | undefined | null,
): CombinedFindManyRecordsQueryResult => {
if (!searchResults) {
return {};
}
return Object.entries(searchResults).reduce((acc, [key, value]) => {
let newKey = key.replace(/^search/, '');
newKey = newKey.charAt(0).toLowerCase() + newKey.slice(1);
acc[newKey] = value;
return acc;
}, {} as CombinedFindManyRecordsQueryResult);
};

View File

@ -0,0 +1,35 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { CombinedFindManyRecordsQueryResult } from '@/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult';
import { multiRecordPickerFormatSearchResults } from '@/object-record/record-picker/multiple-record-picker/utils/multiRecordPickerFormatSearchResults';
import { isDefined } from 'twenty-shared';
export const multipleRecordPickerformatQueryResultAsRecordsWithObjectMetadataId =
({
objectMetadataItems,
searchQueryResult,
}: {
objectMetadataItems: ObjectMetadataItem[];
searchQueryResult: CombinedFindManyRecordsQueryResult;
}) => {
const formattedSearchQueryResult =
multiRecordPickerFormatSearchResults(searchQueryResult);
const recordsWithObjectMetadataId = Object.entries(
formattedSearchQueryResult,
).flatMap(([namePlural, objectRecordConnection]) => {
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.namePlural === namePlural,
);
if (!isDefined(objectMetadataItem)) return [];
return objectRecordConnection.edges.map(({ node }) => ({
objectMetadataItem,
record: node,
}));
});
return {
recordsWithObjectMetadataId,
};
};

View File

@ -3,12 +3,14 @@ import { useRef } from 'react';
import {
SingleRecordPickerMenuItemsWithSearch,
SingleRecordPickerMenuItemsWithSearchProps,
} from '@/object-record/record-picker/components/SingleRecordPickerMenuItemsWithSearch';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
} from '@/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItemsWithSearch';
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from 'twenty-shared';
export const SINGLE_RECORD_PICKER_LISTENER_ID = 'single-record-select';
export type SingleRecordPickerProps = {
width?: number;
componentInstanceId: string;
@ -22,9 +24,9 @@ export const SingleRecordPicker = ({
onCreate,
onRecordSelected,
objectNameSingular,
selectedRecordIds,
width = 200,
componentInstanceId,
layoutDirection,
}: SingleRecordPickerProps) => {
const containerRef = useRef<HTMLDivElement>(null);
@ -41,11 +43,11 @@ export const SingleRecordPicker = ({
onCancel();
}
},
listenerId: 'single-record-select',
listenerId: SINGLE_RECORD_PICKER_LISTENER_ID,
});
return (
<RecordPickerComponentInstanceContext.Provider
<SingleRecordPickerComponentInstanceContext.Provider
value={{ instanceId: componentInstanceId }}
>
<DropdownMenu ref={containerRef} width={width} data-select-disable>
@ -58,10 +60,10 @@ export const SingleRecordPicker = ({
onCreate,
onRecordSelected,
objectNameSingular,
selectedRecordIds,
layoutDirection,
}}
/>
</DropdownMenu>
</RecordPickerComponentInstanceContext.Provider>
</SingleRecordPickerComponentInstanceContext.Provider>
);
};

View File

@ -2,10 +2,12 @@ import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { Avatar, MenuItemSelectAvatar } from 'twenty-ui';
import { RECORD_PICKER_SELECTABLE_LIST_COMPONENT_INSTANCE_ID } from '@/object-record/record-picker/constants/RecordPickerSelectableListComponentInstanceId';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/types/SingleRecordPickerRecord';
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { getSingleRecordPickerSelectableListId } from '@/object-record/record-picker/single-record-picker/utils/getSingleRecordPickerSelectableListId';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
type SingleRecordPickerMenuItemProps = {
record: SingleRecordPickerRecord;
@ -22,11 +24,20 @@ export const SingleRecordPickerMenuItem = ({
onRecordSelected,
selectedRecord,
}: SingleRecordPickerMenuItemProps) => {
const recordPickerComponentInstanceId =
useAvailableComponentInstanceIdOrThrow(
SingleRecordPickerComponentInstanceContext,
);
const selectableListComponentInstanceId =
getSingleRecordPickerSelectableListId(recordPickerComponentInstanceId);
const { isSelectedItemIdSelector } = useSelectableList(
RECORD_PICKER_SELECTABLE_LIST_COMPONENT_INSTANCE_ID,
selectableListComponentInstanceId,
);
const isSelectedItemId = useRecoilValue(isSelectedItemIdSelector(record.id));
return (
<StyledSelectableItem itemId={record.id} key={record.id}>
<MenuItemSelectAvatar

View File

@ -1,4 +1,4 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isNonEmptyString, isUndefined } from '@sniptt/guards';
import { useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
@ -11,10 +11,15 @@ import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectab
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { isDefined } from 'twenty-shared';
import { SingleRecordPickerMenuItem } from '@/object-record/record-picker/components/SingleRecordPickerMenuItem';
import { RECORD_PICKER_SELECTABLE_LIST_COMPONENT_INSTANCE_ID } from '@/object-record/record-picker/constants/RecordPickerSelectableListComponentInstanceId';
import { RecordPickerHotkeyScope } from '@/object-record/record-picker/types/RecordPickerHotkeyScope';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/types/SingleRecordPickerRecord';
import { SingleRecordPickerMenuItem } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItem';
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { getSingleRecordPickerSelectableListId } from '@/object-record/record-picker/single-record-picker/utils/getSingleRecordPickerSelectableListId';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import styled from '@emotion/styled';
export type SingleRecordPickerMenuItemsProps = {
EmptyIcon?: IconComponent;
@ -26,9 +31,12 @@ export type SingleRecordPickerMenuItemsProps = {
selectedRecord?: SingleRecordPickerRecord;
hotkeyScope?: string;
isFiltered: boolean;
shouldSelectEmptyOption?: boolean;
};
const StyledContainer = styled.div`
display: flex;
`;
export const SingleRecordPickerMenuItems = ({
EmptyIcon,
emptyLabel,
@ -37,9 +45,8 @@ export const SingleRecordPickerMenuItems = ({
onCancel,
onRecordSelected,
selectedRecord,
hotkeyScope = RecordPickerHotkeyScope.RecordPicker,
hotkeyScope = SingleRecordPickerHotkeyScope.SingleRecordPicker,
isFiltered,
shouldSelectEmptyOption,
}: SingleRecordPickerMenuItemsProps) => {
const containerRef = useRef<HTMLDivElement>(null);
@ -60,8 +67,16 @@ export const SingleRecordPickerMenuItems = ({
isDefined(entity) && isNonEmptyString(entity.name),
);
const recordPickerComponentInstanceId =
useAvailableComponentInstanceIdOrThrow(
SingleRecordPickerComponentInstanceContext,
);
const selectableListComponentInstanceId =
getSingleRecordPickerSelectableListId(recordPickerComponentInstanceId);
const { isSelectedItemIdSelector, resetSelectedItem } = useSelectableList(
RECORD_PICKER_SELECTABLE_LIST_COMPONENT_INSTANCE_ID,
selectableListComponentInstanceId,
);
const isSelectedSelectNoneButton = useRecoilValue(
@ -79,17 +94,21 @@ export const SingleRecordPickerMenuItems = ({
);
const selectableItemIds = recordsInDropdown.map((entity) => entity.id);
const [selectedRecordId, setSelectedRecordId] = useRecoilComponentStateV2(
singleRecordPickerSelectedIdComponentState,
);
return (
<div ref={containerRef}>
<StyledContainer ref={containerRef}>
<SelectableList
selectableListId={RECORD_PICKER_SELECTABLE_LIST_COMPONENT_INSTANCE_ID}
selectableListId={selectableListComponentInstanceId}
selectableItemIdArray={selectableItemIds}
hotkeyScope={hotkeyScope}
onEnter={(itemId) => {
const recordIndex = recordsInDropdown.findIndex(
(record) => record.id === itemId,
);
setSelectedRecordId(itemId);
onRecordSelected(recordsInDropdown[recordIndex]);
resetSelectedItem();
}}
@ -107,10 +126,13 @@ export const SingleRecordPickerMenuItems = ({
emptyLabel && (
<MenuItemSelect
key={record.id}
onClick={() => onRecordSelected()}
onClick={() => {
setSelectedRecordId(undefined);
onRecordSelected();
}}
LeftIcon={EmptyIcon}
text={emptyLabel}
selected={shouldSelectEmptyOption === true}
selected={isUndefined(selectedRecordId)}
hovered={isSelectedSelectNoneButton}
/>
)
@ -131,6 +153,6 @@ export const SingleRecordPickerMenuItems = ({
)}
</DropdownMenuItemsContainer>
</SelectableList>
</div>
</StyledContainer>
);
};

View File

@ -1,11 +1,12 @@
import {
SingleRecordPickerMenuItems,
SingleRecordPickerMenuItemsProps,
} from '@/object-record/record-picker/components/SingleRecordPickerMenuItems';
import { useRecordPickerRecordsOptions } from '@/object-record/record-picker/hooks/useRecordPickerRecordsOptions';
import { useRecordSelectSearch } from '@/object-record/record-picker/hooks/useRecordSelectSearch';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { recordPickerSearchFilterComponentState } from '@/object-record/record-picker/states/recordPickerSearchFilterComponentState';
} from '@/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItems';
import { useSingleRecordPickerRecords } from '@/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerRecords';
import { useSingleRecordPickerSearch } from '@/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerSearch';
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 { RecordPickerLayoutDirection } from '@/object-record/record-picker/types/RecordPickerLayoutDirection';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNewButton';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@ -13,18 +14,15 @@ import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/Dropdow
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { Placement } from '@floating-ui/react';
import { isDefined } from 'twenty-shared';
import { IconPlus } from 'twenty-ui';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export type SingleRecordPickerMenuItemsWithSearchProps = {
excludedRecordIds?: string[];
onCreate?: ((searchInput?: string) => void) | (() => void);
objectNameSingular: string;
recordPickerInstanceId?: string;
selectedRecordIds: string[];
dropdownPlacement?: Placement | null;
layoutDirection?: RecordPickerLayoutDirection;
} & Pick<
SingleRecordPickerMenuItemsProps,
| 'EmptyIcon'
@ -42,25 +40,23 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
onCreate,
onRecordSelected,
objectNameSingular,
selectedRecordIds,
dropdownPlacement,
layoutDirection = 'search-bar-on-top',
}: SingleRecordPickerMenuItemsWithSearchProps) => {
const { handleSearchFilterChange } = useRecordSelectSearch();
const { handleSearchFilterChange } = useSingleRecordPickerSearch();
const recordPickerInstanceId = useAvailableComponentInstanceIdOrThrow(
RecordPickerComponentInstanceContext,
SingleRecordPickerComponentInstanceContext,
);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const recordPickerSearchFilter = useRecoilComponentValueV2(
recordPickerSearchFilterComponentState,
singleRecordPickerSearchFilterComponentState,
recordPickerInstanceId,
);
const { records } = useRecordPickerRecordsOptions({
const { records } = useSingleRecordPickerRecords({
objectNameSingular,
selectedRecordIds,
excludedRecordIds,
});
@ -72,12 +68,9 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
/>
);
const shouldDisplayDropdownMenuItems =
records.recordsToSelect.length + records.selectedRecords?.length > 0;
return (
<>
{dropdownPlacement?.includes('end') && (
{layoutDirection === 'search-bar-on-bottom' && (
<>
{isDefined(onCreate) && !hasObjectReadOnlyPermission && (
<DropdownMenuItemsContainer scrollable={false}>
@ -85,22 +78,18 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
</DropdownMenuItemsContainer>
)}
{records.recordsToSelect.length > 0 && <DropdownMenuSeparator />}
{shouldDisplayDropdownMenuItems && (
<SingleRecordPickerMenuItems
recordsToSelect={records.recordsToSelect}
loading={records.loading}
selectedRecord={records.selectedRecords?.[0]}
shouldSelectEmptyOption={selectedRecordIds?.length === 0}
hotkeyScope={recordPickerInstanceId}
isFiltered={!!recordPickerSearchFilter}
{...{
EmptyIcon,
emptyLabel,
onCancel,
onRecordSelected,
}}
/>
)}
<SingleRecordPickerMenuItems
recordsToSelect={records.recordsToSelect}
loading={records.loading}
selectedRecord={records.selectedRecords?.[0]}
isFiltered={!!recordPickerSearchFilter}
{...{
EmptyIcon,
emptyLabel,
onCancel,
onRecordSelected,
}}
/>
<DropdownMenuSeparator />
</>
)}
@ -109,26 +98,21 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
autoFocus
role="combobox"
/>
{(dropdownPlacement?.includes('start') ||
isUndefinedOrNull(dropdownPlacement)) && (
{layoutDirection === 'search-bar-on-top' && (
<>
<DropdownMenuSeparator />
{shouldDisplayDropdownMenuItems && (
<SingleRecordPickerMenuItems
recordsToSelect={records.recordsToSelect}
loading={records.loading}
selectedRecord={records.selectedRecords?.[0]}
shouldSelectEmptyOption={selectedRecordIds?.length === 0}
hotkeyScope={recordPickerInstanceId}
isFiltered={!!recordPickerSearchFilter}
{...{
EmptyIcon,
emptyLabel,
onCancel,
onRecordSelected,
}}
/>
)}
<SingleRecordPickerMenuItems
recordsToSelect={records.recordsToSelect}
loading={records.loading}
selectedRecord={records.selectedRecords?.[0]}
isFiltered={!!recordPickerSearchFilter}
{...{
EmptyIcon,
emptyLabel,
onCancel,
onRecordSelected,
}}
/>
{records.recordsToSelect.length > 0 && isDefined(onCreate) && (
<DropdownMenuSeparator />
)}

View File

@ -10,8 +10,8 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
import { allMockPersonRecords } from '~/testing/mock-data/people';
import { sleep } from '~/utils/sleep';
import { SingleRecordPicker } from '@/object-record/record-picker/components/SingleRecordPicker';
import { SingleRecordPickerRecord } from '../../types/SingleRecordPickerRecord';
import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
const records = allMockPersonRecords.map<SingleRecordPickerRecord>(
(person) => ({
@ -34,7 +34,6 @@ const meta: Meta<typeof SingleRecordPicker> = {
],
args: {
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
selectedRecordIds: [],
componentInstanceId: 'single-record-picker',
},
argTypes: {

View File

@ -0,0 +1,2 @@
export const SINGLE_RECORD_PICKER_CLICK_OUTSIDE_LISTENER_ID =
'single-record-picker-click-outside-listener';

View File

@ -2,25 +2,25 @@ import { act, renderHook } from '@testing-library/react';
import { ChangeEvent } from 'react';
import { RecoilRoot } from 'recoil';
import { useRecordSelectSearch } from '@/object-record/record-picker/hooks/useRecordSelectSearch';
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { recordPickerSearchFilterComponentState } from '@/object-record/record-picker/states/recordPickerSearchFilterComponentState';
import { useSingleRecordPickerSearch } from '@/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerSearch';
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 { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
const instanceId = 'instanceId';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<RecordPickerComponentInstanceContext.Provider value={{ instanceId }}>
<SingleRecordPickerComponentInstanceContext.Provider value={{ instanceId }}>
<RecoilRoot>{children}</RecoilRoot>
</RecordPickerComponentInstanceContext.Provider>
</SingleRecordPickerComponentInstanceContext.Provider>
);
describe('useRecordSelectSearch', () => {
describe('useSingleRecordPickerRecords', () => {
it('should update searchFilter after change event', async () => {
const { result } = renderHook(
() => {
const recordSelectSearchHook = useRecordSelectSearch(instanceId);
const recordSelectSearchHook = useSingleRecordPickerSearch(instanceId);
const internallyStoredFilter = useRecoilComponentValueV2(
recordPickerSearchFilterComponentState,
singleRecordPickerSearchFilterComponentState,
instanceId,
);
return { recordSelectSearchHook, internallyStoredFilter };

View File

@ -0,0 +1,29 @@
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 { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const useSingleRecordPickerRecords = ({
objectNameSingular,
excludedRecordIds = [],
}: {
objectNameSingular: string;
excludedRecordIds?: string[];
}) => {
const recordPickerSearchFilter = useRecoilComponentValueV2(
singleRecordPickerSearchFilterComponentState,
);
const selectedRecordId = useRecoilComponentValueV2(
singleRecordPickerSelectedIdComponentState,
);
const records = useFilteredSearchRecordQuery({
searchFilter: recordPickerSearchFilter,
selectedIds: selectedRecordId ? [selectedRecordId] : [],
excludedRecordIds: excludedRecordIds,
objectNameSingular,
});
return { records };
};

View File

@ -1,26 +1,26 @@
import { RecordPickerComponentInstanceContext } from '@/object-record/record-picker/states/contexts/RecordPickerComponentInstanceContext';
import { recordPickerPreselectedIdComponentState } from '@/object-record/record-picker/states/recordPickerPreselectedIdComponentState';
import { recordPickerSearchFilterComponentState } from '@/object-record/record-picker/states/recordPickerSearchFilterComponentState';
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 { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useDebouncedCallback } from 'use-debounce';
export const useRecordSelectSearch = (
export const useSingleRecordPickerSearch = (
recordPickerComponentInstanceIdFromProps?: string,
) => {
const recordPickerComponentInstanceId =
useAvailableComponentInstanceIdOrThrow(
RecordPickerComponentInstanceContext,
SingleRecordPickerComponentInstanceContext,
recordPickerComponentInstanceIdFromProps,
);
const setRecordPickerSearchFilter = useSetRecoilComponentStateV2(
recordPickerSearchFilterComponentState,
singleRecordPickerSearchFilterComponentState,
recordPickerComponentInstanceId,
);
const setRecordPickerPreselectedId = useSetRecoilComponentStateV2(
recordPickerPreselectedIdComponentState,
const setRecordPickerSelectedId = useSetRecoilComponentStateV2(
singleRecordPickerSelectedIdComponentState,
recordPickerComponentInstanceId,
);
const debouncedSetSearchFilter = useDebouncedCallback(
@ -33,14 +33,14 @@ export const useRecordSelectSearch = (
const resetSearchFilter = () => {
debouncedSetSearchFilter('');
setRecordPickerPreselectedId('');
setRecordPickerSelectedId(undefined);
};
const handleSearchFilterChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
debouncedSetSearchFilter(event.currentTarget.value);
setRecordPickerPreselectedId('');
setRecordPickerSelectedId(undefined);
};
return {

Some files were not shown because too many files have changed in this diff Show More